diff --git a/.env b/.env index f1bf980b5b..136531ec2d 100644 --- a/.env +++ b/.env @@ -115,6 +115,13 @@ DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION="" # The name of the S3 bucket where scan output should be stored DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET="" +# The storage address the browser can reach, used only to sign report download URLs +# (e.g. "https://storage.example.com"). Leave empty on AWS S3. Set it when storage is +# only reachable inside the container network, such as MinIO on "http://minio:9000". +# The reverse proxy in front of it must forward the Host header unchanged: SigV4 signs +# Host, so rewriting it to the internal name invalidates the signature. +DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="" + # Django settings DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,prowler-api DJANGO_BIND_ADDRESS=0.0.0.0 @@ -158,7 +165,7 @@ SENTRY_RELEASE=local # REO_DEV_CLIENT_ID= #### Prowler release version #### -NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.41.0 +NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.43.0 # Social login credentials SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google" diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ed97ff5a1a..dc1e714b47 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,23 +1,23 @@ # SDK -/* @prowler-cloud/detection-remediation -/prowler/ @prowler-cloud/detection-remediation -/tests/ @prowler-cloud/detection-remediation -/dashboard/ @prowler-cloud/detection-remediation -/docs/ @prowler-cloud/detection-remediation -/examples/ @prowler-cloud/detection-remediation -/util/ @prowler-cloud/detection-remediation -/contrib/ @prowler-cloud/detection-remediation -/permissions/ @prowler-cloud/detection-remediation -/codecov.yml @prowler-cloud/detection-remediation @prowler-cloud/api +/* @prowler-cloud/engineering +/prowler/ @prowler-cloud/engineering +/tests/ @prowler-cloud/engineering +/dashboard/ @prowler-cloud/engineering +/docs/ @prowler-cloud/engineering +/examples/ @prowler-cloud/engineering +/util/ @prowler-cloud/engineering +/contrib/ @prowler-cloud/engineering +/permissions/ @prowler-cloud/engineering +/codecov.yml @prowler-cloud/engineering # API -/api/ @prowler-cloud/api +/api/ @prowler-cloud/engineering # UI -/ui/ @prowler-cloud/ui +/ui/ @prowler-cloud/engineering # AI -/mcp_server/ @prowler-cloud/detection-remediation +/mcp_server/ @prowler-cloud/engineering # Platform /.github/ @prowler-cloud/platform diff --git a/.github/actions/setup-python-uv/action.yml b/.github/actions/setup-python-uv/action.yml index d3293004a9..4dba54e7f4 100644 --- a/.github/actions/setup-python-uv/action.yml +++ b/.github/actions/setup-python-uv/action.yml @@ -46,6 +46,17 @@ runs: env: GITHUB_TOKEN: ${{ github.token }} run: | + if grep -q "prowler-cloud/prowler" uv.lock; then + : + else + status=$? + if [ "$status" -ne 1 ]; then + echo "::error::grep failed reading uv.lock (exit code $status)." + exit "$status" + fi + echo "No prowler-cloud/prowler entry in uv.lock, nothing to update." + exit 0 + fi LATEST_COMMIT=$(curl -sf --retry 3 --retry-all-errors --retry-delay 2 --retry-max-time 60 \ -H "Authorization: Bearer ${GITHUB_TOKEN}" \ -H "Accept: application/vnd.github+json" \ @@ -66,6 +77,17 @@ runs: env: GITHUB_TOKEN: ${{ github.token }} run: | + if grep -q "prowler-cloud/prowler" uv.lock; then + : + else + status=$? + if [ "$status" -ne 1 ]; then + echo "::error::grep failed reading uv.lock (exit code $status)." + exit "$status" + fi + echo "No prowler-cloud/prowler entry in uv.lock, nothing to update." + exit 0 + fi LATEST_COMMIT=$(curl -sf --retry 3 --retry-all-errors --retry-delay 2 --retry-max-time 60 \ -H "Authorization: Bearer ${GITHUB_TOKEN}" \ -H "Accept: application/vnd.github+json" \ diff --git a/.github/test-impact.yml b/.github/test-impact.yml index 874e9eba50..67c106d58b 100644 --- a/.github/test-impact.yml +++ b/.github/test-impact.yml @@ -451,6 +451,17 @@ modules: e2e: - ui/tests/home/** + - name: ui-registry + match: + - ui/actions/registry/** + - ui/app/**/registry/** + - ui/components/registry/** + - ui/lib/registry/** + - ui/tests/registry/** + tests: [] + e2e: + - ui/tests/registry/** + - name: ui-shadcn match: - ui/components/shadcn/** diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index 8563f43038..0c6ef3ad96 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -39,7 +39,7 @@ jobs: - name: Check labels id: label_check - uses: agilepathway/label-checker@c3d16ad512e7cea5961df85ff2486bb774caf3c5 # v1.6.65 + uses: agilepathway/label-checker@c324842522fbd012e4f590afe3b4e591301322ed # v1.6.66 with: allow_failure: true prefix_mode: true diff --git a/.github/workflows/sdk-refresh-aws-services-regions.yml b/.github/workflows/sdk-refresh-aws-services-regions.yml index 5a38858247..075852c6a6 100644 --- a/.github/workflows/sdk-refresh-aws-services-regions.yml +++ b/.github/workflows/sdk-refresh-aws-services-regions.yml @@ -44,7 +44,10 @@ jobs: cache: 'pip' - name: Install dependencies - run: pip install boto3 + # Pinned to the versions in pyproject.toml: the ISO partitions region + # data comes from the endpoints.json bundled with botocore, so the + # botocore version is itself a data source and must be deterministic + run: pip install boto3==1.40.61 botocore==1.40.61 - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1 diff --git a/.github/workflows/ui-e2e-tests-v2.yml b/.github/workflows/ui-e2e-tests-v2.yml index 2a384597c3..2bf6db8a5a 100644 --- a/.github/workflows/ui-e2e-tests-v2.yml +++ b/.github/workflows/ui-e2e-tests-v2.yml @@ -10,12 +10,12 @@ on: - master - "v5.*" paths: - - '.github/workflows/ui-e2e-tests-v2.yml' - - '.github/test-impact.yml' - - 'ui/**' - - 'api/**' # API changes can affect UI E2E - - '!ui/CHANGELOG.md' - - '!api/CHANGELOG.md' + - ".github/workflows/ui-e2e-tests-v2.yml" + - ".github/test-impact.yml" + - "ui/**" + - "api/**" # API changes can affect UI E2E + - "!ui/CHANGELOG.md" + - "!api/CHANGELOG.md" concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -40,11 +40,11 @@ jobs: (needs.impact-analysis.outputs.has-ui-e2e == 'true' || needs.impact-analysis.outputs.run-all == 'true') runs-on: ubuntu-latest env: - AUTH_SECRET: 'fallback-ci-secret-for-testing' + AUTH_SECRET: "fallback-ci-secret-for-testing" AUTH_TRUST_HOST: true - NEXTAUTH_URL: 'http://localhost:3000' - AUTH_URL: 'http://localhost:3000' - UI_API_BASE_URL: 'http://localhost:8080/api/v1' + NEXTAUTH_URL: "http://localhost:3000" + AUTH_URL: "http://localhost:3000" + UI_API_BASE_URL: "http://localhost:8080/api/v1" E2E_ADMIN_USER: ${{ secrets.E2E_ADMIN_USER }} E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }} E2E_AWS_PROVIDER_ACCOUNT_ID: ${{ secrets.E2E_AWS_PROVIDER_ACCOUNT_ID }} @@ -60,7 +60,7 @@ jobs: E2E_M365_SECRET_ID: ${{ secrets.E2E_M365_SECRET_ID }} E2E_M365_TENANT_ID: ${{ secrets.E2E_M365_TENANT_ID }} E2E_M365_CERTIFICATE_CONTENT: ${{ secrets.E2E_M365_CERTIFICATE_CONTENT }} - E2E_KUBERNETES_CONTEXT: 'kind-kind' + E2E_KUBERNETES_CONTEXT: "kind-kind" E2E_KUBERNETES_KUBECONFIG_PATH: /home/runner/.kube/config E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY: ${{ secrets.E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY }} E2E_GCP_PROJECT_ID: ${{ secrets.E2E_GCP_PROJECT_ID }} @@ -292,7 +292,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: - node-version-file: 'ui/.nvmrc' + node-version-file: "ui/.nvmrc" - name: Setup pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 @@ -337,60 +337,59 @@ jobs: if: steps.playwright-cache.outputs.cache-hit != 'true' run: pnpm run test:e2e:install - - name: Run E2E tests + - name: Run standard E2E tests + id: standard-e2e working-directory: ./ui run: | if [[ "${RUN_ALL_TESTS}" == "true" ]]; then - echo "Running ALL E2E tests..." + echo "Running all standard E2E tests..." pnpm run test:e2e else - echo "Running targeted E2E tests: ${E2E_TEST_PATHS}" - # Convert glob patterns to playwright test paths - # e.g., "ui/tests/providers/**" -> "tests/providers" + echo "Running targeted standard E2E tests: ${E2E_TEST_PATHS}" TEST_PATHS="${E2E_TEST_PATHS}" - # Remove ui/ prefix and convert ** to empty (playwright handles recursion) TEST_PATHS=$(echo "$TEST_PATHS" | sed 's|ui/||g' | sed 's|\*\*||g' | tr ' ' '\n' | sort -u) - # Drop auth setup helpers (not runnable test suites) - TEST_PATHS=$(echo "$TEST_PATHS" | grep -v '^tests/setups/') - # Safety net: if bare "tests/" appears (from broad patterns like ui/tests/**), - # expand to specific subdirs to avoid Playwright discovering setup files + TEST_PATHS=$(echo "$TEST_PATHS" | grep -vE '^tests/(setups|registry)/' || true) + if echo "$TEST_PATHS" | grep -qx 'tests/'; then - echo "Expanding bare 'tests/' to specific subdirs (excluding setups)..." SPECIFIC_DIRS="" for dir in tests/*/; do - [[ "$dir" == "tests/setups/" ]] && continue + [[ "$dir" == "tests/setups/" || "$dir" == "tests/registry/" ]] && continue SPECIFIC_DIRS="${SPECIFIC_DIRS}${dir}"$'\n' done - # Replace "tests/" with specific dirs, keep other paths - TEST_PATHS=$(echo "$TEST_PATHS" | grep -vx 'tests/') + TEST_PATHS=$(echo "$TEST_PATHS" | grep -vx 'tests/' || true) TEST_PATHS="${TEST_PATHS}"$'\n'"${SPECIFIC_DIRS}" TEST_PATHS=$(echo "$TEST_PATHS" | grep -v '^$' | sort -u) fi - if [[ -z "$TEST_PATHS" ]]; then - echo "No runnable E2E test paths after filtering setups" - exit 0 - fi - # Filter out directories that don't contain any test files + VALID_PATHS="" - while IFS= read -r p; do - [[ -z "$p" ]] && continue - if find "$p" -name '*.spec.ts' -o -name '*.test.ts' 2>/dev/null | head -1 | grep -q .; then - VALID_PATHS="${VALID_PATHS}${p}"$'\n' + while IFS= read -r path; do + [[ -z "$path" ]] && continue + if find "$path" -name '*.spec.ts' -o -name '*.test.ts' 2>/dev/null | head -1 | grep -q .; then + VALID_PATHS="${VALID_PATHS}${path}"$'\n' else - echo "Skipping empty test directory: $p" + echo "Skipping empty test directory: $path" fi done <<< "$TEST_PATHS" VALID_PATHS=$(echo "$VALID_PATHS" | grep -v '^$' || true) - if [[ -z "$VALID_PATHS" ]]; then - echo "No test files found in any resolved paths — skipping E2E" - exit 0 + + if [[ -n "$VALID_PATHS" ]]; then + TEST_PATHS=$(echo "$VALID_PATHS" | tr '\n' ' ') + echo "Resolved standard test paths: $TEST_PATHS" + read -ra test_paths <<< "$TEST_PATHS" + pnpm exec playwright test "${test_paths[@]}" + else + echo "No standard E2E test paths selected." fi - TEST_PATHS=$(echo "$VALID_PATHS" | tr '\n' ' ') - echo "Resolved test paths: $TEST_PATHS" - read -ra test_paths <<< "$TEST_PATHS" - pnpm exec playwright test "${test_paths[@]}" fi + - name: Run Registry fixture E2E tests + if: | + !cancelled() && + (steps.standard-e2e.outcome == 'success' || steps.standard-e2e.outcome == 'failure') && + (env.RUN_ALL_TESTS == 'true' || contains(format(' {0} ', env.E2E_TEST_PATHS), ' ui/tests/registry/')) + working-directory: ./ui + run: pnpm run test:e2e:registry + - name: Upload test reports uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 if: failure() diff --git a/.grype.yaml b/.grype.yaml index 8fe74513c9..26fec99bdd 100644 --- a/.grype.yaml +++ b/.grype.yaml @@ -17,6 +17,40 @@ ignore: - 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 diff --git a/.trivyignore.yaml b/.trivyignore.yaml index b78162ecc4..f572065625 100644 --- a/.trivyignore.yaml +++ b/.trivyignore.yaml @@ -113,40 +113,82 @@ vulnerabilities: purls: - "pkg:npm/fast-uri" expired_at: 2027-01-31 + - id: CVE-2026-75899 + purls: + - "pkg:npm/fast-uri" + expired_at: 2027-01-31 + - id: CVE-2026-75975 + purls: + - "pkg:npm/fast-uri" + expired_at: 2027-01-31 + - id: CVE-2026-76172 + purls: + - "pkg:npm/fast-uri" + expired_at: 2027-01-31 + - id: CVE-2026-75931 + purls: + - "pkg:npm/fast-uri" + expired_at: 2027-01-31 - id: CVE-2026-69192 purls: - "pkg:npm/ip-address" expired_at: 2027-01-31 - # CVE-2026-62901 is a DoS in System.Net.WebSockets (unchecked input for loop condition, - # CWE-606), fixed in .NET 9.0.19 / 10.0.11 (published 2026-08-11). The vulnerable runtime - # ships inside the PowerShell tarball the Dockerfile pins: 7.5.9 is the latest 7.5.x and - # bundles .NET 9.0.18; 7.6.4 bundles .NET 10.0.x < 10.0.11, so no published PowerShell - # release contains the fix yet. Prowler only invokes pwsh locally to run M365 module - # cmdlets; the image does not accept inbound WebSocket connections, so the DoS path is - # not reachable from the network. Remove this temporary suppression as soon as a - # PowerShell release shipping .NET 9.0.19+ is available. - - id: CVE-2026-62901 + # CVE-2026-84304 is a DoS in grpc-go <= 1.83.0: a peer fragments a gRPC stream into + # millions of tiny HTTP/2 DATA frames until the receiver runs out of heap. Fixed in + # 1.83.1 (published 2026-09-01). Trivy 0.74.0, the latest published release and the + # version the images ship, pins 1.82.1 as an indirect dependency: + # https://github.com/aquasecurity/trivy/blob/v0.74.0/go.mod + # Upstream bump still open: https://github.com/aquasecurity/trivy/pull/11176 + # Trivy only speaks gRPC in client/server mode (`trivy server`, `--server`). Prowler + # invokes it exclusively as `trivy image` and `trivy fs` on a local path, so no gRPC + # listener or connection ever exists in the image and the affected path is not + # reachable. Remove this temporary suppression as soon as a Trivy release pins + # grpc >= 1.83.1. + - id: CVE-2026-84304 purls: - - "pkg:nuget/Microsoft.NETCore.App.Runtime.linux-x64" - - "pkg:nuget/Microsoft.NETCore.App.Runtime.linux-arm64" - expired_at: 2026-09-15 + - "pkg:golang/google.golang.org/grpc" + expired_at: 2026-10-15 - # Modules compiled into the Trivy binary the images ship. The binary is pinned by version - # and verified by checksum in the Dockerfile; only a rebuild by its vendor moves these. - # CVE-2026-71556 affects go-git worktree operations that can follow symlinks outside a - # cloned repository. Trivy 0.73.0, the latest published release and the version the - # images ship, still pins that vulnerable version: - # https://github.com/aquasecurity/trivy/blob/v0.73.0/go.mod#L46 - # Trivy main already contains the 5.19.2 fix, but no published release includes it yet: - # https://github.com/aquasecurity/trivy/commit/a2edba9a03987ba0d2ebc8212c1a9a1e6979497b - # Prowler invokes Trivy only with `fs` on an existing local path or with `image`; it does - # not ask Trivy to clone or mutate a Git worktree, so the affected path is not reachable. - # Remove this temporary suppression as soon as a fixed Trivy release is available. - - id: CVE-2026-71556 + # CVE-2026-84445 is a DoS in grpc-go servers built with `xds.NewGRPCServer()`: a request + # carrying neither `:authority` nor `Host` reaches the xDS routing interceptor, which + # indexes an empty slice of authorities and panics. The per-RPC goroutine does not + # recover, so the whole server process dies. Fixed in 1.82.2 and 1.83.2 (published + # 2026-09-08). Trivy 0.74.0, the latest published release and the version the images + # ship, pins 1.82.1 as an indirect dependency: + # https://github.com/aquasecurity/trivy/blob/v0.74.0/go.mod + # Trivy main already carries 1.83.2, but no published release includes it yet. + # The reachability argument is the one made for CVE-2026-84304 above, only narrower: + # this panic needs an xDS-managed gRPC server. Prowler invokes Trivy exclusively as + # `trivy image` and `trivy fs` on a local path, never `trivy server`, so the image runs + # no gRPC server at all, xDS or otherwise. Remove this temporary suppression as soon as + # a Trivy release pins grpc >= 1.83.2. + # https://github.com/advisories/GHSA-2v4p-qf9q-27wj + - id: CVE-2026-84445 purls: - - "pkg:golang/github.com/go-git/go-git/v5" - expired_at: 2026-09-15 + - "pkg:golang/google.golang.org/grpc@v1.82.1" + expired_at: 2026-10-15 + + # CVE-2026-56855 and CVE-2026-78662 are DoS deadlocks in x/crypto/ssh: a malicious peer + # can flood or misuse channel messages (RFC 4254) to block the whole connection. + # Fixed in golang.org/x/crypto v0.56.0 (published 2026-09-02). Trivy 0.74.0, the latest + # published release and the version the images ship, still pins v0.55.0, and Trivy main + # has not bumped it either: + # https://github.com/aquasecurity/trivy/blob/v0.74.0/go.mod + # x/crypto/ssh is pulled in transitively through go-git's ssh transport, the same + # dependency chain as the CVE-2026-71556 entry above. Prowler invokes Trivy only with + # `fs` on an existing local path or with `image`; it never asks Trivy to clone over SSH + # or to run `trivy server`, so no SSH connection -- as client or server -- ever exists in + # the image and the affected code path is not reachable. Remove this temporary + # suppression as soon as a fixed Trivy release is available. + - id: CVE-2026-56855 + purls: + - "pkg:golang/golang.org/x/crypto@v0.55.0" + expired_at: 2026-10-15 + - id: CVE-2026-78662 + purls: + - "pkg:golang/golang.org/x/crypto@v0.55.0" + expired_at: 2026-10-15 - id: CVE-2026-56852 purls: diff --git a/Dockerfile b/Dockerfile index fdc85831de..dc62fd64a6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM python:3.12.13-slim-trixie@sha256:57cd7c3a7a273101a6485ba99423ee56815788280 LABEL maintainer="https://github.com/prowler-cloud/prowler" LABEL org.opencontainers.image.source="https://github.com/prowler-cloud/prowler" -ARG POWERSHELL_VERSION=7.5.9 +ARG POWERSHELL_VERSION=7.5.11 ENV POWERSHELL_VERSION=${POWERSHELL_VERSION} # Opt out of PowerShell telemetry (Application Insights -> dc.services.visualstudio.com) ENV POWERSHELL_TELEMETRY_OPTOUT=1 @@ -17,25 +17,30 @@ ENV ZIZMOR_VERSION=${ZIZMOR_VERSION} # Pinned here, not fetched with the artefact: a compromised release ships its own checksum. ARG TRIVY_SHA256_AMD64=2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a ARG TRIVY_SHA256_ARM64=b94ce1976bbf3c15b514b605ee88be7c6d94a29be2302847ff01cb794d47aad5 -ARG POWERSHELL_SHA256_AMD64=492ff26bb958336bf61e597ce19e07648b4003bd2a08659e02f0e3e0446ebfe0 -ARG POWERSHELL_SHA256_ARM64=2503b71da3e83635592b092df59a0aca4c3606b4d9b068217bb00be989cb0d56 +ARG POWERSHELL_SHA256_AMD64=82a8b13d92b0f3ae48e56cf2f3f7961679371736ca90145ca71617c2913ba9d8 +ARG POWERSHELL_SHA256_ARM64=830ebda118c731ece3fa7e6b7e8573a21346387cbbca5b2f5e3b9bfe24f96672 ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03 ARG ZIZMOR_SHA256_ARM64=d66e37ef8a375fb07939c630ebf9709a6e0f20242bdc3faf672a7ed97e0b768d -# High CVEs fixed in Debian trixie-security but not yet in the pinned base image: +# High CVEs fixed in Debian trixie but not yet in the pinned base image: # openssl/libssl3t64/openssl-provider-legacy 3.5.7-1~deb13u2 CVE-2026-14456, # -14457, -18798, -54874, -63072, -63073, -63074, -63075, -63076, -75803 -# (image ships 3.5.6-1~deb13u2) +# libsqlite3-0 3.46.1-7+deb13u2 CVE-2026-11822, -11824 +# gzip 1.13-1+deb13u1 CVE-2026-41992 +# perl-base 5.40.1-6+deb13u1 CVE-2026-42497, -48962, -57432 +# libssh2-1t64 1.11.1-1+deb13u2 CVE-2026-58050 +# libpcre2-8-0 10.46-1~deb13u2 CVE-2026-86145, -89161 # Taken as a targeted --only-upgrade rather than by moving the digest: the newest -# published python:3.12-slim-trixie carries the same vulnerable version. The three -# packages are all built from openssl and are flagged separately, so all are named. -# Drop them once the base image ships 3.5.7-1~deb13u2 or later. +# published python:3.12-slim-trixie carries the same vulnerable versions. The three +# openssl packages are flagged separately, so all are named. +# Drop each one once the base image ships its fixed version. # hadolint ignore=DL3008 RUN apt-get update && apt-get install -y --no-install-recommends \ wget libicu76 libunwind8 libssl3 libcurl4 ca-certificates apt-transport-https gnupg \ build-essential pkg-config libzstd-dev zlib1g-dev \ && apt-get install -y --no-install-recommends --only-upgrade \ util-linux libssl3t64 openssl openssl-provider-legacy \ + libsqlite3-0 gzip perl-base libssh2-1t64 libpcre2-8-0 \ && rm -rf /var/lib/apt/lists/* # Install PowerShell diff --git a/README.md b/README.md index de5c235dd7..2851101f05 100644 --- a/README.md +++ b/README.md @@ -126,12 +126,12 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically | Provider | Checks | Services | [Compliance Frameworks](https://docs.prowler.com/user-guide/compliance/tutorials/compliance) | [Categories](https://docs.prowler.com/user-guide/cli/tutorials/misc#categories) | Support | Interface | |---|---|---|---|---|---|---| -| AWS | 639 | 86 | 47 | 19 | Official | UI, API, CLI | -| Azure | 191 | 22 | 21 | 16 | Official | UI, API, CLI | -| GCP | 109 | 20 | 19 | 12 | Official | UI, API, CLI | -| Kubernetes | 92 | 7 | 8 | 11 | Official | UI, API, CLI | +| AWS | 662 | 86 | 50 | 19 | Official | UI, API, CLI | +| Azure | 191 | 22 | 25 | 16 | Official | UI, API, CLI | +| GCP | 110 | 20 | 22 | 12 | Official | UI, API, CLI | +| Kubernetes | 92 | 7 | 11 | 11 | Official | UI, API, CLI | | GitHub | 24 | 3 | 2 | 5 | Official | UI, API, CLI | -| M365 | 143 | 10 | 6 | 10 | Official | UI, API, CLI | +| M365 | 144 | 10 | 9 | 10 | Official | UI, API, CLI | | OCI | 52 | 14 | 5 | 10 | Official | UI, API, CLI | | Alibaba Cloud | 63 | 9 | 6 | 9 | Official | UI, API, CLI | | Cloudflare | 29 | 3 | 2 | 5 | Official | UI, API, CLI | @@ -139,7 +139,7 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically | MongoDB Atlas | 10 | 3 | 1 | 8 | Official | UI, API, CLI | | LLM | [See `promptfoo` docs.](https://www.promptfoo.dev/docs/red-team/plugins/) | N/A | N/A | N/A | Official | CLI | | Image | N/A | N/A | N/A | N/A | Official | UI, API, CLI | -| Google Workspace | 65 | 11 | 3 | 6 | Official | UI, API, CLI | +| Google Workspace | 65 | 11 | 4 | 6 | Official | UI, API, CLI | | OpenStack | 34 | 5 | 1 | 9 | Official | UI, API, CLI | | Vercel | 26 | 6 | 1 | 8 | Official | UI, API, CLI | | Okta | 29 | 8 | 2 | 2 | Official | UI, API, CLI | diff --git a/api/.env.example b/api/.env.example index f97d868890..c9300ab6b9 100644 --- a/api/.env.example +++ b/api/.env.example @@ -59,5 +59,9 @@ DJANGO_GITHUB_OAUTH_CLIENT_ID="" DJANGO_GITHUB_OAUTH_CLIENT_SECRET="" DJANGO_GITHUB_OAUTH_CALLBACK_URL="" +# Public base URL of the Prowler UI, used to link Jira issues back to findings. +# Leave empty to omit the link. +DJANGO_UI_BASE_URL="" + # Deletion Task Batch Size DJANGO_DELETION_BATCH_SIZE=5000 diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 7b05c116bc..318cd30d4f 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -4,6 +4,26 @@ All notable changes to the **Prowler API** are documented in this file. +## [1.43.0] (Prowler v5.42.0) + +### 🔄 Changed + +- Speed up compliance overview ingestion by reading ThreatScore mappings from the compliance template instead of each finding, generating time-ordered `uuid7` row ids and grouping inserted rows by framework and requirement [(#12738)](https://github.com/prowler-cloud/prowler/pull/12738) + +--- + +## [1.42.0] (Prowler v5.41.0) + +### 🚀 Added + +- Jira issues created from Prowler Cloud now carry the `prowler`, `prowler-`, `prowler-`, `prowler-`, and `prowler-finding-` labels, a link back to the finding when `DJANGO_UI_BASE_URL` is configured, and the tenant name [(#12540)](https://github.com/prowler-cloud/prowler/pull/12540) + +### 🐞 Fixed + +- `POST /api/v1/mute-rules` now updates only each affected provider's latest completed scan and future scans, preventing historical reaggregation from flooding Celery queues [(#12681)](https://github.com/prowler-cloud/prowler/pull/12681) + +--- + ## [1.41.0] (Prowler v5.40.0) ### 🐞 Fixed diff --git a/api/Dockerfile b/api/Dockerfile index 6866494bb4..2262bd00d6 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -2,7 +2,7 @@ FROM python:3.12.13-slim-trixie@sha256:57cd7c3a7a273101a6485ba99423ee56815788280 LABEL maintainer="https://github.com/prowler-cloud/api" -ARG POWERSHELL_VERSION=7.5.9 +ARG POWERSHELL_VERSION=7.5.11 ENV POWERSHELL_VERSION=${POWERSHELL_VERSION} # Opt out of PowerShell telemetry (Application Insights -> dc.services.visualstudio.com) ENV POWERSHELL_TELEMETRY_OPTOUT=1 @@ -16,19 +16,23 @@ ENV ZIZMOR_VERSION=${ZIZMOR_VERSION} # Pinned here, not fetched with the artefact: a compromised release ships its own checksum. ARG TRIVY_SHA256_AMD64=2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a ARG TRIVY_SHA256_ARM64=b94ce1976bbf3c15b514b605ee88be7c6d94a29be2302847ff01cb794d47aad5 -ARG POWERSHELL_SHA256_AMD64=492ff26bb958336bf61e597ce19e07648b4003bd2a08659e02f0e3e0446ebfe0 -ARG POWERSHELL_SHA256_ARM64=2503b71da3e83635592b092df59a0aca4c3606b4d9b068217bb00be989cb0d56 +ARG POWERSHELL_SHA256_AMD64=82a8b13d92b0f3ae48e56cf2f3f7961679371736ca90145ca71617c2913ba9d8 +ARG POWERSHELL_SHA256_ARM64=830ebda118c731ece3fa7e6b7e8573a21346387cbbca5b2f5e3b9bfe24f96672 ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03 ARG ZIZMOR_SHA256_ARM64=d66e37ef8a375fb07939c630ebf9709a6e0f20242bdc3faf672a7ed97e0b768d -# High CVEs fixed in Debian trixie-security but not yet in the pinned base image: +# High CVEs fixed in Debian trixie but not yet in the pinned base image: # openssl/libssl3t64/openssl-provider-legacy 3.5.7-1~deb13u2 CVE-2026-14456, # -14457, -18798, -54874, -63072, -63073, -63074, -63075, -63076, -75803 -# (image ships 3.5.6-1~deb13u2) +# libsqlite3-0 3.46.1-7+deb13u2 CVE-2026-11822, -11824 +# gzip 1.13-1+deb13u1 CVE-2026-41992 +# perl-base 5.40.1-6+deb13u1 CVE-2026-42497, -48962, -57432 +# libssh2-1t64 1.11.1-1+deb13u2 CVE-2026-58050 +# libpcre2-8-0 10.46-1~deb13u2 CVE-2026-86145, -89161 # Taken as a targeted --only-upgrade rather than by moving the digest: the newest -# published python:3.12-slim-trixie carries the same vulnerable version. The three -# packages are all built from openssl and are flagged separately, so all are named. -# Drop them once the base image ships 3.5.7-1~deb13u2 or later. +# published python:3.12-slim-trixie carries the same vulnerable versions. The three +# openssl packages are flagged separately, so all are named. +# Drop each one once the base image ships its fixed version. # hadolint ignore=DL3008 RUN apt-get update && apt-get install -y --no-install-recommends \ wget \ @@ -46,6 +50,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3-dev \ && apt-get install -y --no-install-recommends --only-upgrade \ util-linux libssl3t64 openssl openssl-provider-legacy \ + libsqlite3-0 gzip perl-base libssh2-1t64 libpcre2-8-0 \ && rm -rf /var/lib/apt/lists/* # Install PowerShell diff --git a/api/changelog.d/api-image-debian-cves.security.md b/api/changelog.d/api-image-debian-cves.security.md new file mode 100644 index 0000000000..2d8b0403ad --- /dev/null +++ b/api/changelog.d/api-image-debian-cves.security.md @@ -0,0 +1 @@ +`libsqlite3-0`, `gzip`, `perl-base` and `libpcre2-8-0` upgraded in the API container image, patching high Debian CVEs diff --git a/api/changelog.d/api-image-powershell-dotnet-cve.security.md b/api/changelog.d/api-image-powershell-dotnet-cve.security.md new file mode 100644 index 0000000000..28b91a32a0 --- /dev/null +++ b/api/changelog.d/api-image-powershell-dotnet-cve.security.md @@ -0,0 +1 @@ +PowerShell from 7.5.9 to 7.5.11 in the API container image, bundling .NET runtime 9.0.20 and patching CVE-2026-62901 diff --git a/api/changelog.d/lapsed-invitations-block-re-invites.fixed.md b/api/changelog.d/lapsed-invitations-block-re-invites.fixed.md new file mode 100644 index 0000000000..29a8e99235 --- /dev/null +++ b/api/changelog.d/lapsed-invitations-block-re-invites.fixed.md @@ -0,0 +1 @@ +Lapsed pending invitations are reported as expired and no longer block a new invitation for the same email diff --git a/api/changelog.d/report-download-public-storage-endpoint.fixed.md b/api/changelog.d/report-download-public-storage-endpoint.fixed.md new file mode 100644 index 0000000000..87004d8a56 --- /dev/null +++ b/api/changelog.d/report-download-public-storage-endpoint.fixed.md @@ -0,0 +1 @@ +Report download URLs can be signed against a browser-reachable storage host via `DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL`, so downloads complete on deployments where storage is only reachable inside the container network diff --git a/api/changelog.d/s3-client-default-region.fixed.md b/api/changelog.d/s3-client-default-region.fixed.md new file mode 100644 index 0000000000..62efc579f2 --- /dev/null +++ b/api/changelog.d/s3-client-default-region.fixed.md @@ -0,0 +1 @@ +A scan report download no longer fails with a server error when `DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION` is unset, which is common on storage with no meaningful region diff --git a/api/pyproject.toml b/api/pyproject.toml index 1987534a30..00b50e8300 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.42.0" +version = "1.44.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/filters.py b/api/src/backend/api/filters.py index a7f888b453..ad89671c89 100644 --- a/api/src/backend/api/filters.py +++ b/api/src/backend/api/filters.py @@ -1439,8 +1439,20 @@ class InvitationFilter(FilterSet): inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date") updated_at = DateFilter(field_name="updated_at", lookup_expr="date") expires_at = DateFilter(field_name="expires_at", lookup_expr="date") - state = ChoiceFilter(choices=Invitation.State.choices) - state__in = ChoiceInFilter(choices=Invitation.State.choices, lookup_expr="in") + state = ChoiceFilter(choices=Invitation.State.choices, method="filter_state") + state__in = ChoiceInFilter( + choices=Invitation.State.choices, lookup_expr="in", method="filter_state_in" + ) + + def filter_state(self, queryset, name, value): + return self.filter_state_in(queryset, name, [value]) + + def filter_state_in(self, queryset, name, value): + lapsed = Invitation.lapsed_q() + query = Q(state__in=value) & ~lapsed + if Invitation.State.EXPIRED in value: + query |= lapsed + return queryset.filter(query) class Meta: model = Invitation diff --git a/api/src/backend/api/migrations/0098_tenant_onboarding_profile.py b/api/src/backend/api/migrations/0098_tenant_onboarding_profile.py new file mode 100644 index 0000000000..7ee767fb91 --- /dev/null +++ b/api/src/backend/api/migrations/0098_tenant_onboarding_profile.py @@ -0,0 +1,116 @@ +import uuid + +import api.rls +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0097_attack_paths_scan_db_defaults"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="TenantOnboardingProfile", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("inserted_at", models.DateTimeField(auto_now_add=True)), + ( + "declared_cloud_accounts", + models.CharField( + blank=True, + choices=[ + ("1", "1"), + ("2-10", "2-10"), + ("11-50", "11-50"), + ("51-200", "51-200"), + ("200+", "200+"), + ], + max_length=16, + null=True, + ), + ), + ( + "declared_role", + models.CharField( + blank=True, + choices=[ + ("security", "Security"), + ("devops_platform", "DevOps / Platform"), + ("developer", "Developer"), + ("compliance_grc", "Compliance / GRC"), + ("other", "Other"), + ], + max_length=32, + null=True, + ), + ), + ( + "declared_seniority", + models.CharField( + blank=True, + choices=[ + ("practitioner", "Practitioner / IC"), + ("lead", "Team lead / Manager"), + ("director", "Director / Head of"), + ("executive", "VP / C-level"), + ("founder", "Founder / Owner"), + ], + max_length=32, + null=True, + ), + ), + ("skipped", models.BooleanField(default=False)), + ( + "submitted_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="tenant_onboarding_profiles", + related_query_name="tenant_onboarding_profile", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "tenant", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="api.tenant" + ), + ), + ], + options={ + "db_table": "tenant_onboarding_profiles", + "abstract": False, + }, + ), + migrations.AddConstraint( + model_name="tenantonboardingprofile", + constraint=models.UniqueConstraint( + fields=("tenant_id",), name="unique_tenant_onboarding_profile" + ), + ), + migrations.AddConstraint( + model_name="tenantonboardingprofile", + # `statements` written out explicitly: RowLevelSecurityConstraint + # .deconstruct() does not serialize it, so an autogenerated + # migration falls back to ["SELECT"] and leaves the table without + # INSERT/UPDATE/DELETE policies. + constraint=api.rls.RowLevelSecurityConstraint( + "tenant_id", + name="rls_on_tenantonboardingprofile", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ), + ] diff --git a/api/src/backend/api/migrations/0099_delete_tenant_onboarding_profile.py b/api/src/backend/api/migrations/0099_delete_tenant_onboarding_profile.py new file mode 100644 index 0000000000..870805fb2d --- /dev/null +++ b/api/src/backend/api/migrations/0099_delete_tenant_onboarding_profile.py @@ -0,0 +1,15 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + # The onboarding profile step was reverted after 0098 had been merged, so + # the table goes away through a new migration rather than by deleting 0098. + dependencies = [ + ("api", "0098_tenant_onboarding_profile"), + ] + + operations = [ + migrations.DeleteModel( + name="TenantOnboardingProfile", + ), + ] diff --git a/api/src/backend/api/models.py b/api/src/backend/api/models.py index a280708d53..6a18cd1994 100644 --- a/api/src/backend/api/models.py +++ b/api/src/backend/api/models.py @@ -1380,6 +1380,15 @@ class Invitation(RowLevelSecurityProtectedModel): self.email = self.email.strip().lower() super().save(*args, **kwargs) + @classmethod + def lapsed_q(cls): + """Pending invitations whose expiry date has already passed.""" + return Q(state=cls.State.PENDING, expires_at__lte=datetime.now(UTC)) + + @property + def is_lapsed(self): + return self.state == self.State.PENDING and self.expires_at <= datetime.now(UTC) + class Meta(RowLevelSecurityProtectedModel.Meta): db_table = "invitations" diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index 9c321bdf13..ab57f94702 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.42.0 + version: 1.44.0 description: |- Prowler API specification. diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 7b153efb8f..43a13e4d4c 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -82,7 +82,7 @@ from django.db import close_old_connections, connection, connections from django.db.models import Count from django.db.models.signals import pre_delete from django.http import JsonResponse -from django.test import RequestFactory +from django.test import RequestFactory, override_settings from django.test.utils import CaptureQueriesContext from django.urls import reverse from django_celery_results.models import TaskResult @@ -4540,6 +4540,52 @@ class TestScanViewSet: assert response.status_code == status.HTTP_302_FOUND assert response["Location"] == presigned_url + @override_settings( + DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="https://storage.example.com", + DJANGO_OUTPUT_S3_AWS_ACCESS_KEY_ID="access-key", + DJANGO_OUTPUT_S3_AWS_SECRET_ACCESS_KEY="secret-key", + DJANGO_OUTPUT_S3_AWS_SESSION_TOKEN="", + DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION="eu-west-1", + ) + def test_report_s3_redirects_to_the_public_storage_host( + self, authenticated_client, scans_fixture, monkeypatch + ): + """The object is looked up internally but the redirect the browser follows is public.""" + scan = scans_fixture[0] + bucket = "test-bucket" + key = "report.zip" + scan.output_location = f"s3://{bucket}/{key}" + scan.state = StateChoices.COMPLETED + scan.save() + + monkeypatch.setattr( + "api.v1.views.env", + type("env", (), {"str": lambda self, *_args, **_kwargs: bucket})(), + ) + + head_calls = [] + + class InternalS3Client: + def head_object(self, Bucket, Key): + head_calls.append((Bucket, Key)) + return {} + + def generate_presigned_url(self, *_args, **_kwargs): + raise AssertionError("the internal client must not sign the redirect") + + monkeypatch.setattr("api.v1.views.get_s3_client", lambda: InternalS3Client()) + + url = reverse("scan-report", kwargs={"pk": scan.id}) + response = authenticated_client.get(url) + + assert response.status_code == status.HTTP_302_FOUND + assert head_calls == [(bucket, key)] + + location = urlparse(response["Location"]) + assert location.netloc == "storage.example.com" + assert location.path == f"/{bucket}/{key}" + assert "X-Amz-Signature" in parse_qs(location.query) + def test_report_s3_success_no_local_files( self, authenticated_client, scans_fixture, monkeypatch ): @@ -8784,6 +8830,190 @@ class TestInvitationViewSet: user.id ) + @staticmethod + def _invitation_create_payload(email, role): + return json.dumps( + { + "data": { + "type": "invitations", + "attributes": {"email": email}, + "relationships": { + "roles": {"data": [{"type": "roles", "id": str(role.id)}]} + }, + } + } + ) + + @staticmethod + def _create_lapsed_invitation(email, tenant, inviter): + return Invitation.objects.create( + email=email, + state=Invitation.State.PENDING, + expires_at=datetime.now(UTC) - timedelta(days=1), + inviter=inviter, + tenant=tenant, + ) + + def test_invitations_create_with_lapsed_pending_invitation_for_same_email( + self, + authenticated_client, + create_test_user, + tenants_fixture, + invitations_fixture, + roles_fixture, + ): + lapsed_invitation, expired_invitation = invitations_fixture + lapsed_invitation.expires_at = datetime.now(UTC) - timedelta(days=1) + lapsed_invitation.save() + other_email_lapsed_invitation = self._create_lapsed_invitation( + "other@prowler.com", tenants_fixture[0], create_test_user + ) + + response = authenticated_client.post( + reverse("invitation-list"), + data=self._invitation_create_payload( + lapsed_invitation.email, roles_fixture[0] + ), + content_type="application/vnd.api+json", + ) + + assert response.status_code == status.HTTP_201_CREATED + new_invitation = Invitation.objects.get(id=response.json()["data"]["id"]) + assert new_invitation.email == lapsed_invitation.email + assert new_invitation.state == Invitation.State.PENDING + lapsed_invitation.refresh_from_db() + assert lapsed_invitation.state == Invitation.State.EXPIRED + expired_invitation.refresh_from_db() + assert expired_invitation.state == Invitation.State.EXPIRED + other_email_lapsed_invitation.refresh_from_db() + assert other_email_lapsed_invitation.state == Invitation.State.PENDING + + def test_invitations_create_with_active_pending_invitation_for_same_email( + self, + authenticated_client, + create_test_user, + tenants_fixture, + invitations_fixture, + roles_fixture, + ): + active_invitation, _ = invitations_fixture + self._create_lapsed_invitation( + active_invitation.email, tenants_fixture[0], create_test_user + ) + invitation_count = Invitation.objects.count() + + response = authenticated_client.post( + reverse("invitation-list"), + data=self._invitation_create_payload( + active_invitation.email, roles_fixture[0] + ), + content_type="application/vnd.api+json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert ( + response.json()["errors"][0]["source"]["pointer"] + == "/data/attributes/email" + ) + assert Invitation.objects.count() == invitation_count + active_invitation.refresh_from_db() + assert active_invitation.state == Invitation.State.PENDING + + def test_invitations_create_ignores_pending_invitations_from_other_tenants( + self, authenticated_client, create_test_user, tenants_fixture, roles_fixture + ): + email = "cross_tenant@prowler.com" + other_tenant = tenants_fixture[1] + other_tenant_lapsed_invitation = self._create_lapsed_invitation( + email, other_tenant, create_test_user + ) + Invitation.objects.create( + email=email, inviter=create_test_user, tenant=other_tenant + ) + + response = authenticated_client.post( + reverse("invitation-list"), + data=self._invitation_create_payload(email, roles_fixture[0]), + content_type="application/vnd.api+json", + ) + + assert response.status_code == status.HTTP_201_CREATED + other_tenant_lapsed_invitation.refresh_from_db() + assert other_tenant_lapsed_invitation.state == Invitation.State.PENDING + + def test_invitations_report_lapsed_pending_invitation_as_expired( + self, + authenticated_client, + create_test_user, + tenants_fixture, + invitations_fixture, + ): + active_invitation, expired_invitation = invitations_fixture + lapsed_invitation = self._create_lapsed_invitation( + "lapsed@prowler.com", tenants_fixture[0], create_test_user + ) + + list_response = authenticated_client.get(reverse("invitation-list")) + retrieve_response = authenticated_client.get( + reverse("invitation-detail", kwargs={"pk": lapsed_invitation.id}) + ) + + assert list_response.status_code == status.HTTP_200_OK + assert retrieve_response.status_code == status.HTTP_200_OK + assert { + invitation["id"]: invitation["attributes"]["state"] + for invitation in list_response.json()["data"] + } == { + str(active_invitation.id): Invitation.State.PENDING.value, + str(expired_invitation.id): Invitation.State.EXPIRED.value, + str(lapsed_invitation.id): Invitation.State.EXPIRED.value, + } + assert ( + retrieve_response.json()["data"]["attributes"]["state"] + == Invitation.State.EXPIRED.value + ) + + @pytest.mark.parametrize( + "filter_name, filter_value, expected_invitations", + [ + ("state", "pending", {"active"}), + ("state", "expired", {"expired", "lapsed"}), + ("state", "accepted", set()), + ("state__in", "pending", {"active"}), + ("state__in", "expired", {"expired", "lapsed"}), + ("state__in", "pending,expired", {"active", "expired", "lapsed"}), + ("state__in", "accepted,revoked", set()), + ], + ) + def test_invitations_filter_state_treats_lapsed_pending_as_expired( + self, + authenticated_client, + create_test_user, + tenants_fixture, + invitations_fixture, + filter_name, + filter_value, + expected_invitations, + ): + active_invitation, expired_invitation = invitations_fixture + lapsed_invitation = self._create_lapsed_invitation( + "lapsed@prowler.com", tenants_fixture[0], create_test_user + ) + invitation_ids = { + "active": str(active_invitation.id), + "expired": str(expired_invitation.id), + "lapsed": str(lapsed_invitation.id), + } + + response = authenticated_client.get( + reverse("invitation-list"), {f"filter[{filter_name}]": filter_value} + ) + + assert response.status_code == status.HTTP_200_OK + assert {invitation["id"] for invitation in response.json()["data"]} == { + invitation_ids[name] for name in expected_invitations + } + @pytest.mark.parametrize( "email", [ @@ -8791,8 +9021,10 @@ class TestInvitationViewSet: "invalid_email@", # There is a pending invitation with this email "testing@prowler.com", + "TESTING@prowler.com", # User is already a member of the tenant TEST_USER, + TEST_USER.upper(), ], ) def test_invitations_create_invalid_email( @@ -9047,6 +9279,56 @@ class TestInvitationViewSet: == "This invitation cannot be revoked." ) + def test_invitations_delete_lapsed_invitation( + self, authenticated_client, invitations_fixture + ): + invitation, *_ = invitations_fixture + invitation.expires_at = datetime.now(UTC) - timedelta(days=1) + invitation.save() + + response = authenticated_client.delete( + reverse("invitation-detail", kwargs={"pk": str(invitation.id)}) + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert ( + response.json()["errors"][0]["detail"] + == "This invitation cannot be revoked." + ) + invitation.refresh_from_db() + assert invitation.state == Invitation.State.PENDING + + def test_invitations_partial_update_lapsed_invitation( + self, authenticated_client, invitations_fixture + ): + invitation, *_ = invitations_fixture + invitation.expires_at = datetime.now(UTC) - timedelta(days=1) + invitation.save() + data = { + "data": { + "id": str(invitation.id), + "type": "invitations", + "attributes": { + "email": invitation.email, + "expires_at": self.TOMORROW_ISO, + }, + } + } + + response = authenticated_client.patch( + reverse("invitation-detail", kwargs={"pk": str(invitation.id)}), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert ( + response.json()["errors"][0]["detail"] + == "This invitation cannot be updated." + ) + invitation.refresh_from_db() + assert invitation.is_lapsed + def test_invitations_accept_invitation_new_user(self, client, invitations_fixture): invitation, *_ = invitations_fixture @@ -18333,19 +18615,14 @@ class TestMuteRuleViewSet: assert len(data) == 2 assert data[0]["id"] == str(mute_rules_fixture[first_index].id) - @patch("api.v1.views.chain") - @patch("api.v1.views.reaggregate_all_finding_group_summaries_task.si") - @patch("api.v1.views.mute_historical_findings_task.si") + @patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async") @patch("api.v1.views.transaction.on_commit", side_effect=lambda fn: fn()) def test_mute_rules_create_valid( self, _mock_on_commit, - mock_mute_signature, - mock_reaggregate_signature, - mock_chain, + mock_mute_task, authenticated_client, findings_fixture, - create_test_user, ): """Test creating a valid mute rule.""" finding_ids = [str(findings_fixture[0].id)] @@ -18372,24 +18649,20 @@ class TestMuteRuleViewSet: assert response_data["attributes"]["name"] == "New Mute Rule" assert response_data["attributes"]["reason"] == "Security exception approved" - # Verify the finding was immediately muted - from api.models import Finding - finding = Finding.objects.get(id=findings_fixture[0].id) - assert finding.muted is True - assert finding.muted_at is not None - assert finding.muted_reason == "Security exception approved" + assert finding.muted is False + assert finding.muted_at is None + assert finding.muted_reason is None - # Verify background task chain was called: mute → reaggregate all - mock_mute_signature.assert_called_once() - mock_reaggregate_signature.assert_called_once() - mock_chain.assert_called_once_with( - mock_mute_signature.return_value, - mock_reaggregate_signature.return_value, + mock_mute_task.assert_called_once_with( + kwargs={ + "tenant_id": str(finding.tenant_id), + "mute_rule_id": response_data["id"], + "provider_ids": [str(finding.scan.provider_id)], + } ) - mock_chain.return_value.apply_async.assert_called_once() - @patch("tasks.tasks.mute_historical_findings_task.apply_async") + @patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async") def test_mute_rules_create_converts_finding_ids_to_uids( self, mock_task, @@ -18425,7 +18698,7 @@ class TestMuteRuleViewSet: ] assert set(mute_rule.finding_uids) == set(expected_uids) - @patch("tasks.tasks.mute_historical_findings_task.apply_async") + @patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async") def test_mute_rules_deduplicates_uids( self, mock_task, @@ -18492,10 +18765,10 @@ class TestMuteRuleViewSet: finding1.refresh_from_db() finding2.refresh_from_db() - assert finding1.muted is True - assert finding2.muted is True + assert finding1.muted is False + assert finding2.muted is False - @patch("tasks.tasks.mute_historical_findings_task.apply_async") + @patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async") def test_mute_rules_create_overlap_detection_active( self, mock_task, @@ -18528,7 +18801,7 @@ class TestMuteRuleViewSet: "already muted" in error_detail.lower() or "overlap" in error_detail.lower() ) - @patch("tasks.tasks.mute_historical_findings_task.apply_async") + @patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async") def test_mute_rules_create_no_overlap_with_inactive( self, mock_task, @@ -18584,7 +18857,7 @@ class TestMuteRuleViewSet: == "/data/attributes/finding_ids" ) - @patch("tasks.tasks.mute_historical_findings_task.apply_async") + @patch("api.v1.views.mute_findings_in_latest_scans_task.apply_async") def test_mute_rules_create_invalid_finding_ids( self, mock_task, authenticated_client ): diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index 5e1f2fa6d8..271d3a9b47 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -2149,6 +2149,12 @@ class InvitationSerializer(RLSSerializer): if tenant_id is not None: self.fields["roles"].queryset = Role.objects.filter(tenant_id=tenant_id) + def to_representation(self, instance): + data = super().to_representation(instance) + if instance.is_lapsed: + data["state"] = Invitation.State.EXPIRED.value + return data + class Meta: model = Invitation fields = [ @@ -2175,6 +2181,7 @@ class InvitationBaseWriteSerializer(BaseWriteSerializer): self.fields["roles"].queryset = Role.objects.filter(tenant_id=tenant_id) def validate_email(self, value): + value = value.strip().lower() user = User.objects.filter(email=value).first() tenant_id = self.context["tenant_id"] if user and Membership.objects.filter(user=user, tenant=tenant_id).exists(): @@ -2182,9 +2189,13 @@ class InvitationBaseWriteSerializer(BaseWriteSerializer): "The user may already be a member of the tenant or there was an issue with the " "email provided." ) - if Invitation.objects.filter( - email=value, state=Invitation.State.PENDING - ).exists(): + pending_invitations = Invitation.objects.filter( + tenant_id=tenant_id, email=value, state=Invitation.State.PENDING + ) + pending_invitations.filter(Invitation.lapsed_q()).update( + state=Invitation.State.EXPIRED + ) + if pending_invitations.filter(expires_at__gt=datetime.now(UTC)).exists(): raise ValidationError( "Unable to process your request. Please check the information provided and " "try again." diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 2717b523ac..1bb40f4fd3 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -244,7 +244,6 @@ from api.v1.serializers import ( UserUpdateSerializer, ) from botocore.exceptions import ClientError, NoCredentialsError, ParamValidationError -from celery import chain from celery.result import AsyncResult from config.custom_logging import BackendLogger from config.env import env @@ -327,7 +326,7 @@ from rest_framework_simplejwt.token_blacklist.models import ( ) from tasks.beat import schedule_provider_scan from tasks.jobs.attack_paths import db_utils as attack_paths_db_utils -from tasks.jobs.export import get_s3_client +from tasks.jobs.export import get_s3_client, get_s3_presign_client from tasks.tasks import ( QUEUED_SCAN_TASK_STATE, backfill_compliance_summaries_task, @@ -342,8 +341,7 @@ from tasks.tasks import ( enqueue_scan_execution_on_commit, get_active_provider_scan, jira_integration_task, - mute_historical_findings_task, - reaggregate_all_finding_group_summaries_task, + mute_findings_in_latest_scans_task, refresh_lighthouse_provider_models_task, ) @@ -2409,7 +2407,8 @@ class ScanViewSet(ProviderVisibilityMixin, BaseRLSViewSet): } if content_type: params["ResponseContentType"] = content_type - url = client.generate_presigned_url( + # The browser follows this URL, so it is signed against the public host. + url = (get_s3_presign_client() or client).generate_presigned_url( "get_object", Params=params, ExpiresIn=300, @@ -4473,7 +4472,7 @@ class InvitationViewSet(BaseRLSViewSet): def partial_update(self, request, *args, **kwargs): instance = self.get_object() - if instance.state != Invitation.State.PENDING: + if instance.state != Invitation.State.PENDING or instance.is_lapsed: raise ValidationError(detail="This invitation cannot be updated.") serializer = self.get_serializer( instance, @@ -4487,7 +4486,7 @@ class InvitationViewSet(BaseRLSViewSet): def destroy(self, request, *args, **kwargs): instance = self.get_object() - if instance.state != Invitation.State.PENDING: + if instance.state != Invitation.State.PENDING or instance.is_lapsed: raise ValidationError(detail="This invitation cannot be revoked.") instance.state = Invitation.State.REVOKED instance.save() @@ -7551,35 +7550,28 @@ class MuteRuleViewSet(BaseRLSViewSet): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - # Create the mute rule + tenant_id = str(request.tenant_id) + finding_ids = serializer.validated_data["finding_ids"] + provider_ids = list( + dict.fromkeys( + Finding.all_objects.filter( + id__in=finding_ids, tenant_id=tenant_id + ).values_list("scan__provider_id", flat=True) + ) + ) + mute_rule = serializer.save() - tenant_id = str(request.tenant_id) - finding_ids = request.data.get("finding_ids", []) - - # Immediately mute the selected findings - Finding.all_objects.filter( - id__in=finding_ids, tenant_id=tenant_id, muted=False - ).update( - muted=True, - muted_at=mute_rule.inserted_at, - muted_reason=mute_rule.reason, - ) - - # Launch background task for historical muting + reaggregation transaction.on_commit( - lambda: chain( - mute_historical_findings_task.si( - tenant_id=tenant_id, - mute_rule_id=str(mute_rule.id), - ), - reaggregate_all_finding_group_summaries_task.si( - tenant_id=tenant_id, - ), - ).apply_async() + lambda: mute_findings_in_latest_scans_task.apply_async( + kwargs={ + "tenant_id": tenant_id, + "mute_rule_id": str(mute_rule.id), + "provider_ids": [str(provider_id) for provider_id in provider_ids], + } + ) ) - # Return the created mute rule serializer = self.get_serializer(mute_rule) return Response( data=serializer.data, diff --git a/api/src/backend/config/django/base.py b/api/src/backend/config/django/base.py index a079942600..a208dda915 100644 --- a/api/src/backend/config/django/base.py +++ b/api/src/backend/config/django/base.py @@ -295,6 +295,11 @@ DJANGO_OUTPUT_S3_AWS_SECRET_ACCESS_KEY = env.str( ) DJANGO_OUTPUT_S3_AWS_SESSION_TOKEN = env.str("DJANGO_OUTPUT_S3_AWS_SESSION_TOKEN", "") DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION = env.str("DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION", "") +# Browser-reachable storage host used to sign download URLs. Empty means sign against the +# same endpoint the API talks to, which is what Prowler Cloud on S3 does. +DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL = env.str( + "DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL", "" +) # HTTP Security Headers SECURE_CONTENT_TYPE_NOSNIFF = True @@ -303,6 +308,11 @@ SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin" DJANGO_DELETION_BATCH_SIZE = env.int("DJANGO_DELETION_BATCH_SIZE", 5000) +# Public base URL of the Prowler UI (for example https://cloud.prowler.com). Used to +# build links back to findings in outbound integrations such as Jira. Empty by +# default, so self-hosted deployments emit no links unless they configure it. +UI_BASE_URL = env.str("DJANGO_UI_BASE_URL", "").rstrip("/") + # SAML requirement CSRF_COOKIE_SECURE = True SESSION_COOKIE_SECURE = True diff --git a/api/src/backend/tasks/jobs/export.py b/api/src/backend/tasks/jobs/export.py index e658d6018c..aacb22578a 100644 --- a/api/src/backend/tasks/jobs/export.py +++ b/api/src/backend/tasks/jobs/export.py @@ -6,6 +6,7 @@ import boto3 import config.django.base as base from api.db_utils import rls_transaction from api.models import Scan +from botocore.config import Config from botocore.exceptions import ClientError, NoCredentialsError, ParamValidationError from celery.utils.log import get_task_logger from django.conf import settings @@ -222,7 +223,9 @@ def get_s3_client(): aws_access_key_id=settings.DJANGO_OUTPUT_S3_AWS_ACCESS_KEY_ID, aws_secret_access_key=settings.DJANGO_OUTPUT_S3_AWS_SECRET_ACCESS_KEY, aws_session_token=settings.DJANGO_OUTPUT_S3_AWS_SESSION_TOKEN, - region_name=settings.DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION, + # Storage that has no meaningful region, MinIO among it, is usually configured + # without one, and botocore rejects an empty region before any request is made. + region_name=settings.DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION or "us-east-1", ) s3_client.list_buckets() except (ClientError, NoCredentialsError, ParamValidationError, ValueError): @@ -232,6 +235,44 @@ def get_s3_client(): return s3_client +def get_s3_presign_client(): + """Return a client that signs URLs against the public storage host. + + None means no public host is configured and the caller should presign with its own + client, which leaves deployments on real S3 with the URL they get today. + """ + public_endpoint = settings.DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL + if not public_endpoint: + return None + + # Blank keys are signed as-is (empty credential scope) instead of deferring to the + # provider chain, so static credentials are only passed when they are set. + credentials = {} + if ( + settings.DJANGO_OUTPUT_S3_AWS_ACCESS_KEY_ID + and settings.DJANGO_OUTPUT_S3_AWS_SECRET_ACCESS_KEY + ): + credentials = { + "aws_access_key_id": settings.DJANGO_OUTPUT_S3_AWS_ACCESS_KEY_ID, + "aws_secret_access_key": settings.DJANGO_OUTPUT_S3_AWS_SECRET_ACCESS_KEY, + # An empty string is a token as far as botocore is concerned: it appends an + # empty X-Amz-Security-Token that storage counts when it recomputes the signature. + "aws_session_token": settings.DJANGO_OUTPUT_S3_AWS_SESSION_TOKEN or None, + } + + return boto3.client( + "s3", + **credentials, + # SigV4 puts the region in the credential scope, and MinIO answers to us-east-1 + # unless it was told otherwise, so an empty region would sign an unusable URL. + region_name=settings.DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION or "us-east-1", + endpoint_url=public_endpoint, + # The signature covers the host, so the addressing style has to be pinned rather + # than guessed from the endpoint: MinIO serves path-style. + config=Config(signature_version="s3v4", s3={"addressing_style": "path"}), + ) + + def _upload_to_s3( tenant_id: str, scan_id: str, local_path: str, relative_key: str ) -> str | None: diff --git a/api/src/backend/tasks/jobs/integrations.py b/api/src/backend/tasks/jobs/integrations.py index 303d28c139..3fedcdc83a 100644 --- a/api/src/backend/tasks/jobs/integrations.py +++ b/api/src/backend/tasks/jobs/integrations.py @@ -2,13 +2,16 @@ import os import time from datetime import UTC, datetime from glob import glob +from urllib.parse import quote from api.db_router import READ_REPLICA_ALIAS, MainRouter from api.db_utils import REPLICA_MAX_ATTEMPTS, REPLICA_RETRY_BASE_DELAY, rls_transaction from api.models import Finding, Integration, Provider +from api.rls import Tenant from api.utils import initialize_prowler_integration, initialize_prowler_provider from celery.utils.log import get_task_logger from config.django.base import DJANGO_FINDINGS_BATCH_SIZE +from django.conf import settings from django.db import OperationalError from prowler.lib.outputs.asff.asff import ASFF from prowler.lib.outputs.compliance.generic.generic import GenericCompliance @@ -16,6 +19,7 @@ from prowler.lib.outputs.csv.csv import CSV from prowler.lib.outputs.finding import Finding as FindingOutput from prowler.lib.outputs.html.html import HTML from prowler.lib.outputs.jira.exceptions.exceptions import JiraBaseException +from prowler.lib.outputs.jira.jira import Jira from prowler.lib.outputs.ocsf.ocsf import OCSF from prowler.providers.aws.aws_provider import AwsProvider from prowler.providers.aws.lib.s3.s3 import S3 @@ -477,6 +481,55 @@ def upload_security_hub_integration( return False +JIRA_LABEL_PREFIX = "prowler" + + +def build_jira_finding_url(finding_uid: str) -> str: + """Build the Prowler UI link for a finding, or "" when no UI base URL is set. + + The link filters by the finding ``uid`` rather than the per-scan record id so + it keeps resolving after the finding is seen again in later scans. + """ + base_url = getattr(settings, "UI_BASE_URL", "") + if not base_url or not finding_uid: + return "" + return f"{base_url}/findings?filter[uid]={quote(finding_uid, safe='')}" + + +def build_jira_issue_labels( + finding_uid: str, provider: str, severity: str, check_id: str +) -> list[str]: + """Build the deterministic label set written to every Jira issue. + + Labels are prefixed to avoid colliding with customer labels and sanitized so + Jira never rejects them; the finding-uid label is what lets a ticket be traced + back (or JQL-filtered) to its finding. + """ + raw_labels = [ + JIRA_LABEL_PREFIX, + f"{JIRA_LABEL_PREFIX}-{provider}" if provider else "", + f"{JIRA_LABEL_PREFIX}-{severity}" if severity else "", + f"{JIRA_LABEL_PREFIX}-{check_id}" if check_id else "", + Jira.build_finding_label(finding_uid), + ] + return Jira.sanitize_labels(raw_labels) + + +def get_tenant_name(tenant_id: str) -> str: + """Return the tenant name for the Jira issue "Tenant Info" row, or "" if unknown. + + The name is informational only, so a lookup failure must never block the send. + """ + try: + return ( + Tenant.objects.filter(id=tenant_id).values_list("name", flat=True).first() + or "" + ) + except Exception: + logger.warning("Could not resolve tenant name for %s", tenant_id) + return "" + + def send_findings_to_jira( tenant_id: str, integration_id: str, @@ -487,6 +540,7 @@ def send_findings_to_jira( with rls_transaction(tenant_id): integration = Integration.objects.get(id=integration_id) jira_integration = initialize_prowler_integration(integration) + tenant_info = get_tenant_name(tenant_id) num_tickets_created = 0 error_messages = [] @@ -519,6 +573,15 @@ def send_findings_to_jira( recommendation = remediation.get("recommendation", {}) remediation_code = remediation.get("code", {}) + provider_type = finding_instance.scan.provider.provider + issue_labels = build_jira_issue_labels( + finding_uid=finding_instance.uid, + provider=provider_type, + severity=finding_instance.severity, + check_id=finding_instance.check_id, + ) + finding_url = build_jira_finding_url(finding_instance.uid) + try: # Send the individual finding to Jira result = jira_integration.send_finding( @@ -527,7 +590,7 @@ def send_findings_to_jira( severity=finding_instance.severity, status=finding_instance.status, status_extended=finding_instance.status_extended or "", - provider=finding_instance.scan.provider.provider, + provider=provider_type, region=region, resource_uid=resource_uid, resource_name=resource_name, @@ -542,6 +605,9 @@ def send_findings_to_jira( compliance=finding_instance.compliance or {}, project_key=project_key, issue_type=issue_type, + issue_labels=issue_labels, + finding_url=finding_url, + tenant_info=tenant_info, ) except JiraBaseException as error: error_message = error.message or JIRA_GENERIC_SEND_ERROR @@ -557,6 +623,11 @@ def send_findings_to_jira( if result: num_tickets_created += 1 + logger.info( + "Finding %s sent to Jira as %s", + finding_id, + result.get("key") if isinstance(result, dict) else result, + ) else: error_message = JIRA_GENERIC_SEND_ERROR logger.error(error_message) diff --git a/api/src/backend/tasks/jobs/muting.py b/api/src/backend/tasks/jobs/muting.py index 12a32ac574..839889a8ef 100644 --- a/api/src/backend/tasks/jobs/muting.py +++ b/api/src/backend/tasks/jobs/muting.py @@ -1,63 +1,104 @@ +from collections.abc import Iterable + from api.db_utils import rls_transaction -from api.models import Finding, MuteRule +from api.models import Finding, MuteRule, Scan, StateChoices from celery.utils.log import get_task_logger -from config.django.base import DJANGO_FINDINGS_BATCH_SIZE -from tasks.utils import batched logger = get_task_logger(__name__) -def mute_historical_findings(tenant_id: str, mute_rule_id: str): - """ - Mute historical findings that match the given mute rule. +def _mute_findings_for_rule( + *, + tenant_id: str, + scan_id: str, + finding_uids: Iterable[str], + muted_at, + muted_reason: str, +) -> int: + finding_uids = list(finding_uids) + if not finding_uids: + return 0 - This function processes findings in batches, updating their muted status - and adding the mute reason. + return Finding.all_objects.filter( + tenant_id=tenant_id, + scan_id=scan_id, + uid__in=finding_uids, + muted=False, + ).update( + muted=True, + muted_at=muted_at, + muted_reason=muted_reason, + ) - Args: - tenant_id (str): The tenant ID for RLS context - mute_rule_id (str): The ID of the mute rule to apply - Returns: - dict: Summary of the muting operation with findings_muted count - """ - findings_muted_count = 0 +def mute_findings_in_latest_scans( + tenant_id: str, mute_rule_id: str, provider_ids: list[str] +) -> dict: + """Apply a mute rule to the latest completed scan of each provider.""" + provider_ids = list(dict.fromkeys(provider_ids)) - # Get the list of UIDs to mute and the reason with rls_transaction(tenant_id): mute_rule = MuteRule.objects.get(id=mute_rule_id, tenant_id=tenant_id) - finding_uids = mute_rule.finding_uids - mute_reason = mute_rule.reason - muted_at = mute_rule.inserted_at - - # Query findings that match the UIDs and are not already muted - with rls_transaction(tenant_id): - findings_to_mute = Finding.objects.filter( - tenant_id=tenant_id, uid__in=finding_uids, muted=False - ) - total_findings = findings_to_mute.count() - - logger.info( - f"Processing {total_findings} findings for mute rule {mute_rule_id}" + latest_scans = list( + Scan.objects.filter( + tenant_id=tenant_id, + provider_id__in=provider_ids, + state=StateChoices.COMPLETED, + completed_at__isnull=False, + ) + .order_by("provider_id", "-completed_at", "-inserted_at", "-id") + .distinct("provider_id") + .values_list("id", flat=True) ) - if total_findings > 0: - for batch, is_last in batched( - findings_to_mute.iterator(), DJANGO_FINDINGS_BATCH_SIZE - ): - batch_ids = [f.id for f in batch] - updated_count = Finding.all_objects.filter( - id__in=batch_ids, tenant_id=tenant_id - ).update( - muted=True, - muted_at=muted_at, - muted_reason=mute_reason, - ) - findings_muted_count += updated_count - - logger.info(f"Muted {findings_muted_count} findings for rule {mute_rule_id}") + changed_scan_ids = [] + findings_muted = 0 + for scan_id in latest_scans: + updated = _mute_findings_for_rule( + tenant_id=tenant_id, + scan_id=str(scan_id), + finding_uids=mute_rule.finding_uids, + muted_at=mute_rule.inserted_at, + muted_reason=mute_rule.reason, + ) + if updated: + findings_muted += updated + changed_scan_ids.append(str(scan_id)) + logger.info( + "Muted %d findings in %d latest scans for rule %s", + findings_muted, + len(changed_scan_ids), + mute_rule_id, + ) return { - "findings_muted": findings_muted_count, + "findings_muted": findings_muted, "rule_id": mute_rule_id, + "scan_ids": changed_scan_ids, } + + +def reconcile_scan_mute_rules(tenant_id: str, scan_id: str) -> dict: + """Apply the current enabled mute rules to one completed scan.""" + findings_muted = 0 + + with rls_transaction(tenant_id): + mute_rules = MuteRule.objects.filter(tenant_id=tenant_id, enabled=True).values( + "finding_uids", "reason", "inserted_at" + ) + + for mute_rule in mute_rules: + findings_muted += _mute_findings_for_rule( + tenant_id=tenant_id, + scan_id=scan_id, + finding_uids=mute_rule["finding_uids"], + muted_at=mute_rule["inserted_at"], + muted_reason=mute_rule["reason"], + ) + + logger.info( + "Reconciled mute rules for scan %s; muted %d findings", + scan_id, + findings_muted, + ) + return {"findings_muted": findings_muted, "scan_id": str(scan_id)} diff --git a/api/src/backend/tasks/jobs/scan.py b/api/src/backend/tasks/jobs/scan.py index 0a8db605c0..8b57ebeecd 100644 --- a/api/src/backend/tasks/jobs/scan.py +++ b/api/src/backend/tasks/jobs/scan.py @@ -5,7 +5,6 @@ import json import random import re import time -import uuid from collections import defaultdict from collections.abc import Callable, Iterable from datetime import UTC, datetime @@ -73,6 +72,7 @@ from tasks.jobs.queries import ( COMPLIANCE_UPSERT_TENANT_SUMMARY_SQL, ) from tasks.utils import CustomEncoder, batched +from uuid6 import uuid7 logger = get_task_logger(__name__) @@ -1756,32 +1756,27 @@ def aggregate_findings(tenant_id: str, scan_id: str): def _aggregate_findings_by_region( - tenant_id: str, scan_id: str, modeled_threatscore_compliance_id: str + tenant_id: str, + scan_id: str, + normalized_threatscore_id: str, + threatscore_requirements_by_check: dict[str, list[str]], ) -> tuple[dict, dict]: """ Aggregate findings by region using streaming, column-scoped ORM reads. Reads only the consumed columns as tuples via ``values_list`` and streams them with ``.iterator()``, using the denormalized ``resource_regions`` array - instead of ``prefetch_related("resources")``. ``resource_regions`` mirrors the - regions of a finding's related resources, so it yields the same per-region - tally without joining the resource table. - - Args: - tenant_id: Tenant UUID - scan_id: Scan UUID - modeled_threatscore_compliance_id: ID for ThreatScore compliance framework + instead of ``prefetch_related("resources")``. ThreatScore requirement ids + are resolved per ``check_id`` from ``threatscore_requirements_by_check``. Returns: tuple: (check_status_by_region, findings_count_by_compliance) - check_status_by_region: {region: {check_id: status}} - - findings_count_by_compliance: {region: {normalized_id: {requirement_id: {total, pass}}}} + - findings_count_by_compliance: {region: {normalized_threatscore_id: {requirement_id: {total, pass}}}} """ check_status_by_region: dict = {} findings_count_by_compliance: dict = {} - normalized_id = re.sub(r"[^a-z0-9]", "", modeled_threatscore_compliance_id.lower()) - with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): findings = ( Finding.all_objects.filter( @@ -1790,14 +1785,12 @@ def _aggregate_findings_by_region( muted=False, status__in=["PASS", "FAIL"], ) - .values_list("check_id", "status", "resource_regions", "compliance") + .values_list("check_id", "status", "resource_regions") .iterator(chunk_size=DJANGO_FINDINGS_BATCH_SIZE) ) - for check_id, status, resource_regions, compliance in findings: - threatscore_requirements = (compliance or {}).get( - modeled_threatscore_compliance_id - ) + for check_id, status, resource_regions in findings: + threatscore_requirements = threatscore_requirements_by_check.get(check_id) for region in resource_regions or (): # Priority: FAIL > any other status @@ -1809,7 +1802,7 @@ def _aggregate_findings_by_region( if threatscore_requirements: compliance_key = findings_count_by_compliance.setdefault( region, {} - ).setdefault(normalized_id, {}) + ).setdefault(normalized_threatscore_id, {}) for requirement_id in threatscore_requirements: requirement_stats = compliance_key.setdefault( @@ -1848,15 +1841,28 @@ def create_compliance_requirements(tenant_id: str, scan_id: str): compliance_template = PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE[ provider_instance.provider ] - modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0" + normalized_threatscore_id = _normalized_compliance_key( + "ProwlerThreatScore", "1.0" + ) requirement_lookup: dict[str, list[tuple[str, str]]] = {} + threatscore_requirements_by_check: dict[str, list[str]] = {} for compliance_id, compliance in compliance_template.items(): + is_threatscore = ( + _normalized_compliance_key( + compliance["framework"], compliance["version"] + ) + == normalized_threatscore_id + ) for requirement_id, requirement in compliance["requirements"].items(): for check_id in requirement["checks"].keys(): requirement_lookup.setdefault(check_id, []).append( (compliance_id, requirement_id) ) + if is_threatscore: + threatscore_requirements_by_check.setdefault( + check_id, [] + ).append(requirement_id) regions = [] requirements_created = 0 @@ -1869,7 +1875,10 @@ def create_compliance_requirements(tenant_id: str, scan_id: str): # Aggregate findings by region using SQL for optimal performance check_status_by_region, findings_count_by_compliance = ( _aggregate_findings_by_region( - tenant_id, scan_id, modeled_threatscore_compliance_id + tenant_id, + scan_id, + normalized_threatscore_id, + threatscore_requirements_by_check, ) ) @@ -1934,23 +1943,35 @@ def create_compliance_requirements(tenant_id: str, scan_id: str): # Yield rows lazily (consumed batch-by-batch by COPY) so peak memory # stays bounded; tally requirement_statuses in the same pass. The # ORM fallback re-iterates from scratch, so the tally resets first. + # Region is the innermost loop so consecutive rows share the leading + # columns of the table's secondary indexes. def _iter_compliance_requirement_rows(): requirement_statuses.clear() - for region in regions: - region_stats = region_requirement_stats.get(region, {}) - region_findings = findings_count_by_compliance.get(region, {}) - for ( - compliance_id, - framework, - version, - modeled_compliance_id, - requirements, - ) in compliance_plan: - compliance_stats = region_stats.get(compliance_id, {}) - compliance_findings = region_findings.get( - modeled_compliance_id, {} + for ( + compliance_id, + framework, + version, + modeled_compliance_id, + requirements, + ) in compliance_plan: + stats_by_region = [ + ( + region, + region_requirement_stats.get(region, {}).get( + compliance_id, {} + ), + findings_count_by_compliance.get(region, {}).get( + modeled_compliance_id, {} + ), ) - for requirement_id, description, total_checks in requirements: + for region in regions + ] + for requirement_id, description, total_checks in requirements: + for ( + region, + compliance_stats, + compliance_findings, + ) in stats_by_region: stats = compliance_stats.get(requirement_id) if stats: passed_checks = stats["passed_checks"] @@ -1981,7 +2002,7 @@ def create_compliance_requirements(tenant_id: str, scan_id: str): requirement_statuses[key]["pass_count"] += 1 yield { - "id": uuid.uuid4(), + "id": uuid7(), "tenant_id": tenant_id_str, "inserted_at": utc_datetime_now, "compliance_id": compliance_id, diff --git a/api/src/backend/tasks/tasks.py b/api/src/backend/tasks/tasks.py index e9101ff1bb..d160a2b6c7 100644 --- a/api/src/backend/tasks/tasks.py +++ b/api/src/backend/tasks/tasks.py @@ -73,7 +73,10 @@ from tasks.jobs.lighthouse_providers import ( check_lighthouse_provider_connection, refresh_lighthouse_provider_models, ) -from tasks.jobs.muting import mute_historical_findings +from tasks.jobs.muting import ( + mute_findings_in_latest_scans, + reconcile_scan_mute_rules, +) from tasks.jobs.orphan_recovery import reconcile_orphans from tasks.jobs.report import ( STALE_TMP_OUTPUT_MAX_AGE_HOURS, @@ -526,6 +529,7 @@ def perform_scan_task( provider_id=provider_id, checks_to_execute=checks_to_execute, ) + reconcile_scan_mute_rules(tenant_id, scan_id) _perform_scan_complete_tasks(tenant_id, scan_id, provider_id) return result finally: @@ -635,6 +639,7 @@ def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str): scan_id=str(scan_instance.id), provider_id=provider_id, ) + reconcile_scan_mute_rules(tenant_id, str(scan_instance.id)) _perform_scan_complete_tasks(tenant_id, str(scan_instance.id), provider_id) return result finally: @@ -1188,85 +1193,48 @@ def aggregate_finding_group_summaries_task(tenant_id: str, scan_id: str): return aggregate_finding_group_summaries(tenant_id=tenant_id, scan_id=scan_id) -@shared_task( - base=RLSTask, name="reaggregate-all-finding-group-summaries", queue="overview" -) -@set_tenant(keep_tenant=True) -def reaggregate_all_finding_group_summaries_task(tenant_id: str): - """Reaggregate every pre-aggregated summary table for this tenant. +def _dispatch_scan_summary_reaggregation(tenant_id: str, scan_ids: list[str]) -> None: + if not scan_ids: + return - Mirrors the unbounded scope of `mute_historical_findings_task`: that task - rewrites every Finding row whose UID matches a mute rule, with no time - limit. To keep the pre-aggregated tables consistent with that update, - this task re-runs the same per-scan aggregation pipeline that scan - completion runs on the latest completed scan of every (provider, day) - pair, rebuilding the tables that power the read endpoints: - - - `ScanSummary` and `DailySeveritySummary` -> `/overviews/findings`, - `/overviews/findings-severity`, `/overviews/services`. - - `FindingGroupDailySummary` -> `/finding-groups` and - `/finding-groups/latest`. - - `ScanGroupSummary` -> `/overviews/resource-groups` (resource - inventory). - - `ScanCategorySummary` -> `/overviews/categories`. - - `AttackSurfaceOverview` -> `/overviews/attack-surfaces`. - - Per-scan pipelines are dispatched in parallel via a Celery group so - wallclock scales with the worker pool. - """ - completed_scans = list( - Scan.objects.filter( - tenant_id=tenant_id, - state=StateChoices.COMPLETED, - completed_at__isnull=False, - ) - .order_by("-completed_at") - .values("id", "completed_at", "provider_id") + logger.info( + "Reaggregating overview/finding summaries for %d latest scans", + len(scan_ids), ) - - # Keep the latest scan per (provider, day) pair so the daily summary row - # the aggregator writes is the most recent snapshot of that day for that - # provider. Iterating from most recent to oldest means the first scan we - # see for a given key wins. - latest_scans: dict[tuple, str] = {} - for scan in completed_scans: - key = (scan["provider_id"], scan["completed_at"].date()) - if key not in latest_scans: - latest_scans[key] = str(scan["id"]) - - scan_ids = list(latest_scans.values()) - if scan_ids: - logger.info( - "Reaggregating overview/finding summaries for %d scans (provider x day)", - len(scan_ids), - ) - # DailySeveritySummary reads from ScanSummary, so ScanSummary must be - # recomputed first; the other aggregators read Finding directly and - # can run in parallel with the severity step. - group( - chain( - perform_scan_summary_task.si(tenant_id=tenant_id, scan_id=scan_id), - group( - aggregate_daily_severity_task.si( - tenant_id=tenant_id, scan_id=scan_id - ), - aggregate_finding_group_summaries_task.si( - tenant_id=tenant_id, scan_id=scan_id - ), - aggregate_scan_resource_group_summaries_task.si( - tenant_id=tenant_id, scan_id=scan_id - ), - aggregate_scan_category_summaries_task.si( - tenant_id=tenant_id, scan_id=scan_id - ), - aggregate_attack_surface_task.si( - tenant_id=tenant_id, scan_id=scan_id - ), + group( + chain( + perform_scan_summary_task.si(tenant_id=tenant_id, scan_id=scan_id), + group( + aggregate_daily_severity_task.si(tenant_id=tenant_id, scan_id=scan_id), + aggregate_finding_group_summaries_task.si( + tenant_id=tenant_id, scan_id=scan_id ), - ) - for scan_id in scan_ids - ).apply_async() - return {"scans_reaggregated": len(scan_ids)} + aggregate_scan_resource_group_summaries_task.si( + tenant_id=tenant_id, scan_id=scan_id + ), + aggregate_scan_category_summaries_task.si( + tenant_id=tenant_id, scan_id=scan_id + ), + aggregate_attack_surface_task.si(tenant_id=tenant_id, scan_id=scan_id), + ), + ) + for scan_id in scan_ids + ).apply_async() + + +@shared_task(base=RLSTask, name="findings-mute-latest-scans", queue="overview") +@set_tenant(keep_tenant=True) +def mute_findings_in_latest_scans_task( + tenant_id: str, mute_rule_id: str, provider_ids: list[str] +): + """Apply a mute rule to current scans and rebuild only changed summaries.""" + result = mute_findings_in_latest_scans( + tenant_id=tenant_id, + mute_rule_id=mute_rule_id, + provider_ids=provider_ids, + ) + _dispatch_scan_summary_reaggregation(tenant_id, result["scan_ids"]) + return result @shared_task(base=RLSTask, name="lighthouse-connection-check") @@ -1467,25 +1435,3 @@ def generate_compliance_reports_task(tenant_id: str, scan_id: str, provider_id: generate_csa=True, generate_cis=True, ) - - -@shared_task(name="findings-mute-historical") -def mute_historical_findings_task(tenant_id: str, mute_rule_id: str): - """ - Background task to mute all historical findings matching a mute rule. - - This task processes findings in batches to avoid memory issues with large datasets. - It updates the Finding.muted, Finding.muted_at, and Finding.muted_reason fields - for all findings whose UID is in the mute rule's finding_uids list. - - Args: - tenant_id (str): The tenant ID for RLS context. - mute_rule_id (str): The primary key of the MuteRule to apply. - - Returns: - dict: A dictionary containing: - - 'findings_muted' (int): Total number of findings muted. - - 'rule_id' (str): The mute rule ID. - - 'status' (str): Final status ('completed'). - """ - return mute_historical_findings(tenant_id, mute_rule_id) diff --git a/api/src/backend/tasks/tests/test_export.py b/api/src/backend/tasks/tests/test_export.py index 416361d95e..bbec0b742e 100644 --- a/api/src/backend/tasks/tests/test_export.py +++ b/api/src/backend/tasks/tests/test_export.py @@ -4,15 +4,18 @@ import zipfile from datetime import datetime from pathlib import Path from unittest.mock import MagicMock, patch +from urllib.parse import parse_qs, urlparse import pytest from botocore.exceptions import ClientError +from django.test import override_settings from tasks.jobs.export import ( _compress_output_files, _generate_compliance_output_directory, _generate_output_directory, _upload_to_s3, get_s3_client, + get_s3_presign_client, ) @@ -47,6 +50,19 @@ class TestOutputs: assert client is not None client_mock.list_buckets.assert_called() + @patch("tasks.jobs.export.boto3.client") + @override_settings( + DJANGO_OUTPUT_S3_AWS_ACCESS_KEY_ID="access-key", + DJANGO_OUTPUT_S3_AWS_SECRET_ACCESS_KEY="secret-key", + DJANGO_OUTPUT_S3_AWS_SESSION_TOKEN="", + DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION="", + ) + def test_get_s3_client_without_a_region_uses_a_default(self, mock_boto_client): + """botocore rejects an empty region up front, and the download views do not catch it.""" + get_s3_client() + + assert mock_boto_client.call_args.kwargs["region_name"] == "us-east-1" + @patch("tasks.jobs.export.boto3.client") @patch("tasks.jobs.export.settings") def test_get_s3_client_fallback(self, mock_settings, mock_boto_client): @@ -243,3 +259,107 @@ class TestOutputs: assert os.path.isdir(os.path.dirname(ens)) assert threatscore.endswith(f"aws-test-check-{expected_timestamp}") assert ens.endswith(f"aws-test-check-{expected_timestamp}") + + +PRESIGN_SETTINGS = { + "DJANGO_OUTPUT_S3_AWS_ACCESS_KEY_ID": "access-key", + "DJANGO_OUTPUT_S3_AWS_SECRET_ACCESS_KEY": "secret-key", + "DJANGO_OUTPUT_S3_AWS_SESSION_TOKEN": "", + "DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION": "eu-west-1", +} + + +def _presign(client): + return client.generate_presigned_url( + "get_object", + Params={"Bucket": "output-bucket", "Key": "tenant/scan/report.zip"}, + ExpiresIn=300, + ) + + +class TestS3PresignClient: + @override_settings(**PRESIGN_SETTINGS, DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="") + def test_no_public_endpoint_returns_none(self): + assert get_s3_presign_client() is None + + @override_settings( + **PRESIGN_SETTINGS, + DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="https://storage.example.com", + ) + def test_url_targets_the_public_host_in_path_style(self): + url = urlparse(_presign(get_s3_presign_client())) + + assert url.netloc == "storage.example.com" + assert url.path == "/output-bucket/tenant/scan/report.zip" + + @override_settings( + **PRESIGN_SETTINGS, + DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="https://storage.example.com", + ) + def test_signature_covers_the_public_host(self): + query = parse_qs(urlparse(_presign(get_s3_presign_client())).query) + + assert query["X-Amz-SignedHeaders"] == ["host"] + assert "/eu-west-1/s3/aws4_request" in query["X-Amz-Credential"][0] + + @override_settings( + **PRESIGN_SETTINGS, + DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="https://storage.example.com", + ) + def test_signature_is_bound_to_the_host_it_was_signed_against(self): + """Rewriting the host afterwards cannot work, which is why the endpoint is a setting.""" + public = parse_qs(urlparse(_presign(get_s3_presign_client())).query) + + with override_settings( + DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="http://minio:9000" + ): + internal = parse_qs(urlparse(_presign(get_s3_presign_client())).query) + + assert public["X-Amz-Signature"] != internal["X-Amz-Signature"] + + @override_settings( + **{**PRESIGN_SETTINGS, "DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION": ""}, + DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="https://storage.example.com", + ) + def test_region_falls_back_to_the_minio_default(self): + query = parse_qs(urlparse(_presign(get_s3_presign_client())).query) + + assert "/us-east-1/s3/aws4_request" in query["X-Amz-Credential"][0] + + @override_settings( + **PRESIGN_SETTINGS, + DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="https://storage.example.com", + ) + def test_unset_session_token_is_left_out_of_the_url(self): + """An empty token still reaches the URL as a blank param that storage signs over.""" + url = _presign(get_s3_presign_client()) + query = parse_qs(urlparse(url).query, keep_blank_values=True) + + assert "X-Amz-Security-Token" not in query + + @override_settings( + **{**PRESIGN_SETTINGS, "DJANGO_OUTPUT_S3_AWS_SESSION_TOKEN": "session-token"}, + DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="https://storage.example.com", + ) + def test_session_token_is_forwarded_when_set(self): + query = parse_qs(urlparse(_presign(get_s3_presign_client())).query) + + assert query["X-Amz-Security-Token"] == ["session-token"] + + @override_settings( + **{ + **PRESIGN_SETTINGS, + "DJANGO_OUTPUT_S3_AWS_ACCESS_KEY_ID": "", + "DJANGO_OUTPUT_S3_AWS_SECRET_ACCESS_KEY": "", + }, + DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="https://storage.example.com", + ) + def test_blank_static_credentials_defer_to_the_provider_chain(self, monkeypatch): + """Empty keys would otherwise be signed as-is, yielding a blank credential scope.""" + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "chain-key") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "chain-secret") + monkeypatch.delenv("AWS_SESSION_TOKEN", raising=False) + + query = parse_qs(urlparse(_presign(get_s3_presign_client())).query) + + assert query["X-Amz-Credential"][0].startswith("chain-key/") diff --git a/api/src/backend/tasks/tests/test_integrations.py b/api/src/backend/tasks/tests/test_integrations.py index a95d02fa7f..421fdb992a 100644 --- a/api/src/backend/tasks/tests/test_integrations.py +++ b/api/src/backend/tasks/tests/test_integrations.py @@ -6,15 +6,20 @@ from api.db_router import READ_REPLICA_ALIAS, MainRouter from api.models import Integration from api.utils import prowler_integration_connection_test from django.db import OperationalError +from django.test import override_settings from prowler.lib.outputs.jira.exceptions.exceptions import ( JiraRefreshTokenError, JiraRequiredCustomFieldsError, ) +from prowler.lib.outputs.jira.jira import Jira from prowler.providers.aws.lib.security_hub.security_hub import SecurityHubConnection from prowler.providers.common.models import Connection from tasks.jobs.integrations import ( + build_jira_finding_url, + build_jira_issue_labels, get_s3_client_from_integration, get_security_hub_client_from_integration, + get_tenant_name, send_findings_to_jira, upload_s3_integration, upload_security_hub_integration, @@ -1696,6 +1701,7 @@ class TestJiraIntegration: finding1 = MagicMock() finding1.id = "finding-1" + finding1.uid = "prowler-aws-check_001-123456789012-us-east-1-my bucket" finding1.check_id = "check_001" finding1.severity = "high" finding1.status = "FAIL" @@ -1724,6 +1730,7 @@ class TestJiraIntegration: finding2 = MagicMock() finding2.id = "finding-2" + finding2.uid = "prowler-azure-check_002-sub/resource" finding2.check_id = "check_002" finding2.severity = "medium" finding2.status = "PASS" @@ -1748,9 +1755,13 @@ class TestJiraIntegration: ] # Call the function - result = send_findings_to_jira( - tenant_id, integration_id, project_key, issue_type, finding_ids - ) + with ( + override_settings(UI_BASE_URL="https://cloud.example.com"), + patch("tasks.jobs.integrations.get_tenant_name", return_value="Acme"), + ): + result = send_findings_to_jira( + tenant_id, integration_id, project_key, issue_type, finding_ids + ) # Assertions assert result == {"created_count": 2, "failed_count": 0} @@ -1773,12 +1784,36 @@ class TestJiraIntegration: assert first_call.kwargs["provider"] == "aws" assert first_call.kwargs["project_key"] == project_key assert first_call.kwargs["issue_type"] == issue_type + # Finding reference: labels, link back and tenant info + assert first_call.kwargs["issue_labels"] == [ + "prowler", + "prowler-aws", + "prowler-high", + "prowler-check_001", + "prowler-finding-prowler-aws-check_001-123456789012-us-east-1-my_bucket", + ] + assert first_call.kwargs["finding_url"] == ( + "https://cloud.example.com/findings?filter[uid]=" + "prowler-aws-check_001-123456789012-us-east-1-my%20bucket" + ) + assert first_call.kwargs["tenant_info"] == "Acme" # Verify second call second_call = mock_jira_integration.send_finding.call_args_list[1] assert second_call.kwargs["check_id"] == "check_002" assert second_call.kwargs["severity"] == "medium" assert second_call.kwargs["status"] == "PASS" + assert second_call.kwargs["issue_labels"] == [ + "prowler", + "prowler-azure", + "prowler-medium", + "prowler-check_002", + "prowler-finding-prowler-azure-check_002-sub/resource", + ] + assert second_call.kwargs["finding_url"] == ( + "https://cloud.example.com/findings?filter[uid]=" + "prowler-azure-check_002-sub%2Fresource" + ) @patch("tasks.jobs.integrations.rls_transaction") @patch("tasks.jobs.integrations.Finding") @@ -2200,3 +2235,101 @@ class TestJiraIntegration: assert call_kwargs["remediation_code_cli"] == "" assert call_kwargs["remediation_code_other"] == "" assert call_kwargs["compliance"] == {} + + +class TestJiraFindingReference: + """Helpers that give Jira issues a stable reference back to the finding.""" + + def test_build_jira_issue_labels(self): + assert build_jira_issue_labels( + finding_uid="prowler-aws-check-123-eu-west-1-hub/unknown", + provider="aws", + severity="critical", + check_id="iam_root_mfa", + ) == [ + "prowler", + "prowler-aws", + "prowler-critical", + "prowler-iam_root_mfa", + "prowler-finding-prowler-aws-check-123-eu-west-1-hub/unknown", + ] + + def test_build_jira_issue_labels_skips_empty_parts(self): + assert build_jira_issue_labels( + finding_uid="", provider="", severity="", check_id="" + ) == ["prowler"] + + def test_build_jira_issue_labels_sanitizes_metadata(self): + assert build_jira_issue_labels( + finding_uid=" uid\x00 with spaces ", + provider="aws cloud", + severity="high severity", + check_id="check id", + ) == [ + "prowler", + "prowler-aws_cloud", + "prowler-high_severity", + "prowler-check_id", + "prowler-finding-uid_with_spaces", + ] + + def test_build_jira_issue_labels_preserves_maximum_length_uid(self): + finding_uid = "u" * (Jira.LABEL_MAX_LENGTH - len(Jira.FINDING_LABEL_PREFIX) - 1) + finding_label = build_jira_issue_labels( + finding_uid=finding_uid, + provider="gcp", + severity="low", + check_id="check", + )[-1] + + assert finding_label == f"{Jira.FINDING_LABEL_PREFIX}-{finding_uid}" + assert len(finding_label) == Jira.LABEL_MAX_LENGTH + + def test_build_jira_issue_labels_distinguishes_long_uids(self): + common_prefix = "u" * 300 + first_uid = f"{common_prefix}-first" + second_uid = f"{common_prefix}-second" + + first_label = build_jira_issue_labels( + finding_uid=first_uid, + provider="gcp", + severity="low", + check_id="check", + )[-1] + second_label = build_jira_issue_labels( + finding_uid=second_uid, + provider="gcp", + severity="low", + check_id="check", + )[-1] + + assert first_label == Jira.build_finding_label(first_uid) + assert second_label == Jira.build_finding_label(second_uid) + assert first_label != second_label + assert len(first_label) == Jira.LABEL_MAX_LENGTH + assert len(second_label) == Jira.LABEL_MAX_LENGTH + + @override_settings(UI_BASE_URL="") + def test_build_jira_finding_url_without_base_url(self): + assert build_jira_finding_url("prowler-aws-check-1") == "" + + @override_settings(UI_BASE_URL="https://cloud.example.com") + def test_build_jira_finding_url_with_base_url(self): + assert build_jira_finding_url("prowler-aws-check-1") == ( + "https://cloud.example.com/findings?filter[uid]=prowler-aws-check-1" + ) + # uid characters that would break the query string are encoded + assert build_jira_finding_url("a/b c&d") == ( + "https://cloud.example.com/findings?filter[uid]=a%2Fb%20c%26d" + ) + assert build_jira_finding_url("") == "" + + @pytest.mark.django_db + def test_get_tenant_name(self, tenants_fixture): + tenant = tenants_fixture[0] + assert get_tenant_name(str(tenant.id)) == tenant.name + + @pytest.mark.django_db + def test_get_tenant_name_unknown_or_invalid(self): + assert get_tenant_name("00000000-0000-0000-0000-000000000000") == "" + assert get_tenant_name("not-a-uuid") == "" diff --git a/api/src/backend/tasks/tests/test_muting.py b/api/src/backend/tasks/tests/test_muting.py index 2e542980bf..b11e704cd0 100644 --- a/api/src/backend/tasks/tests/test_muting.py +++ b/api/src/backend/tasks/tests/test_muting.py @@ -1,531 +1,205 @@ -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from uuid import uuid4 import pytest -from api.models import Finding, MuteRule -from django.core.exceptions import ObjectDoesNotExist +from api.models import Finding, MuteRule, Scan, StateChoices from prowler.lib.check.models import Severity from prowler.lib.outputs.finding import Status -from tasks.jobs.muting import mute_historical_findings +from tasks.jobs.muting import ( + mute_findings_in_latest_scans, + reconcile_scan_mute_rules, +) + + +def _create_finding(scan: Scan, uid: str) -> Finding: + return Finding.objects.create( + tenant_id=scan.tenant_id, + uid=uid, + scan=scan, + status=Status.FAIL, + status_extended="Test finding", + impact=Severity.high, + severity=Severity.high, + raw_result={}, + check_id="test_check", + check_metadata={"CheckId": "test_check"}, + muted=False, + ) + + +def _create_mute_rule(tenant_id, user, finding_uids, *, enabled=True) -> MuteRule: + return MuteRule.objects.create( + tenant_id=tenant_id, + name=f"Mute rule {uuid4()}", + reason="Approved exception", + enabled=enabled, + created_by=user, + finding_uids=finding_uids, + ) @pytest.mark.django_db -class TestMuteHistoricalFindings: - """ - Test suite for the mute_historical_findings function. +class TestMuteFindingsInLatestScans: + def test_mutes_latest_scan_and_leaves_older_scan_unchanged( + self, scans_fixture, create_test_user + ): + latest_scan = scans_fixture[0] + older_scan = Scan.objects.create( + tenant_id=latest_scan.tenant_id, + provider=latest_scan.provider, + name="Older scan", + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + started_at=datetime.now(UTC) - timedelta(days=1), + completed_at=datetime.now(UTC) - timedelta(days=1), + ) + uid = "latest-scan-only" + older_finding = _create_finding(older_scan, uid) + latest_finding = _create_finding(latest_scan, uid) + mute_rule = _create_mute_rule(latest_scan.tenant_id, create_test_user, [uid]) - This class tests the batch processing of findings to update their muted status - based on MuteRule criteria. - """ + result = mute_findings_in_latest_scans( + str(latest_scan.tenant_id), + str(mute_rule.id), + [str(latest_scan.provider_id)], + ) - @pytest.fixture(scope="function") - def test_user(self, create_test_user): - """Create a test user for mute rule creation.""" - return create_test_user + older_finding.refresh_from_db() + latest_finding.refresh_from_db() + assert older_finding.muted is False + assert latest_finding.muted is True + assert latest_finding.muted_at == mute_rule.inserted_at + assert latest_finding.muted_reason == mute_rule.reason + assert result == { + "findings_muted": 1, + "rule_id": str(mute_rule.id), + "scan_ids": [str(latest_scan.id)], + } - @pytest.fixture(scope="function") - def mute_rule_with_findings(self, tenants_fixture, findings_fixture, test_user): - """ - Create a mute rule that targets the first finding in the fixture. - """ + def test_mutes_one_latest_scan_per_provider(self, scans_fixture, create_test_user): + first_scan, second_scan, _ = scans_fixture + uid = "shared-selected-uid" + first_finding = _create_finding(first_scan, uid) + second_finding = _create_finding(second_scan, uid) + mute_rule = _create_mute_rule(first_scan.tenant_id, create_test_user, [uid]) + + result = mute_findings_in_latest_scans( + str(first_scan.tenant_id), + str(mute_rule.id), + [str(first_scan.provider_id), str(second_scan.provider_id)], + ) + + first_finding.refresh_from_db() + second_finding.refresh_from_db() + assert first_finding.muted is True + assert second_finding.muted is True + assert result["findings_muted"] == 2 + assert set(result["scan_ids"]) == {str(first_scan.id), str(second_scan.id)} + + def test_provider_without_completed_scan_does_nothing( + self, tenants_fixture, provider_factory, create_test_user + ): tenant = tenants_fixture[0] - finding = findings_fixture[0] - - mute_rule = MuteRule.objects.create( - tenant_id=tenant.id, - name="Test Mute Rule", - reason="Testing mute functionality", - enabled=True, - created_by=test_user, - finding_uids=[finding.uid], + provider = provider_factory() + mute_rule = _create_mute_rule( + tenant.id, create_test_user, ["future-scan-finding"] ) - return mute_rule + result = mute_findings_in_latest_scans( + str(tenant.id), str(mute_rule.id), [str(provider.id)] + ) - @pytest.fixture(scope="function") - def mute_rule_multiple_findings(self, scans_fixture, test_user): - """ - Create multiple unmuted findings and a mute rule targeting all of them. - """ + assert result == { + "findings_muted": 0, + "rule_id": str(mute_rule.id), + "scan_ids": [], + } + + def test_retry_does_not_report_changed_scans_twice( + self, scans_fixture, create_test_user + ): scan = scans_fixture[0] - tenant_id = scan.tenant_id - - # Create 5 unmuted findings - finding_uids = [] - for i in range(5): - finding = Finding.objects.create( - tenant_id=tenant_id, - uid=f"test_finding_uid_mute_{i}", - scan=scan, - status=Status.FAIL, - status_extended=f"Test status {i}", - impact=Severity.high, - severity=Severity.high, - raw_result={ - "status": Status.FAIL, - "impact": Severity.high, - "severity": Severity.high, - }, - check_id=f"test_check_id_{i}", - check_metadata={ - "CheckId": f"test_check_id_{i}", - "Description": f"Test description {i}", - }, - muted=False, - ) - finding_uids.append(finding.uid) - - # Create mute rule targeting all findings - mute_rule = MuteRule.objects.create( - tenant_id=tenant_id, - name="Test Multiple Findings Mute Rule", - reason="Testing batch muting", - enabled=True, - created_by=test_user, - finding_uids=finding_uids, + finding = _create_finding(scan, "idempotent-mute") + mute_rule = _create_mute_rule(scan.tenant_id, create_test_user, [finding.uid]) + args = ( + str(scan.tenant_id), + str(mute_rule.id), + [str(scan.provider_id)], ) - return mute_rule, finding_uids + first_result = mute_findings_in_latest_scans(*args) + second_result = mute_findings_in_latest_scans(*args) - @pytest.fixture(scope="function") - def mute_rule_already_muted(self, findings_fixture, test_user): - """ - Create a mute rule that targets an already-muted finding. - """ - tenant_id = findings_fixture[1].tenant_id - already_muted_finding = findings_fixture[1] + assert first_result["scan_ids"] == [str(scan.id)] + assert second_result["findings_muted"] == 0 + assert second_result["scan_ids"] == [] - mute_rule = MuteRule.objects.create( - tenant_id=tenant_id, - name="Test Already Muted Rule", - reason="Testing already muted findings", - enabled=True, - created_by=test_user, - finding_uids=[already_muted_finding.uid], + def test_does_not_cross_tenant_boundary( + self, tenants_fixture, provider_factory, create_test_user + ): + tenant = tenants_fixture[0] + other_tenant = tenants_fixture[2] + other_provider = provider_factory(tenant=other_tenant) + other_scan = Scan.objects.create( + tenant_id=other_tenant.id, + provider=other_provider, + name="Other tenant scan", + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + started_at=datetime.now(UTC), + completed_at=datetime.now(UTC), + ) + other_finding = _create_finding(other_scan, "tenant-isolated-uid") + mute_rule = _create_mute_rule(tenant.id, create_test_user, [other_finding.uid]) + + result = mute_findings_in_latest_scans( + str(tenant.id), str(mute_rule.id), [str(other_provider.id)] ) - return mute_rule + other_finding.refresh_from_db() + assert other_finding.muted is False + assert result["scan_ids"] == [] - @pytest.fixture(scope="function") - def mute_rule_mixed_findings(self, scans_fixture, test_user): - """ - Create a mute rule with a mix of muted and unmuted findings. - """ + def test_nonexistent_rule_raises(self, tenants_fixture): + with pytest.raises(MuteRule.DoesNotExist): + mute_findings_in_latest_scans(str(tenants_fixture[0].id), str(uuid4()), []) + + +@pytest.mark.django_db +class TestReconcileScanMuteRules: + def test_applies_only_enabled_rules_to_requested_scan( + self, scans_fixture, create_test_user + ): scan = scans_fixture[0] - tenant_id = scan.tenant_id - - # Create 3 unmuted findings - unmuted_uids = [] - for i in range(3): - finding = Finding.objects.create( - tenant_id=tenant_id, - uid=f"unmuted_finding_{i}", - scan=scan, - status=Status.FAIL, - status_extended=f"Unmuted status {i}", - impact=Severity.medium, - severity=Severity.medium, - raw_result={ - "status": Status.FAIL, - "impact": Severity.medium, - "severity": Severity.medium, - }, - check_id=f"unmuted_check_{i}", - check_metadata={ - "CheckId": f"unmuted_check_{i}", - "Description": f"Unmuted description {i}", - }, - muted=False, - ) - unmuted_uids.append(finding.uid) - - # Create 2 already muted findings - muted_uids = [] - for i in range(2): - finding = Finding.objects.create( - tenant_id=tenant_id, - uid=f"muted_finding_{i}", - scan=scan, - status=Status.FAIL, - status_extended=f"Muted status {i}", - impact=Severity.low, - severity=Severity.low, - raw_result={ - "status": Status.FAIL, - "impact": Severity.low, - "severity": Severity.low, - }, - check_id=f"muted_check_{i}", - check_metadata={ - "CheckId": f"muted_check_{i}", - "Description": f"Muted description {i}", - }, - muted=True, - muted_at=datetime.now(UTC), - muted_reason="Already muted", - ) - muted_uids.append(finding.uid) - - # Create mute rule targeting all findings - all_uids = unmuted_uids + muted_uids - mute_rule = MuteRule.objects.create( - tenant_id=tenant_id, - name="Test Mixed Findings Rule", - reason="Testing mixed muted/unmuted findings", - enabled=True, - created_by=test_user, - finding_uids=all_uids, + active_finding = _create_finding(scan, "active-rule-uid") + disabled_finding = _create_finding(scan, "disabled-rule-uid") + active_rule = _create_mute_rule( + scan.tenant_id, create_test_user, [active_finding.uid] ) - - return mute_rule, unmuted_uids, muted_uids - - @pytest.fixture(scope="function") - def mute_rule_batch_test(self, scans_fixture, test_user): - """ - Create enough findings to test batch processing (>1000 for default batch size). - """ - scan = scans_fixture[0] - tenant_id = scan.tenant_id - - # Create 1500 findings to exceed default batch size of 1000 - finding_uids = [] - for i in range(1500): - finding = Finding.objects.create( - tenant_id=tenant_id, - uid=f"batch_test_finding_{i}", - scan=scan, - status=Status.FAIL, - status_extended=f"Batch test status {i}", - impact=Severity.critical, - severity=Severity.critical, - raw_result={ - "status": Status.FAIL, - "impact": Severity.critical, - "severity": Severity.critical, - }, - check_id=f"batch_test_check_{i}", - check_metadata={ - "CheckId": f"batch_test_check_{i}", - "Description": f"Batch test description {i}", - }, - muted=False, - ) - finding_uids.append(finding.uid) - - # Create mute rule targeting all findings - mute_rule = MuteRule.objects.create( - tenant_id=tenant_id, - name="Test Batch Processing Rule", - reason="Testing batch processing functionality", - enabled=True, - created_by=test_user, - finding_uids=finding_uids, + _create_mute_rule( + scan.tenant_id, + create_test_user, + [disabled_finding.uid], + enabled=False, ) - - return mute_rule, finding_uids - - def test_mute_historical_findings_single_finding( - self, mute_rule_with_findings, findings_fixture - ): - """ - Test muting a single historical finding. - """ - mute_rule = mute_rule_with_findings - tenant_id = str(mute_rule.tenant_id) - finding = findings_fixture[0] - - # Ensure the finding is not muted before execution - finding.refresh_from_db() - assert finding.muted is False - assert finding.muted_at is None - assert finding.muted_reason is None - - # Execute the muting function - result = mute_historical_findings(tenant_id, str(mute_rule.id)) - - # Verify return value - assert result["findings_muted"] == 1 - assert result["rule_id"] == str(mute_rule.id) - - # Verify the finding was muted - finding.refresh_from_db() - assert finding.muted is True - assert finding.muted_at == mute_rule.inserted_at - assert finding.muted_reason == mute_rule.reason - - def test_mute_historical_findings_multiple_findings( - self, mute_rule_multiple_findings - ): - """ - Test muting multiple historical findings. - """ - mute_rule, finding_uids = mute_rule_multiple_findings - tenant_id = str(mute_rule.tenant_id) - - # Verify all findings are unmuted - findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids) - assert findings.count() == 5 - for finding in findings: - assert finding.muted is False - - # Execute the muting function - result = mute_historical_findings(tenant_id, str(mute_rule.id)) - - # Verify return value - assert result["findings_muted"] == 5 - assert result["rule_id"] == str(mute_rule.id) - - # Verify all findings were muted - findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids) - for finding in findings: - assert finding.muted is True - assert finding.muted_at == mute_rule.inserted_at - assert finding.muted_reason == mute_rule.reason - - def test_mute_historical_findings_already_muted( - self, mute_rule_already_muted, findings_fixture - ): - """ - Test that already-muted findings are not counted or updated. - """ - mute_rule = mute_rule_already_muted - tenant_id = str(mute_rule.tenant_id) - finding = findings_fixture[1] - - # Verify the finding is already muted - finding.refresh_from_db() - assert finding.muted is True - original_muted_at = finding.muted_at - original_muted_reason = finding.muted_reason - - # Execute the muting function - result = mute_historical_findings(tenant_id, str(mute_rule.id)) - - # Verify no findings were muted - assert result["findings_muted"] == 0 - assert result["rule_id"] == str(mute_rule.id) - - # Verify the finding's mute status did not change - finding.refresh_from_db() - assert finding.muted is True - assert finding.muted_at == original_muted_at - assert finding.muted_reason == original_muted_reason - - def test_mute_historical_findings_mixed_status(self, mute_rule_mixed_findings): - """ - Test muting when some findings are already muted and others are not. - """ - mute_rule, unmuted_uids, muted_uids = mute_rule_mixed_findings - tenant_id = str(mute_rule.tenant_id) - - # Execute the muting function - result = mute_historical_findings(tenant_id, str(mute_rule.id)) - - # Verify only unmuted findings were counted - assert result["findings_muted"] == 3 - assert result["rule_id"] == str(mute_rule.id) - - # Verify unmuted findings are now muted - unmuted_findings = Finding.objects.filter( - tenant_id=tenant_id, uid__in=unmuted_uids + older_scan = Scan.objects.create( + tenant_id=scan.tenant_id, + provider=scan.provider, + name="Older matching scan", + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + started_at=datetime.now(UTC) - timedelta(days=1), + completed_at=datetime.now(UTC) - timedelta(days=1), ) - for finding in unmuted_findings: - assert finding.muted is True - assert finding.muted_at == mute_rule.inserted_at - assert finding.muted_reason == mute_rule.reason + older_finding = _create_finding(older_scan, active_finding.uid) - # Verify already-muted findings remained unchanged - already_muted_findings = Finding.objects.filter( - tenant_id=tenant_id, uid__in=muted_uids - ) - for finding in already_muted_findings: - assert finding.muted is True - assert finding.muted_reason == "Already muted" + result = reconcile_scan_mute_rules(str(scan.tenant_id), str(scan.id)) - def test_mute_historical_findings_nonexistent_rule(self, tenants_fixture): - """ - Test that a nonexistent mute rule raises ObjectDoesNotExist. - """ - tenant_id = str(tenants_fixture[0].id) - nonexistent_rule_id = str(uuid4()) - - with pytest.raises(ObjectDoesNotExist): - mute_historical_findings(tenant_id, nonexistent_rule_id) - - def test_mute_historical_findings_no_matching_findings( - self, tenants_fixture, test_user - ): - """ - Test muting when no findings match the rule's UIDs. - """ - tenant_id = str(tenants_fixture[0].id) - - # Create a mute rule with non-existent finding UIDs - mute_rule = MuteRule.objects.create( - tenant_id=tenant_id, - name="Test No Match Rule", - reason="Testing no matching findings", - enabled=True, - created_by=test_user, - finding_uids=[ - "nonexistent_uid_1", - "nonexistent_uid_2", - "nonexistent_uid_3", - ], - ) - - # Execute the muting function - result = mute_historical_findings(tenant_id, str(mute_rule.id)) - - # Verify no findings were muted - assert result["findings_muted"] == 0 - assert result["rule_id"] == str(mute_rule.id) - - def test_mute_historical_findings_batch_processing(self, mute_rule_batch_test): - """ - Test that large numbers of findings are processed in batches correctly. - """ - mute_rule, finding_uids = mute_rule_batch_test - tenant_id = str(mute_rule.tenant_id) - - # Verify all findings exist and are unmuted - findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids) - assert findings.count() == 1500 - for finding in findings: - assert finding.muted is False - - # Execute the muting function - result = mute_historical_findings(tenant_id, str(mute_rule.id)) - - # Verify return value - assert result["findings_muted"] == 1500 - assert result["rule_id"] == str(mute_rule.id) - - # Verify all findings were muted - findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=finding_uids) - for finding in findings: - assert finding.muted is True - assert finding.muted_at == mute_rule.inserted_at - assert finding.muted_reason == mute_rule.reason - - def test_mute_historical_findings_preserves_muted_at_timestamp( - self, mute_rule_with_findings, findings_fixture - ): - """ - Test that muted_at is set to the rule's inserted_at, not the current time. - """ - mute_rule = mute_rule_with_findings - tenant_id = str(mute_rule.tenant_id) - finding = findings_fixture[0] - - # Execute the muting function - result = mute_historical_findings(tenant_id, str(mute_rule.id)) - - # Verify the finding was muted - assert result["findings_muted"] == 1 - - # Verify muted_at matches the rule's inserted_at timestamp - finding.refresh_from_db() - assert finding.muted_at == mute_rule.inserted_at - assert finding.muted_at is not None - - def test_mute_historical_findings_partial_match(self, scans_fixture, test_user): - """ - Test muting when only some of the rule's UIDs exist as findings. - """ - scan = scans_fixture[0] - tenant_id = str(scan.tenant_id) - - # Create 3 findings - existing_uids = [] - for i in range(3): - finding = Finding.objects.create( - tenant_id=tenant_id, - uid=f"partial_match_finding_{i}", - scan=scan, - status=Status.FAIL, - status_extended=f"Partial match status {i}", - impact=Severity.high, - severity=Severity.high, - raw_result={ - "status": Status.FAIL, - "impact": Severity.high, - "severity": Severity.high, - }, - check_id=f"partial_match_check_{i}", - check_metadata={ - "CheckId": f"partial_match_check_{i}", - "Description": f"Partial match description {i}", - }, - muted=False, - ) - existing_uids.append(finding.uid) - - # Create a mute rule with both existing and non-existing UIDs - all_uids = existing_uids + [ - "nonexistent_uid_1", - "nonexistent_uid_2", - ] - mute_rule = MuteRule.objects.create( - tenant_id=tenant_id, - name="Test Partial Match Rule", - reason="Testing partial matching", - enabled=True, - created_by=test_user, - finding_uids=all_uids, - ) - - # Execute the muting function - result = mute_historical_findings(tenant_id, str(mute_rule.id)) - - # Verify only existing findings were muted - assert result["findings_muted"] == 3 - assert result["rule_id"] == str(mute_rule.id) - - # Verify the existing findings were muted - findings = Finding.objects.filter(tenant_id=tenant_id, uid__in=existing_uids) - assert findings.count() == 3 - for finding in findings: - assert finding.muted is True - assert finding.muted_at == mute_rule.inserted_at - assert finding.muted_reason == mute_rule.reason - - def test_mute_historical_findings_empty_uids(self, tenants_fixture, test_user): - """ - Test muting when the rule has an empty finding_uids array. - """ - tenant_id = str(tenants_fixture[0].id) - - # Create a mute rule with empty finding_uids - mute_rule = MuteRule.objects.create( - tenant_id=tenant_id, - name="Test Empty UIDs Rule", - reason="Testing empty UIDs", - enabled=True, - created_by=test_user, - finding_uids=[], - ) - - # Execute the muting function - result = mute_historical_findings(tenant_id, str(mute_rule.id)) - - # Verify no findings were muted - assert result["findings_muted"] == 0 - assert result["rule_id"] == str(mute_rule.id) - - def test_mute_historical_findings_return_format(self, mute_rule_with_findings): - """ - Test that the return value has the correct format and fields. - """ - mute_rule = mute_rule_with_findings - tenant_id = str(mute_rule.tenant_id) - - result = mute_historical_findings(tenant_id, str(mute_rule.id)) - - # Verify return value structure - assert isinstance(result, dict) - assert "findings_muted" in result - assert "rule_id" in result - assert isinstance(result["findings_muted"], int) - assert isinstance(result["rule_id"], str) - assert result["rule_id"] == str(mute_rule.id) + active_finding.refresh_from_db() + disabled_finding.refresh_from_db() + older_finding.refresh_from_db() + assert active_finding.muted is True + assert active_finding.muted_at == active_rule.inserted_at + assert disabled_finding.muted is False + assert older_finding.muted is False + assert result == {"findings_muted": 1, "scan_id": str(scan.id)} diff --git a/api/src/backend/tasks/tests/test_scan.py b/api/src/backend/tasks/tests/test_scan.py index 8027588398..0cee24351e 100644 --- a/api/src/backend/tasks/tests/test_scan.py +++ b/api/src/backend/tasks/tests/test_scan.py @@ -1,6 +1,5 @@ import csv import json -import re import uuid from collections.abc import MutableMapping from contextlib import contextmanager @@ -10,6 +9,7 @@ from unittest.mock import MagicMock, patch import pytest from api.db_router import MainRouter +from api.db_utils import rls_transaction from api.exceptions import ProviderConnectionError, ProviderDeletedException from api.models import ( Finding, @@ -2795,6 +2795,167 @@ class TestCreateComplianceRequirements: assert count_after_first > 0 assert count_after_second == count_after_first + with rls_transaction(tenant_id): + row_versions = { + row_id.version + for row_id in ComplianceRequirementOverview.objects.filter( + scan_id=scan_id + ).values_list("id", flat=True) + } + assert row_versions == {7} + + def test_create_compliance_requirements_threatscore_counts_from_template( + self, + tenants_fixture, + scans_fixture, + aws_provider, + findings_fixture, + ): + """ThreatScore finding counts are derived from the template mapping, + not from each finding's stored ``compliance`` payload.""" + from api.models import ComplianceRequirementOverview + + with patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" + ) as mock_compliance_template: + tenant_id = str(tenants_fixture[0].id) + scan_id = str(scans_fixture[0].id) + + mock_compliance_template.__getitem__.return_value = { + "prowler_threatscore_aws": { + "framework": "ProwlerThreatScore", + "version": "1.0", + "requirements": { + "1.1.1": { + "description": "ThreatScore requirement", + "checks": {"test_check_id": None}, + }, + "1.1.2": { + "description": "Unrelated requirement", + "checks": {"other_check_id": None}, + }, + }, + }, + "other_framework": { + "framework": "Other", + "version": "2.0", + "requirements": { + "a": { + "description": "Same check, other framework", + "checks": {"test_check_id": None}, + }, + }, + }, + } + + create_compliance_requirements(tenant_id, scan_id) + + with rls_transaction(tenant_id): + counted = sum( + len(finding.resource_regions or []) + for finding in Finding.all_objects.filter( + scan_id=scan_id, + muted=False, + status__in=["PASS", "FAIL"], + check_id="test_check_id", + ) + ) + rows = list( + ComplianceRequirementOverview.objects.filter( + scan_id=scan_id + ).values_list("compliance_id", "requirement_id", "total_findings") + ) + assert counted > 0 + assert ( + sum( + total + for compliance_id, requirement_id, total in rows + if (compliance_id, requirement_id) + == ("prowler_threatscore_aws", "1.1.1") + ) + == counted + ) + assert all( + total == 0 + for compliance_id, requirement_id, total in rows + if (compliance_id, requirement_id) == ("prowler_threatscore_aws", "1.1.2") + ) + assert all( + total == 0 + for compliance_id, _, total in rows + if compliance_id == "other_framework" + ) + + def test_create_compliance_requirements_rows_across_regions_and_frameworks( + self, + tenants_fixture, + scans_fixture, + aws_provider, + ): + from api.models import ComplianceRequirementOverview + + tenant_id = str(tenants_fixture[0].id) + scan_id = str(scans_fixture[0].id) + check_status_by_region = { + "us-east-1": {"check_a": "FAIL", "check_b": "PASS"}, + "eu-west-1": {"check_a": "PASS"}, + } + template = { + "fw_one": { + "framework": "One", + "version": "1", + "requirements": { + "r1": {"description": "a", "checks": {"check_a": None}}, + "r2": { + "description": "a+b", + "checks": {"check_a": None, "check_b": None}, + }, + }, + }, + "fw_two": { + "framework": "Two", + "version": "2", + "requirements": { + "m1": {"description": "manual", "checks": {}}, + }, + }, + } + + with ( + patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" + ) as mock_compliance_template, + patch( + "tasks.jobs.scan._aggregate_findings_by_region", + return_value=(check_status_by_region, {}), + ), + ): + mock_compliance_template.__getitem__.return_value = template + result = create_compliance_requirements(tenant_id, scan_id) + + assert result["requirements_created"] == 6 + with rls_transaction(tenant_id): + rows = set( + ComplianceRequirementOverview.objects.filter( + scan_id=scan_id + ).values_list( + "compliance_id", + "requirement_id", + "region", + "requirement_status", + "passed_checks", + "failed_checks", + "total_checks", + ) + ) + assert rows == { + ("fw_one", "r1", "us-east-1", "FAIL", 0, 1, 1), + ("fw_one", "r1", "eu-west-1", "PASS", 1, 0, 1), + ("fw_one", "r2", "us-east-1", "FAIL", 1, 1, 2), + ("fw_one", "r2", "eu-west-1", "PASS", 1, 0, 2), + ("fw_two", "m1", "us-east-1", "MANUAL", 0, 0, 0), + ("fw_two", "m1", "eu-west-1", "MANUAL", 0, 0, 0), + } def test_create_compliance_requirements_kubernetes_provider( self, @@ -4723,17 +4884,11 @@ class TestAggregateFindingsByRegion: """Test function returns correct data structure.""" tenant_id = str(uuid.uuid4()) scan_id = str(uuid.uuid4()) - modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0" + normalized_id = "prowlerthreatscore10" - # (check_id, status, resource_regions, compliance) tuples - finding_rows = [ - ( - "check1", - "FAIL", - ["us-east-1"], - {modeled_threatscore_compliance_id: ["req1", "req2"]}, - ) - ] + # (check_id, status, resource_regions) tuples + finding_rows = [("check1", "FAIL", ["us-east-1"])] + threatscore_by_check = {"check1": ["req1", "req2"]} mock_queryset = MagicMock() mock_queryset.values_list.return_value = mock_queryset @@ -4747,13 +4902,16 @@ class TestAggregateFindingsByRegion: check_status_by_region, findings_count_by_compliance = ( _aggregate_findings_by_region( - tenant_id, scan_id, modeled_threatscore_compliance_id + tenant_id, + scan_id, + normalized_id, + threatscore_by_check, ) ) # Streaming query contract: column-scoped values_list + iterator mock_queryset.values_list.assert_called_once_with( - "check_id", "status", "resource_regions", "compliance" + "check_id", "status", "resource_regions" ) mock_queryset.iterator.assert_called_once() @@ -4774,13 +4932,14 @@ class TestAggregateFindingsByRegion: """Test that FAIL status takes priority over other statuses.""" tenant_id = str(uuid.uuid4()) scan_id = str(uuid.uuid4()) - modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0" + normalized_id = "prowlerthreatscore10" # Same check/region: PASS first, then FAIL — FAIL must win finding_rows = [ - ("check1", "PASS", ["us-east-1"], {}), - ("check1", "FAIL", ["us-east-1"], {}), + ("check1", "PASS", ["us-east-1"]), + ("check1", "FAIL", ["us-east-1"]), ] + threatscore_by_check = {} mock_queryset = MagicMock() mock_queryset.values_list.return_value = mock_queryset @@ -4793,12 +4952,15 @@ class TestAggregateFindingsByRegion: mock_findings_filter.return_value = mock_queryset check_status_by_region, _ = _aggregate_findings_by_region( - tenant_id, scan_id, modeled_threatscore_compliance_id + tenant_id, + scan_id, + normalized_id, + threatscore_by_check, ) # Streaming query contract: column-scoped values_list + iterator mock_queryset.values_list.assert_called_once_with( - "check_id", "status", "resource_regions", "compliance" + "check_id", "status", "resource_regions" ) mock_queryset.iterator.assert_called_once() @@ -4813,8 +4975,9 @@ class TestAggregateFindingsByRegion: """Test that muted findings are filtered out (muted=False in query).""" tenant_id = str(uuid.uuid4()) scan_id = str(uuid.uuid4()) - modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0" + normalized_id = "prowlerthreatscore10" + threatscore_by_check = {} mock_queryset = MagicMock() mock_queryset.values_list.return_value = mock_queryset mock_queryset.iterator.return_value = [] @@ -4826,12 +4989,15 @@ class TestAggregateFindingsByRegion: mock_findings_filter.return_value = mock_queryset _aggregate_findings_by_region( - tenant_id, scan_id, modeled_threatscore_compliance_id + tenant_id, + scan_id, + normalized_id, + threatscore_by_check, ) # Streaming query contract: column-scoped values_list + iterator mock_queryset.values_list.assert_called_once_with( - "check_id", "status", "resource_regions", "compliance" + "check_id", "status", "resource_regions" ) mock_queryset.iterator.assert_called_once() @@ -4851,23 +5017,14 @@ class TestAggregateFindingsByRegion: """Test that ThreatScore compliance counts are processed correctly.""" tenant_id = str(uuid.uuid4()) scan_id = str(uuid.uuid4()) - modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0" + normalized_id = "prowlerthreatscore10" # PASS and FAIL findings mapped to the same ThreatScore requirement finding_rows = [ - ( - "check1", - "PASS", - ["us-east-1"], - {modeled_threatscore_compliance_id: ["req1"]}, - ), - ( - "check2", - "FAIL", - ["us-east-1"], - {modeled_threatscore_compliance_id: ["req1"]}, - ), + ("check1", "PASS", ["us-east-1"]), + ("check2", "FAIL", ["us-east-1"]), ] + threatscore_by_check = {"check1": ["req1"], "check2": ["req1"]} mock_queryset = MagicMock() mock_queryset.values_list.return_value = mock_queryset @@ -4880,19 +5037,19 @@ class TestAggregateFindingsByRegion: mock_findings_filter.return_value = mock_queryset _, findings_count_by_compliance = _aggregate_findings_by_region( - tenant_id, scan_id, modeled_threatscore_compliance_id + tenant_id, + scan_id, + normalized_id, + threatscore_by_check, ) # Streaming query contract: column-scoped values_list + iterator mock_queryset.values_list.assert_called_once_with( - "check_id", "status", "resource_regions", "compliance" + "check_id", "status", "resource_regions" ) mock_queryset.iterator.assert_called_once() # Verify compliance counts - normalized_id = re.sub( - r"[^a-z0-9]", "", modeled_threatscore_compliance_id.lower() - ) assert "us-east-1" in findings_count_by_compliance assert normalized_id in findings_count_by_compliance["us-east-1"] assert "req1" in findings_count_by_compliance["us-east-1"][normalized_id] @@ -4909,13 +5066,14 @@ class TestAggregateFindingsByRegion: """Test aggregation across multiple regions.""" tenant_id = str(uuid.uuid4()) scan_id = str(uuid.uuid4()) - modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0" + normalized_id = "prowlerthreatscore10" # One finding per region finding_rows = [ - ("check1", "FAIL", ["us-east-1"], {}), - ("check1", "PASS", ["us-west-2"], {}), + ("check1", "FAIL", ["us-east-1"]), + ("check1", "PASS", ["us-west-2"]), ] + threatscore_by_check = {} mock_queryset = MagicMock() mock_queryset.values_list.return_value = mock_queryset @@ -4928,12 +5086,15 @@ class TestAggregateFindingsByRegion: mock_findings_filter.return_value = mock_queryset check_status_by_region, _ = _aggregate_findings_by_region( - tenant_id, scan_id, modeled_threatscore_compliance_id + tenant_id, + scan_id, + normalized_id, + threatscore_by_check, ) # Streaming query contract: column-scoped values_list + iterator mock_queryset.values_list.assert_called_once_with( - "check_id", "status", "resource_regions", "compliance" + "check_id", "status", "resource_regions" ) mock_queryset.iterator.assert_called_once() @@ -4951,16 +5112,10 @@ class TestAggregateFindingsByRegion: """A finding with multiple resource_regions is tallied in every region.""" tenant_id = str(uuid.uuid4()) scan_id = str(uuid.uuid4()) - modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0" + normalized_id = "prowlerthreatscore10" - finding_rows = [ - ( - "check1", - "FAIL", - ["us-east-1", "eu-west-1"], - {modeled_threatscore_compliance_id: ["req1"]}, - ) - ] + finding_rows = [("check1", "FAIL", ["us-east-1", "eu-west-1"])] + threatscore_by_check = {"check1": ["req1"]} mock_queryset = MagicMock() mock_queryset.values_list.return_value = mock_queryset @@ -4974,19 +5129,19 @@ class TestAggregateFindingsByRegion: check_status_by_region, findings_count_by_compliance = ( _aggregate_findings_by_region( - tenant_id, scan_id, modeled_threatscore_compliance_id + tenant_id, + scan_id, + normalized_id, + threatscore_by_check, ) ) # Streaming query contract: column-scoped values_list + iterator mock_queryset.values_list.assert_called_once_with( - "check_id", "status", "resource_regions", "compliance" + "check_id", "status", "resource_regions" ) mock_queryset.iterator.assert_called_once() - normalized_id = re.sub( - r"[^a-z0-9]", "", modeled_threatscore_compliance_id.lower() - ) for region in ("us-east-1", "eu-west-1"): assert check_status_by_region[region]["check1"] == "FAIL" req_stats = findings_count_by_compliance[region][normalized_id]["req1"] @@ -5000,12 +5155,13 @@ class TestAggregateFindingsByRegion: """A finding with no denormalized regions contributes nothing.""" tenant_id = str(uuid.uuid4()) scan_id = str(uuid.uuid4()) - modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0" + normalized_id = "prowlerthreatscore10" finding_rows = [ - ("check1", "FAIL", [], {modeled_threatscore_compliance_id: ["req1"]}), - ("check2", "PASS", None, {}), + ("check1", "FAIL", []), + ("check2", "PASS", None), ] + threatscore_by_check = {"check1": ["req1"]} mock_queryset = MagicMock() mock_queryset.values_list.return_value = mock_queryset @@ -5019,13 +5175,16 @@ class TestAggregateFindingsByRegion: check_status_by_region, findings_count_by_compliance = ( _aggregate_findings_by_region( - tenant_id, scan_id, modeled_threatscore_compliance_id + tenant_id, + scan_id, + normalized_id, + threatscore_by_check, ) ) # Streaming query contract: column-scoped values_list + iterator mock_queryset.values_list.assert_called_once_with( - "check_id", "status", "resource_regions", "compliance" + "check_id", "status", "resource_regions" ) mock_queryset.iterator.assert_called_once() @@ -5040,8 +5199,9 @@ class TestAggregateFindingsByRegion: """Test with no findings - should return empty dicts.""" tenant_id = str(uuid.uuid4()) scan_id = str(uuid.uuid4()) - modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0" + normalized_id = "prowlerthreatscore10" + threatscore_by_check = {} mock_queryset = MagicMock() mock_queryset.values_list.return_value = mock_queryset mock_queryset.iterator.return_value = [] @@ -5054,13 +5214,16 @@ class TestAggregateFindingsByRegion: check_status_by_region, findings_count_by_compliance = ( _aggregate_findings_by_region( - tenant_id, scan_id, modeled_threatscore_compliance_id + tenant_id, + scan_id, + normalized_id, + threatscore_by_check, ) ) # Streaming query contract: column-scoped values_list + iterator mock_queryset.values_list.assert_called_once_with( - "check_id", "status", "resource_regions", "compliance" + "check_id", "status", "resource_regions" ) mock_queryset.iterator.assert_called_once() diff --git a/api/src/backend/tasks/tests/test_tasks.py b/api/src/backend/tasks/tests/test_tasks.py index 8c846be805..b4854fe196 100644 --- a/api/src/backend/tasks/tests/test_tasks.py +++ b/api/src/backend/tasks/tests/test_tasks.py @@ -1,6 +1,6 @@ import uuid from contextlib import contextmanager -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime from unittest.mock import MagicMock, patch import httpx @@ -33,10 +33,10 @@ from tasks.tasks import ( check_integrations_task, check_lighthouse_provider_connection_task, generate_outputs_task, + mute_findings_in_latest_scans_task, perform_attack_paths_scan_task, perform_scan_task, perform_scheduled_scan_task, - reaggregate_all_finding_group_summaries_task, refresh_lighthouse_provider_models_task, s3_integration_task, security_hub_integration_task, @@ -2959,6 +2959,7 @@ class TestPerformScheduledScanTask: with ( patch("tasks.tasks.perform_prowler_scan", side_effect=_complete_scan), patch("tasks.tasks._perform_scan_complete_tasks"), + patch("tasks.tasks.reconcile_scan_mute_rules") as mock_reconcile, self._override_task_request(perform_scheduled_scan_task, id=task_id), ): perform_scheduled_scan_task.run( @@ -2982,6 +2983,13 @@ class TestPerformScheduledScanTask: ).count() == 1 ) + completed_scan = Scan.objects.get( + tenant_id=tenant.id, + provider=provider, + trigger=Scan.TriggerChoices.SCHEDULED, + state=StateChoices.COMPLETED, + ) + mock_reconcile.assert_called_once_with(str(tenant.id), str(completed_scan.id)) assert ( Scan.objects.filter( tenant_id=tenant.id, @@ -3176,7 +3184,10 @@ class TestPerformScanTask: task=queued_task, ) + events = [] + def _complete_scan(tenant_id, scan_id, provider_id, checks_to_execute=None): + events.append("scan") scan_instance = Scan.objects.get(id=scan_id) scan_instance.state = StateChoices.COMPLETED scan_instance.save() @@ -3184,7 +3195,14 @@ class TestPerformScanTask: with ( patch("tasks.tasks.perform_prowler_scan", side_effect=_complete_scan), - patch("tasks.tasks._perform_scan_complete_tasks"), + patch( + "tasks.tasks.reconcile_scan_mute_rules", + side_effect=lambda *_args: events.append("reconcile"), + ), + patch( + "tasks.tasks._perform_scan_complete_tasks", + side_effect=lambda *_args: events.append("summaries"), + ), patch("tasks.tasks.perform_scan_task.apply_async") as mock_apply_async, ): with django_capture_on_commit_callbacks(execute=True): @@ -3196,6 +3214,7 @@ class TestPerformScanTask: queued_task_result.refresh_from_db() assert result == {"status": "ok"} + assert events == ["scan", "reconcile", "summaries"] assert queued_task_result.status == states.PENDING mock_apply_async.assert_called_once_with( kwargs={ @@ -3241,10 +3260,7 @@ class TestPerformScanTask: @pytest.mark.django_db -class TestReaggregateAllFindingGroupSummaries: - def setup_method(self): - self.tenant_id = str(uuid.uuid4()) - +class TestMuteFindingsInLatestScansTask: @patch("tasks.tasks.chain") @patch("tasks.tasks.group") @patch("tasks.tasks.aggregate_attack_surface_task") @@ -3253,10 +3269,10 @@ class TestReaggregateAllFindingGroupSummaries: @patch("tasks.tasks.aggregate_finding_group_summaries_task") @patch("tasks.tasks.aggregate_daily_severity_task") @patch("tasks.tasks.perform_scan_summary_task") - @patch("tasks.tasks.Scan.objects.filter") - def test_dispatches_subtasks_for_each_provider_per_day( + @patch("tasks.tasks.mute_findings_in_latest_scans") + def test_reaggregates_only_changed_scans( self, - mock_scan_filter, + mock_mute_findings, mock_scan_summary_task, mock_daily_severity_task, mock_finding_group_task, @@ -3265,119 +3281,36 @@ class TestReaggregateAllFindingGroupSummaries: mock_attack_surface_task, mock_group, mock_chain, + tenants_fixture, ): - provider_id_1 = uuid.uuid4() - provider_id_2 = uuid.uuid4() - scan_id_today_p1 = uuid.uuid4() - scan_id_yesterday_p1 = uuid.uuid4() - scan_id_today_p2 = uuid.uuid4() - today = datetime.now(tz=UTC) - yesterday = today - timedelta(days=1) - - mock_outer_group_result = MagicMock() - # The first `group()` call wraps the inner parallel step; subsequent - # calls wrap the outer per-scan generator. - mock_group.side_effect = lambda *args, **kwargs: ( - list(args[0]) if args and hasattr(args[0], "__iter__") else None, - mock_outer_group_result, - )[1] - - mock_scan_filter.return_value.order_by.return_value.values.return_value = [ - { - "id": scan_id_today_p1, - "completed_at": today, - "provider_id": provider_id_1, - }, - { - "id": scan_id_today_p2, - "completed_at": today, - "provider_id": provider_id_2, - }, - { - "id": scan_id_yesterday_p1, - "completed_at": yesterday, - "provider_id": provider_id_1, - }, - ] - - result = reaggregate_all_finding_group_summaries_task(tenant_id=self.tenant_id) - - assert result == {"scans_reaggregated": 3} - expected_scan_ids = { - str(scan_id_today_p1), - str(scan_id_today_p2), - str(scan_id_yesterday_p1), + tenant_id = str(tenants_fixture[0].id) + mute_rule_id = str(uuid.uuid4()) + provider_ids = [str(uuid.uuid4()), str(uuid.uuid4())] + scan_ids = [str(uuid.uuid4()), str(uuid.uuid4())] + result = { + "findings_muted": 2, + "rule_id": mute_rule_id, + "scan_ids": scan_ids, } - for task_mock in ( - mock_scan_summary_task, - mock_daily_severity_task, - mock_finding_group_task, - mock_resource_group_task, - mock_category_task, - mock_attack_surface_task, - ): - assert task_mock.si.call_count == 3 - dispatched = { - call.kwargs["scan_id"] for call in task_mock.si.call_args_list - } - assert dispatched == expected_scan_ids - for call in task_mock.si.call_args_list: - assert call.kwargs["tenant_id"] == self.tenant_id - assert mock_chain.call_count == 3 - mock_outer_group_result.apply_async.assert_called_once() - - @patch("tasks.tasks.chain") - @patch("tasks.tasks.group") - @patch("tasks.tasks.aggregate_attack_surface_task") - @patch("tasks.tasks.aggregate_scan_category_summaries_task") - @patch("tasks.tasks.aggregate_scan_resource_group_summaries_task") - @patch("tasks.tasks.aggregate_finding_group_summaries_task") - @patch("tasks.tasks.aggregate_daily_severity_task") - @patch("tasks.tasks.perform_scan_summary_task") - @patch("tasks.tasks.Scan.objects.filter") - def test_dedupes_scans_to_latest_per_provider_per_day( - self, - mock_scan_filter, - mock_scan_summary_task, - mock_daily_severity_task, - mock_finding_group_task, - mock_resource_group_task, - mock_category_task, - mock_attack_surface_task, - mock_group, - mock_chain, - ): - """When several scans run on the same day for the same provider, only - the latest one is dispatched (matching the daily summary unique key).""" - provider_id = uuid.uuid4() - latest_scan_today = uuid.uuid4() - earlier_scan_today = uuid.uuid4() - today_late = datetime.now(tz=UTC) - today_early = today_late - timedelta(hours=4) - + mock_mute_findings.return_value = result mock_outer_group_result = MagicMock() mock_group.side_effect = lambda *args, **kwargs: ( list(args[0]) if args and hasattr(args[0], "__iter__") else None, mock_outer_group_result, )[1] - # Returned ordered by `-completed_at`, so the most recent comes first. - mock_scan_filter.return_value.order_by.return_value.values.return_value = [ - { - "id": latest_scan_today, - "completed_at": today_late, - "provider_id": provider_id, - }, - { - "id": earlier_scan_today, - "completed_at": today_early, - "provider_id": provider_id, - }, - ] + task_result = mute_findings_in_latest_scans_task( + tenant_id=tenant_id, + mute_rule_id=mute_rule_id, + provider_ids=provider_ids, + ) - result = reaggregate_all_finding_group_summaries_task(tenant_id=self.tenant_id) - - assert result == {"scans_reaggregated": 1} + assert task_result == result + mock_mute_findings.assert_called_once_with( + tenant_id=tenant_id, + mute_rule_id=mute_rule_id, + provider_ids=provider_ids, + ) for task_mock in ( mock_scan_summary_task, mock_daily_severity_task, @@ -3386,23 +3319,35 @@ class TestReaggregateAllFindingGroupSummaries: mock_category_task, mock_attack_surface_task, ): - task_mock.si.assert_called_once_with( - tenant_id=self.tenant_id, scan_id=str(latest_scan_today) - ) - mock_chain.assert_called_once() + assert task_mock.si.call_count == 2 + assert { + call.kwargs["scan_id"] for call in task_mock.si.call_args_list + } == set(scan_ids) + assert mock_chain.call_count == 2 mock_outer_group_result.apply_async.assert_called_once() @patch("tasks.tasks.chain") @patch("tasks.tasks.group") - @patch("tasks.tasks.Scan.objects.filter") - def test_no_completed_scans_skips_dispatch( - self, mock_scan_filter, mock_group, mock_chain + @patch("tasks.tasks.mute_findings_in_latest_scans") + def test_skips_reaggregation_when_no_scan_changed( + self, mock_mute_findings, mock_group, mock_chain, tenants_fixture ): - mock_scan_filter.return_value.order_by.return_value.values.return_value = [] + tenant_id = str(tenants_fixture[0].id) + mute_rule_id = str(uuid.uuid4()) + result = { + "findings_muted": 0, + "rule_id": mute_rule_id, + "scan_ids": [], + } + mock_mute_findings.return_value = result - result = reaggregate_all_finding_group_summaries_task(tenant_id=self.tenant_id) + task_result = mute_findings_in_latest_scans_task( + tenant_id=tenant_id, + mute_rule_id=mute_rule_id, + provider_ids=[], + ) - assert result == {"scans_reaggregated": 0} + assert task_result == result mock_group.assert_not_called() mock_chain.assert_not_called() diff --git a/api/uv.lock b/api/uv.lock index 079bc91c8f..a835e2f223 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -4835,8 +4835,8 @@ wheels = [ [[package]] name = "prowler" -version = "5.40.0" -source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#b6e9967da6bebd6c7b8b237317a2a95e2e0c65bc" } +version = "5.41.0" +source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#f05a490cd74a2c0f11a5d66d8ce29d03fa5c64a2" } dependencies = [ { name = "alibabacloud-actiontrail20200706" }, { name = "alibabacloud-credentials" }, @@ -4928,14 +4928,17 @@ dependencies = [ { name = "stackit-iaas" }, { name = "stackit-objectstorage" }, { name = "stackit-resourcemanager" }, + { name = "stackit-ske" }, { name = "tabulate" }, + { name = "truststore" }, { name = "tzlocal" }, { name = "uuid6" }, + { name = "zstandard" }, ] [[package]] name = "prowler-api" -version = "1.42.0" +version = "1.44.0" source = { virtual = "." } dependencies = [ { name = "cartography" }, @@ -6117,6 +6120,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/9c/38a74d0f7a89b4320f6d2366fb660638bda8860daa08748b12c713d84381/stackit_resourcemanager-0.8.0-py3-none-any.whl", hash = "sha256:dd04bb8353d041a137c4dcba190beabded7acfaff1bc98b218fce20a99389ebc", size = 81288, upload-time = "2026-05-13T09:43:07.81Z" }, ] +[[package]] +name = "stackit-ske" +version = "1.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "requests" }, + { name = "stackit-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/9e/df3ad585cb96d028354f4253568e9879d81bb9395d5ebfa268fa9350e2df/stackit_ske-1.12.0.tar.gz", hash = "sha256:62814279f3b7fb2387648f92d14453a8905ad60115c07579f2741ddb7d1fcc94", size = 37239, upload-time = "2026-06-30T11:18:49.39Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/37/dc54fb7185a2d4da37308322ea1a7b992312030b2e37262de4eb4003f5c7/stackit_ske-1.12.0-py3-none-any.whl", hash = "sha256:45bd8084d87f14f818b3d7e824450248c8784ed204ca1b2dc108f491dcbdb1a3", size = 93142, upload-time = "2026-06-30T11:18:48.233Z" }, +] + [[package]] name = "statsd" version = "4.0.1" @@ -6225,6 +6243,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typer" version = "0.21.1" @@ -6621,6 +6648,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4a/81/2f171fbc4222066957e6b9220c4fb9146792540102c37e6d94e5d14aad97/zope_interface-8.2-cp312-cp312-win_amd64.whl", hash = "sha256:845d14e580220ae4544bd4d7eb800f0b6034fe5585fc2536806e0a26c2ee6640", size = 212444, upload-time = "2026-01-09T08:05:25.148Z" }, ] +[[package]] +name = "zstandard" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" }, + { url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" }, + { url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" }, + { url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" }, + { url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" }, + { url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" }, + { url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" }, + { url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" }, + { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, + { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, + { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, +] + [[package]] name = "zstd" version = "1.5.7.2" diff --git a/contrib/k8s/helm/prowler-api/values.yaml b/contrib/k8s/helm/prowler-api/values.yaml index a6074c7852..5332f9d2eb 100644 --- a/contrib/k8s/helm/prowler-api/values.yaml +++ b/contrib/k8s/helm/prowler-api/values.yaml @@ -212,6 +212,14 @@ mainConfig: # MEDIUM ecr_repository_vulnerability_minimum_severity: "MEDIUM" + # AWS Inspector2 + # aws.inspector2_coverage_recently_scanned + # Maximum days since Inspector2 last scanned an actively covered resource + inspector2_max_days_since_last_scan: 3 + # aws.inspector2_active_findings_within_max_age + # Maximum days an Inspector2 finding can stay active since it was first observed + inspector2_active_finding_max_age_days: 192 + # AWS Trusted Advisor # aws.trustedadvisor_premium_support_plan_subscribed verify_premium_support_plans: True diff --git a/dashboard/compliance/fedramp_20x_ksi_low_aws.py b/dashboard/compliance/fedramp_20x_ksi_low_aws.py deleted file mode 100644 index 5ca220301f..0000000000 --- a/dashboard/compliance/fedramp_20x_ksi_low_aws.py +++ /dev/null @@ -1,46 +0,0 @@ -import warnings - -from dashboard.common_methods import get_section_containers_cis - -warnings.filterwarnings("ignore") - - -def get_table(data): - aux = data[ - [ - "REQUIREMENTS_ID", - "REQUIREMENTS_DESCRIPTION", - "REQUIREMENTS_ATTRIBUTES_SECTION", - "CHECKID", - "STATUS", - "REGION", - "ACCOUNTID", - "RESOURCEID", - ] - ].copy() - - # Shorten the long FedRAMP KSI descriptions for better display - ksi_short_names = { - "A secure cloud service offering will protect user data, control access, and apply zero trust principles": "Identity and Access Management", - "A secure cloud service offering will use cloud native architecture and design principles to enforce and enhance the Confidentiality, Integrity and Availability of the system": "Cloud Native Architecture", - "A secure cloud service provider will ensure that all system changes are properly documented and configuration baselines are updated accordingly": "Change Management", - "A secure cloud service provider will continuously educate their employees on cybersecurity measures, testing them regularly": "Cybersecurity Education", - "A secure cloud service offering will document, report, and analyze security incidents to ensure regulatory compliance and continuous security improvement": "Incident Reporting", - "A secure cloud service offering will monitor, log, and audit all important events, activity, and changes": "Monitoring, Logging, and Auditing", - "A secure cloud service offering will have intentional, organized, universal guidance for how every information resource, including personnel, is secured": "Policy and Inventory", - "A secure cloud service offering will define, maintain, and test incident response plan(s) and recovery capabilities to ensure minimal service disruption and data loss": "Recovery Planning", - "A secure cloud service offering will follow FedRAMP encryption policies, continuously verify information resource integrity, and restrict access to third-party information resources": "Service Configuration", - "A secure cloud service offering will understand, monitor, and manage supply chain risks from third-party information resources": "Third-Party Information Resources", - } - - # Replace long descriptions with short names - use contains for partial matching - if not aux.empty: - for long_desc, short_name in ksi_short_names.items(): - mask = aux["REQUIREMENTS_DESCRIPTION"].str.contains( - long_desc, na=False, regex=False - ) - aux.loc[mask, "REQUIREMENTS_DESCRIPTION"] = short_name - - return get_section_containers_cis( - aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION" - ) diff --git a/dashboard/compliance/fedramp_20x_ksi_low_azure.py b/dashboard/compliance/fedramp_20x_ksi_low_azure.py deleted file mode 100644 index 5ca220301f..0000000000 --- a/dashboard/compliance/fedramp_20x_ksi_low_azure.py +++ /dev/null @@ -1,46 +0,0 @@ -import warnings - -from dashboard.common_methods import get_section_containers_cis - -warnings.filterwarnings("ignore") - - -def get_table(data): - aux = data[ - [ - "REQUIREMENTS_ID", - "REQUIREMENTS_DESCRIPTION", - "REQUIREMENTS_ATTRIBUTES_SECTION", - "CHECKID", - "STATUS", - "REGION", - "ACCOUNTID", - "RESOURCEID", - ] - ].copy() - - # Shorten the long FedRAMP KSI descriptions for better display - ksi_short_names = { - "A secure cloud service offering will protect user data, control access, and apply zero trust principles": "Identity and Access Management", - "A secure cloud service offering will use cloud native architecture and design principles to enforce and enhance the Confidentiality, Integrity and Availability of the system": "Cloud Native Architecture", - "A secure cloud service provider will ensure that all system changes are properly documented and configuration baselines are updated accordingly": "Change Management", - "A secure cloud service provider will continuously educate their employees on cybersecurity measures, testing them regularly": "Cybersecurity Education", - "A secure cloud service offering will document, report, and analyze security incidents to ensure regulatory compliance and continuous security improvement": "Incident Reporting", - "A secure cloud service offering will monitor, log, and audit all important events, activity, and changes": "Monitoring, Logging, and Auditing", - "A secure cloud service offering will have intentional, organized, universal guidance for how every information resource, including personnel, is secured": "Policy and Inventory", - "A secure cloud service offering will define, maintain, and test incident response plan(s) and recovery capabilities to ensure minimal service disruption and data loss": "Recovery Planning", - "A secure cloud service offering will follow FedRAMP encryption policies, continuously verify information resource integrity, and restrict access to third-party information resources": "Service Configuration", - "A secure cloud service offering will understand, monitor, and manage supply chain risks from third-party information resources": "Third-Party Information Resources", - } - - # Replace long descriptions with short names - use contains for partial matching - if not aux.empty: - for long_desc, short_name in ksi_short_names.items(): - mask = aux["REQUIREMENTS_DESCRIPTION"].str.contains( - long_desc, na=False, regex=False - ) - aux.loc[mask, "REQUIREMENTS_DESCRIPTION"] = short_name - - return get_section_containers_cis( - aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION" - ) diff --git a/dashboard/compliance/fedramp_20x_ksi_low_gcp.py b/dashboard/compliance/fedramp_20x_ksi_low_gcp.py deleted file mode 100644 index 5ca220301f..0000000000 --- a/dashboard/compliance/fedramp_20x_ksi_low_gcp.py +++ /dev/null @@ -1,46 +0,0 @@ -import warnings - -from dashboard.common_methods import get_section_containers_cis - -warnings.filterwarnings("ignore") - - -def get_table(data): - aux = data[ - [ - "REQUIREMENTS_ID", - "REQUIREMENTS_DESCRIPTION", - "REQUIREMENTS_ATTRIBUTES_SECTION", - "CHECKID", - "STATUS", - "REGION", - "ACCOUNTID", - "RESOURCEID", - ] - ].copy() - - # Shorten the long FedRAMP KSI descriptions for better display - ksi_short_names = { - "A secure cloud service offering will protect user data, control access, and apply zero trust principles": "Identity and Access Management", - "A secure cloud service offering will use cloud native architecture and design principles to enforce and enhance the Confidentiality, Integrity and Availability of the system": "Cloud Native Architecture", - "A secure cloud service provider will ensure that all system changes are properly documented and configuration baselines are updated accordingly": "Change Management", - "A secure cloud service provider will continuously educate their employees on cybersecurity measures, testing them regularly": "Cybersecurity Education", - "A secure cloud service offering will document, report, and analyze security incidents to ensure regulatory compliance and continuous security improvement": "Incident Reporting", - "A secure cloud service offering will monitor, log, and audit all important events, activity, and changes": "Monitoring, Logging, and Auditing", - "A secure cloud service offering will have intentional, organized, universal guidance for how every information resource, including personnel, is secured": "Policy and Inventory", - "A secure cloud service offering will define, maintain, and test incident response plan(s) and recovery capabilities to ensure minimal service disruption and data loss": "Recovery Planning", - "A secure cloud service offering will follow FedRAMP encryption policies, continuously verify information resource integrity, and restrict access to third-party information resources": "Service Configuration", - "A secure cloud service offering will understand, monitor, and manage supply chain risks from third-party information resources": "Third-Party Information Resources", - } - - # Replace long descriptions with short names - use contains for partial matching - if not aux.empty: - for long_desc, short_name in ksi_short_names.items(): - mask = aux["REQUIREMENTS_DESCRIPTION"].str.contains( - long_desc, na=False, regex=False - ) - aux.loc[mask, "REQUIREMENTS_DESCRIPTION"] = short_name - - return get_section_containers_cis( - aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION" - ) diff --git a/docs/changelog.mdx b/docs/changelog.mdx index d13b5ef3ce..e41b23abb9 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -4,6 +4,151 @@ description: "Track new features, improvements, provider updates, and bug fixes rss: true --- + + ### ☁️ AWS — ISO Partitions + + Prowler now resolves regions and services for the AWS ISO partitions (`aws-iso`, `aws-iso-b`, `aws-iso-e` and `aws-iso-f`) the same way it does for the commercial, China, European Sovereign Cloud and GovCloud partitions. The region matrix is filled from the endpoint metadata bundled with botocore, which needs no credentials or network access, so it covers partitions that are air-gapped from the internet. Scanning them no longer requires a hand-edited `aws_regions_by_service.json`: ISO regions such as `us-isob-east-1` are accepted by `--region` and `--excluded-region`. + + Deployments that declare `PROWLER_AWS_PARTITION` also keep their bootstrap STS calls in the configured region when it belongs to that partition. An install in `us-gov-west-1` that reaches AWS only through its own VPC endpoints is no longer sent to `us-gov-east-1`, where the connection check and the scan used to time out. + + Read more in the [AWS Regions and Partitions documentation](https://docs.prowler.com/user-guide/providers/aws/regions-and-partitions). + + ### ⏱️ AWS — Configurable Timeouts for Restricted Networks + + Scans from networks with restricted egress (VPC endpoints for only some services, GovCloud or private deployments) could take hours: Boto3 waits 60 seconds to connect by default and retries connection errors, so every service without a reachable endpoint cost up to four 60-second attempts in every region. Prowler now lowers the default connect timeout to 10 seconds, keeps the read timeout at 60 seconds, and exposes both through `--aws-connect-timeout` and `--aws-read-timeout`, or through the `PROWLER_AWS_BOTO3_CONNECT_TIMEOUT` and `PROWLER_AWS_BOTO3_READ_TIMEOUT` environment variables for deployments without a CLI. `--aws-retries-max-attempts 0` now disables retries instead of silently falling back to three, leaving a single attempt per call. + + Read more in the [Boto3 configuration documentation](https://docs.prowler.com/user-guide/providers/aws/boto3-configuration). + + ### 🐳 Image Provider — Reusable Vulnerability Database + + The Image provider now honors `TRIVY_CACHE_DIR`. When the variable names a directory, Trivy keeps its vulnerability database there and Prowler leaves the directory in place after the scan, so the database is downloaded once instead of on every scan. Hosts without internet access can now scan images by pointing `TRIVY_CACHE_DIR` at a pre-populated database and setting `TRIVY_SKIP_DB_UPDATE=true`. Without the variable, the temporary cache is created and removed as before. + + Read more in the [Image provider documentation](https://docs.prowler.com/user-guide/providers/image/getting-started-image#vulnerability-database-cache). + + ### 🎫 Jira Integration — Faster Connection Test + + Testing a Jira integration no longer reports a false failure on accounts with many projects. The connection test fetched the issue types of every project one request at a time, which could outlast the wait in the UI even when the check was about to succeed. Issue types are now fetched concurrently, a project whose issue types the integration user cannot see is no longer logged as an error, and the Integrations page keeps following the connection test instead of giving up after about a minute. + + Read more in the [Jira integration documentation](https://docs.prowler.com/user-guide/tutorials/prowler-app-jira-integration). + + ### 📚 Compliance — Catalog Integrity Fixes + + A new integrity test runs over every compliance framework, asserting unique requirement IDs, no check listed twice within a requirement, and that every referenced check exists for its provider. The fixes it drove span 42 frameworks across AWS, Azure, GCP, GitHub, Kubernetes and Microsoft 365: + + - **Duplicate requirement IDs:** identical copies are removed, and distinct requirements that shared an ID get their own, such as `1.10` in CIS AWS 5.0 and `rc_rp_1` for RC.RP-1 in NIST CSF 1.1. In Prowler ThreatScore for Azure, SQL auditing retention moves from `3.2.1` to `3.2.4`, and requirement `1.2.1` of Prowler ThreatScore for GCP now points to `iam_sa_no_user_managed_keys`. + - **Stale check references:** checks that no longer exist are replaced with their current name when there is a direct equivalent, or removed so the requirement reports as manual. Most of these were in the FedRAMP 20x KSI frameworks. + + Renamed requirement IDs appear as new requirements for scans run after the upgrade. + + The compliance overview task that runs after every scan is also faster: ThreatScore mappings are read once from the compliance template instead of from every finding, and rows are inserted with time-ordered `uuid7` IDs grouped by framework and requirement. + + Read more in the [Compliance documentation](https://docs.prowler.com/user-guide/compliance/tutorials/compliance). + + ### 🔍 Checks + + `rolesanywhere_profile_restricts_session_permissions`, `iam_role_service_trust_restricts_source_to_account` and `codebuild_project_uses_allowed_github_organizations` no longer crash with `TypeError` when the scanning role is denied `iam:ListRoles`, which dropped every finding of those checks for the account. Without the role inventory, an enabled IAM Roles Anywhere profile without session scoping reports `MANUAL`, and CodeBuild projects whose service role cannot be resolved are skipped. + + Explore all AWS checks at [Prowler Hub](https://hub.prowler.com/check?provider=aws). + + ### 🔐 Security Updates + + - `next` upgraded to 16.3.3 in the UI, patching unauthenticated remote code execution through AVIF image optimization ([GHSA-2xp9-vwfh-vxw4](https://github.com/advisories/GHSA-2xp9-vwfh-vxw4)) and on Windows-hosted servers ([GHSA-p293-qw3h-jr36](https://github.com/advisories/GHSA-p293-qw3h-jr36)). + - `sharp` upgraded to 0.35.4 in the UI, patching libheif image-decoding vulnerabilities ([GHSA-rgj7-g3m4-5g8c](https://github.com/advisories/GHSA-rgj7-g3m4-5g8c)). + - `nanoid`, `js-yaml` and `postcss`, plus eleven transitive UI dependencies, upgraded to patched versions, resolving 40 npm audit advisories (21 high, 15 moderate, 4 low). + - `libuuid` upgraded to 2.41.6-r1 in the MCP Server image, patching CVE-2026-53612, CVE-2026-53613, CVE-2026-53614, CVE-2026-76642, CVE-2026-78408 and CVE-2026-78410. + + See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.42.0) for the complete list of changes. + + + + ### 📥 Scans — Import Findings from the Browser + + + This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing). + + + Findings produced outside the platform, by the Prowler CLI or a CI pipeline, can now be brought into the app without leaving the browser. The Scans page gains an "Import Findings" dialog that takes a Prowler `.ocsf.json` report by drag-and-drop or file picker, hands it to the ingestion API and tracks the job to completion, reporting how many records were processed and how many were invalid. Files that are not a `.ocsf.json` report, or are empty, are refused before any upload starts, and a rejected upload or a failed status poll can be retried in place. The dialog is available to roles holding the Manage Ingestions permission. + + ![Import Findings button on the Scans page](/images/prowler-app/import-findings/import-findings-button.png) + + ![Import findings dialog with the drag-and-drop area](/images/prowler-app/import-findings/import-findings-dialog.png) + + Read more in the [Import Findings documentation](https://docs.prowler.com/user-guide/tutorials/prowler-import-findings#using-the-ui). + + ### 🎫 Jira Integration — Finding Reference in Every Issue + + Every Jira issue created from a finding now carries a stable reference back to it. Issues are labeled `prowler`, `prowler-`, `prowler-`, `prowler-` and `prowler-finding-`, so they can be filtered, searched with JQL or matched by automation; labels are sanitized to Jira's limits so a long or unusual value never blocks issue creation. The issue also links back to the finding in Prowler, filtered by its UID so the link keeps working after later scans, and names the Prowler organization that sent it. Prowler Cloud always includes the link; Prowler Local Server enables it by setting `DJANGO_UI_BASE_URL` in the API environment. + + Read more in the [Jira integration documentation](https://docs.prowler.com/user-guide/tutorials/prowler-app-jira-integration). + + ### 📚 Compliance — CIS Google Workspace Foundations Benchmark v1.4.0 + + Prowler now ships the CIS Google Workspace Foundations Benchmark v1.4.0. Alongside the new framework, the Google Workspace checks mapped to CIS were reworked to evaluate the benchmark's full audit procedure instead of a single condition, so Gmail spoofing actions, 2-Step Verification, password expiration and alert severity left on Google's defaults no longer pass. Expect new `FAIL` findings on domains that rely on those defaults. Three accuracy fixes also land: + + - `security_2sv_enforced` and `security_2sv_hardware_keys_admins` report `MANUAL` instead of judging domain-wide values that a group or a sub-organizational unit overrides; a domain-wide failure is still reported as such, with the override noted. + - `rules_*_alert_configured` no longer passes a rule whose delivery to the alert center is disabled. + - `security_password_policy_strong` no longer fails a domain that never touched the password strength setting, since Google enforces strong passwords by default. + + `security_login_challenges_configured` was unmapped from CIS Google Workspace requirement 4.1.4.1 (Post-SSO verification) and `security_2sv_enforced` from CISA SCuBA `GWS.COMMONCONTROLS.1.1` (phishing-resistant MFA), because neither check can prove what those requirements ask for. + + Read more in the [Compliance documentation](https://docs.prowler.com/user-guide/compliance/tutorials/compliance). + + ### 🔍 Checks + + Ten new AWS checks land in this release, eight of them contributed by @tamg-aws. Thank you! + + #### Amazon Bedrock AgentCore + + - `iam_policy_passrole_to_bedrock_agentcore_restricted` flags customer-managed IAM policies that allow `iam:PassRole` over every role where the passed role can reach Bedrock AgentCore, so any principal holding the policy could run agent code under any role in the account. + - `iam_policy_no_agentcore_workload_access_token_wildcard` flags customer-managed IAM policies that allow `bedrock-agentcore:GetWorkloadAccessToken`, `GetWorkloadAccessTokenForJWT` or `GetWorkloadAccessTokenForUserId` on resources reaching workload identities other than the caller's own. + - `cloudwatch_log_group_agentcore_data_protection_policy_enabled` verifies that Bedrock AgentCore log groups mask sensitive data with a CloudWatch Logs data protection policy. The log group prefixes are configurable through `agentcore_log_group_name_prefixes` in `config.yaml`. + + #### Amazon GuardDuty + + - `guardduty_runtime_monitoring_enabled` flags detectors without unified Runtime Monitoring, the only feature that covers Amazon EC2 instances and Amazon ECS on AWS Fargate tasks in addition to Amazon EKS. + - `guardduty_ai_protection_enabled` flags detectors without AI Protection, which analyzes CloudTrail data events from Amazon Bedrock, Amazon Bedrock AgentCore and Amazon SageMaker AI. A detector that does not report the feature is `MANUAL` rather than `FAIL`. + + `guardduty_eks_runtime_monitoring_enabled` no longer reports `FAIL` for detectors that use unified Runtime Monitoring, which is mutually exclusive with `EKS_RUNTIME_MONITORING` and already covers Amazon EKS. + + #### Amazon ECR and EKS + + - `ecr_registry_enhanced_scanning_enabled` verifies that the ECR registry scan type is enhanced (Amazon Inspector, covering programming language packages and continuous rescanning) instead of basic, reporting `MANUAL` when the registry scanning configuration cannot be read. + - `eks_cluster_vpc_cni_network_policy_enforced` flags EKS clusters whose Amazon VPC CNI managed add-on does not enable Kubernetes network policy enforcement, reporting `MANUAL` where the EKS API cannot show the setting. + + #### AWS IAM, Elastic Beanstalk and MemoryDB + + - `iam_role_service_trust_restricts_source_to_account` flags IAM roles whose trust policy lets an AWS service principal assume the role without confining the request to a specific source account, including trust policies that `iam_role_cross_service_confused_deputy_prevention` does not evaluate. + - `elasticbeanstalk_environment_no_secrets_in_configuration` scans the option settings of every Elastic Beanstalk environment for hardcoded secrets. Thanks to @haneul-24! + - `memorydb_cluster_in_transit_encryption_enabled` verifies that MemoryDB clusters have in-transit encryption (TLS) enabled. Thanks to @UTKARSH698! + + Explore all AWS checks at [Prowler Hub](https://hub.prowler.com/check?provider=aws). + + ### 🐳 Image Provider — On-Premises Registries + + Scanning registries that live on private networks is now supported end to end. `PROWLER_IMAGE_PROVIDER_ALLOWED_PRIVATE_NETWORKS` takes a comma-separated list of IPs and CIDRs the provider may reach, while every other non-public address, including link-local and loopback, stays blocked by the SSRF guard. Authentication negotiation is also more resilient: the provider falls back to Basic when a registry such as Harbor rejects the negotiated bearer token, and switches to a bearer token when the server answers a Basic or anonymous request with a Bearer challenge. `--registry-insecure` now propagates to Trivy through `TRIVY_INSECURE`, so images behind self-signed certificates can be pulled and scanned, not just enumerated. The flag now disables certificate validation for the image pull too, so keep it for trusted internal registries only. + + Registry scans also skip non-image OCI artifacts (Helm charts, cosign signatures, SBOM attestations), no longer abort the whole scan when Trivy fails on a single image, and enumerate repositories in parallel instead of one request at a time. + + Read more in the [Image provider documentation](https://docs.prowler.com/user-guide/providers/image/getting-started-image#on-premises-registries-and-private-networks). + + ### 🛠️ Prowler MCP Server — Tool Failures Reported as Errors + + Prowler Local Server tools now report a failure as an MCP tool execution error (`isError: true`, with the explanation in `content`) instead of a successful result carrying an `{"error": ...}` object, which clients and models read as a success. The Prowler Documentation and Prowler Hub tools follow the same rule: `prowler_docs_search` no longer reports a failed search as zero matches, `prowler_docs_get_document` no longer reports a failed fetch as a missing page, and `prowler_hub_get_check_code` and `prowler_hub_get_check_fixer` now name the provider a check ID actually belongs to instead of reporting it as nonexistent. `prowler_get_compliance_framework_state_details` also rejects a call that passes both `scan_id` and `provider_id` instead of silently ignoring the provider. + + Read more in the [Prowler MCP documentation](https://docs.prowler.com/getting-started/products/prowler-mcp). + + ### 🙌 External Contributors + + Thank you to our community contributors for this release! + + - @tamg-aws: GuardDuty unified Runtime Monitoring and AI Protection checks ([#12564](https://github.com/prowler-cloud/prowler/pull/12564)), EKS VPC CNI network policy check ([#12661](https://github.com/prowler-cloud/prowler/pull/12661)), ECR enhanced scanning check ([#12660](https://github.com/prowler-cloud/prowler/pull/12660)), Bedrock AgentCore IAM and service trust checks ([#12664](https://github.com/prowler-cloud/prowler/pull/12664)), AgentCore log group data protection check ([#12662](https://github.com/prowler-cloud/prowler/pull/12662)), and fixes to ECR scan frequency ([#12560](https://github.com/prowler-cloud/prowler/pull/12560)), CloudWatch metric filters ([#12561](https://github.com/prowler-cloud/prowler/pull/12561)) and SageMaker direct internet access ([#12659](https://github.com/prowler-cloud/prowler/pull/12659)) + - @haneul-24: AWS `elasticbeanstalk_environment_no_secrets_in_configuration` check ([#12378](https://github.com/prowler-cloud/prowler/pull/12378)) + - @UTKARSH698: AWS `memorydb_cluster_in_transit_encryption_enabled` check ([#12246](https://github.com/prowler-cloud/prowler/pull/12246)) + - @ye11oc4t: GitHub repository discovery pagination for unscoped scans ([#12460](https://github.com/prowler-cloud/prowler/pull/12460)) + + See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.41.0) for the complete list of changes. + + ### 💬 Slack Integration — Alert Channel Destinations @@ -409,7 +554,7 @@ rss: true All checks are fully passive, using AWS APIs and CloudTrail with no instance access or SSM agent required, and are mapped across 23 compliance frameworks, including NIST 800-53 Rev 5, PCI-DSS v4.0, ISO 27001:2022, SOC 2, HIPAA, and MITRE ATT&CK. - Read more about it this [blog post](https://prowler.com/blog/your-llm-runs-in-a-nitro-enclave-who-is-checking-the-enclave). + Read more about it in this [blog post](https://prowler.com/blog/your-llm-runs-in-a-nitro-enclave-who-is-checking-the-enclave). Try them out now at [cloud.prowler.com](https://cloud.prowler.com/sign-up)! diff --git a/docs/developer-guide/aws-details.mdx b/docs/developer-guide/aws-details.mdx index cc8225c45e..55815f7e2c 100644 --- a/docs/developer-guide/aws-details.mdx +++ b/docs/developer-guide/aws-details.mdx @@ -58,7 +58,7 @@ The AWS provider implementation follows the general [Provider structure](/develo The generic service pattern is described in [service page](/developer-guide/services#service-structure-and-initialisation). You can find all the right now implemented services in the following locations: - Directly in the code, in location [`prowler/providers/aws/services/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/aws/services) -- In the [Prowler Hub](https://hub.prowler.com/). For a more human-readable view. +- In the [Prowler Hub](https://hub.prowler.com/) for a more human-readable view. The best reference to understand how to implement a new service is following the [service implementation documentation](/developer-guide/services#adding-a-new-service) and taking other services already implemented as reference. In next subsection you can find a list of common patterns that are used across all AWS services. @@ -132,7 +132,7 @@ def _get_email_identities(self, identity): The AWS checks pattern is described in [checks page](/developer-guide/checks). You can find all the right now implemented checks: - Directly in the code, within each service folder, each check has its own folder named after the name of the check. (e.g. [`prowler/providers/aws/services/s3/s3_bucket_acl_prohibited/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/aws/services/s3/s3_bucket_acl_prohibited)) -- In the [Prowler Hub](https://hub.prowler.com/). For a more human-readable view. +- In the [Prowler Hub](https://hub.prowler.com/) for a more human-readable view. The best reference to understand how to implement a new check is following the [check creation documentation](/developer-guide/checks#creating-a-check) and taking other similar checks as reference. diff --git a/docs/developer-guide/checks.mdx b/docs/developer-guide/checks.mdx index 39ead51dfa..1bbb27a725 100644 --- a/docs/developer-guide/checks.mdx +++ b/docs/developer-guide/checks.mdx @@ -130,12 +130,42 @@ Each check **must** populate the `report.status` and `report.status_extended` fi - Status field: `report.status` - `PASS` – Assigned when the check confirms compliance with the configured value. - `FAIL` – Assigned when the check detects non-compliance with the configured value. - - `MANUAL` – This status must not be used unless manual verification is necessary to determine whether the status (`report.status`) passes (`PASS`) or fails (`FAIL`). + - `MANUAL` – This status must not be used unless manual verification is necessary to determine whether the status (`report.status`) passes (`PASS`) or fails (`FAIL`). This includes the case where Prowler could not retrieve the data needed to evaluate the resource (see below). - Status extended field: `report.status_extended` - It **must** end with a period (`.`). - It **must** include the audited service, the resource, and a concise explanation of the check result, for instance: `EC2 AMI ami-0123456789 is not public.`. +### Permission and Data-Availability Errors Are Not Findings + +A `FAIL` must only be emitted when an insecure condition has actually been detected. A check **must never** report `FAIL` because the underlying API call failed: missing permissions or scopes on the scanning identity, an API that is not enabled, a feature that is not licensed, or data that could not be retrieved are scan-configuration problems, not security issues. Reporting them as `FAIL` surfaces a misleading (and often high-severity) finding to the user and skews compliance scores. + +When the service layer cannot obtain the data a check depends on, the check must: + +1. Emit a single `MANUAL` finding scoped to the widest affected resource (the tenant, account, project or subscription), not one finding per resource. For example, if user registration details cannot be read, emit one tenant-level `MANUAL` instead of one per user. +2. Explain in `status_extended` that the check could not be evaluated and what to fix, naming the permission, scope, API or license required, for instance: `Cannot evaluate credential exposure for privileged users: unable to query Microsoft Defender XDR Advanced Hunting. Verify that the ThreatHunting.Read.All permission is granted to the scanning application.` +3. Leave the check's severity untouched. Do not override `report.check_metadata.Severity` to hide the problem. + +The service layer must make the distinction possible: log the error and expose it to checks in a way that cannot be confused with a legitimate empty result. Common patterns already used in Prowler are: + +- Defaulting the attribute to `None` (data could not be read) instead of `[]`/`{}` (data was read and is empty), e.g. the `metric_filters is not None` guard in `prowler/providers/aws/services/cloudwatch/lib/metric_filters.py`. +- Keeping an availability flag raised on any denied listing, e.g. `logs_client.metric_filters_unavailable` consumed by the AWS CloudWatch metric filter checks. +- Keeping an error flag or message next to the data, e.g. `entra_client.user_registration_details_error` in M365 or `*_scan_errors` in AWS Bedrock. +- Keeping a set of resources whose lookup failed, e.g. `accessapproval_client.settings_lookup_failed` in GCP. + +Make sure the error branch only captures real access errors. A `404`/not-found response frequently means the feature is simply not configured, which **is** a legitimate `FAIL`; a `403` or an unexpected exception is not. An "API not enabled" error is usually a scan-configuration problem too — **except** when the API's activation is itself the control being audited (e.g. GCP Access Approval: with `accessapproval.googleapis.com` disabled the feature provably cannot be enabled, so a definitive API-disabled state is a legitimate `FAIL`, while an undetermined state stays `MANUAL`). + +```python +if _client. is None: + report = CheckReport(metadata=self.metadata(), resource={}) + report.resource_name = "" + report.resource_id = "" + report.status = "MANUAL" + report.status_extended = "Cannot evaluate : could not be retrieved. Verify that is granted to the scanning identity." + findings.append(report) + return findings +``` + ### Prowler's Check Severity Levels The severity of each check is defined in the metadata file using the `Severity` field. Severity values are always lowercase and must be one of the predefined categories below. @@ -438,6 +468,7 @@ The metadata structure is enforced in code using a Pydantic model. For reference - Use clear, actionable, and user-friendly language in `status_extended` to explain the result. Always provide information to identify the resource. - Use helper functions/utilities for repeated logic to avoid code duplication. Save them in the `lib` folder of the service. - Handle exceptions gracefully: catch errors per resource, log them, and continue processing other resources. +- Never report `FAIL` because data could not be retrieved (missing permissions, API not enabled, feature not licensed). Emit a single `MANUAL` finding explaining what is required instead; see [Permission and Data-Availability Errors Are Not Findings](#permission-and-data-availability-errors-are-not-findings). - Document the check with a class and function level docstring explaining what it does, what it checks, and any caveats or provider-specific behaviors. - Use type hints for the `execute()` method (e.g., `-> list[CheckReport]`) for clarity and static analysis. - Ensure checks are efficient; avoid excessive nested loops. If the complexity is high, consider refactoring the check. diff --git a/docs/developer-guide/configurable-checks.mdx b/docs/developer-guide/configurable-checks.mdx index f0cc268886..c0cfb74cae 100644 --- a/docs/developer-guide/configurable-checks.mdx +++ b/docs/developer-guide/configurable-checks.mdx @@ -155,6 +155,8 @@ Only fields with a numeric range, a fixed value set, or a length cap are listed. | `max_days_secret_unused` | `7..365` days | | | `max_days_secret_unrotated` | `1..180` days | NIST IA-5: rotate quarterly; CIS ≤90 | | `min_kinesis_stream_retention_hours` | `24..8760` h | 1 day .. 1 year | +| `inspector2_max_days_since_last_scan` | `1..90` days | | +| `inspector2_active_finding_max_age_days` | `1..365` days | Default `192` matches the FedRAMP 20x rule that marks vulnerabilities still open after 192 days as accepted | | `shodan_api_key` | ≤512 chars | | ### Azure diff --git a/docs/developer-guide/debugging.mdx b/docs/developer-guide/debugging.mdx index a3b46acae4..20d71f81f2 100644 --- a/docs/developer-guide/debugging.mdx +++ b/docs/developer-guide/debugging.mdx @@ -11,7 +11,7 @@ Visual Studio Code (also referred to as VSCode) provides an integrated debugger ### Debugging Configuration Example -The following file is an example of a [debugging configuration](https://code.visualstudio.com/docs/editor/debugging#_launch-configurations) file for [Virtual Studio Code](https://code.visualstudio.com/). +The following file is an example of a [debugging configuration](https://code.visualstudio.com/docs/editor/debugging#_launch-configurations) file for [Visual Studio Code](https://code.visualstudio.com/). This file must be placed inside the *.vscode* directory and named *launch.json*: diff --git a/docs/developer-guide/end2end-testing.mdx b/docs/developer-guide/end2end-testing.mdx index 9b7402b5f3..19e9457d65 100644 --- a/docs/developer-guide/end2end-testing.mdx +++ b/docs/developer-guide/end2end-testing.mdx @@ -51,7 +51,7 @@ When adding or maintaining E2E tests for Prowler Local Server, follow these guid ``` 5. **Tag and document scenarios** - - Follow the existing naming convention for suites and test cases (for example, `SCANS-E2E-001`, `PROVIDER-E2E-003`) and use tags such as `@e2e`, `@serial` and feature tags (for example, `@providers`, `@scans`,`@aws`) to filter and organize tests. + - Follow the existing naming convention for suites and test cases (for example, `SCANS-E2E-001`, `PROVIDER-E2E-003`) and use tags such as `@e2e`, `@serial` and feature tags (for example, `@providers`, `@scans`, `@aws`) to filter and organize tests. **Example:** ```typescript @@ -72,7 +72,7 @@ When adding or maintaining E2E tests for Prowler Local Server, follow these guid } ); ``` - - Document each one in the Markdown files under `ui/tests`, including **Priority**, **Tags**, **Description**, **Preconditions**, **Flow steps**, **Expected results**,**Key verification points** and **Notes**. + - Document each one in the Markdown files under `ui/tests`, including **Priority**, **Tags**, **Description**, **Preconditions**, **Flow steps**, **Expected results**, **Key verification points** and **Notes**. **Example** ```Markdown @@ -257,7 +257,7 @@ To execute E2E tests for Prowler Local Server: pnpm run test:e2e ``` - This command runs Playwright with the configured projects + This command runs Playwright with the configured projects. 2. **Run E2E tests with the Playwright UI runner** diff --git a/docs/developer-guide/environment-variables.mdx b/docs/developer-guide/environment-variables.mdx index e5b581ac77..7473a3536f 100644 --- a/docs/developer-guide/environment-variables.mdx +++ b/docs/developer-guide/environment-variables.mdx @@ -41,6 +41,17 @@ The former build-time variables map to the new runtime variables as follows: `UI_CLOUD_ENABLED` is a plain runtime boolean flag that enables Prowler Cloud behavior when set to the exact string `"true"` and defaults to off; unlike the other renamed variables it has no legacy fallback, so `NEXT_PUBLIC_IS_CLOUD_ENV` is no longer read. +`UI_SELF_REGISTRATION_ENABLED` is a runtime opt-out flag that Prowler Local Server reads only when `UI_CLOUD_ENABLED` is `"true"`. It defaults to on and turns off when set to `"false"`, matched case-insensitively so the same value can be shared with a backend setting written `False`. When it is off, the sign-up page only opens with an invitation token, the sign-in page drops its "Sign up" link, and the profile hides "Create organization"; invited users can still complete their registration. Outside a Prowler Cloud deployment the flag is ignored and account creation stays open. +## Registry UI Rollout and Rollback + +`UI_REGISTRY_ENABLED` is an optional runtime flag for Prowler Cloud and Private Cloud. Registry is eligible only when both `UI_CLOUD_ENABLED` and `UI_REGISTRY_ENABLED` are the exact string `"true"` and the current user has the backend-authorized `manage_registry` permission. Unset, `"false"`, or malformed values fail closed. The flag defaults to off and is not a replacement for backend authorization. Registry access is independent of billing; Private Cloud can use it with `CLOUD_BILLING_ENABLED=false`. + +Roll out Registry only after the Registry backend dependency is deployed, intended roles have `manage_registry`, and acceptance with real credentials has exercised installation, provider account creation, credentials, connection, and scan launch. Deploy the UI with `UI_REGISTRY_ENABLED` unset or `"false"`; set it to `"true"` only in the prepared process environment, then restart or otherwise apply the environment update required by the platform. A Registry key must belong to the configured Registry environment; a production key does not authenticate against a development Registry. + +The catalog displays all artifacts, including built-ins and packages containing only checks or compliance frameworks. Only external provider artifacts support Add. After confirmed installation, open Providers and select the option labeled Registry to configure an account. Creating accounts and running scans also require the corresponding provider and scan permissions. Removing an artifact keeps existing provider accounts, but future connections or scans can fail until the artifact is installed again. + +To roll back, set `UI_REGISTRY_ENABLED=false` or remove it and apply the environment update. Proxy, page, and action checks deny on their next request. Navigation refreshes from server-authorized access when the page is requested again. Rollback does not delete Registry credentials, tenant artifact records, or provider accounts. + The build-time-only Sentry variables used for source-map upload — `SENTRY_ORG`, `SENTRY_PROJECT`, `SENTRY_AUTH_TOKEN`, and `SENTRY_RELEASE` — keep their names, as they are not part of Prowler Local Server's runtime configuration. ## Enabling Third-Party Integrations diff --git a/docs/developer-guide/provider.mdx b/docs/developer-guide/provider.mdx index e36c546f07..09bb78fef2 100644 --- a/docs/developer-guide/provider.mdx +++ b/docs/developer-guide/provider.mdx @@ -26,7 +26,7 @@ For providers supported by Prowler, refer to [Prowler Hub](https://hub.prowler.c Prowler supports several types of providers, each with its own implementation pattern and use case. Understanding these differences is key to designing your provider correctly. -### Classifying your Provider +### Classifying Your Provider Before implementing a new provider, you need to determine which type it belongs to. This classification will guide your implementation approach and help you choose the right patterns and libraries. @@ -1091,7 +1091,7 @@ Main registration makes your provider discoverable by Prowler's core system. It' cis.batch_write_data_to_file() ``` -#### Step 11: Register in the list of providers +#### Step 11: Register in the List of Providers **Explanation:** This is needed to be able to use the provider in the generic checks. The provider must be registered in the `init_global_provider` method to handle CLI arguments and initialization. @@ -1967,7 +1967,7 @@ Main registration makes your provider discoverable by Prowler's core system. It' This step is the same as the [SDK providers](#step-10-register-in-main). -#### Step 11: Register in the list of providers +#### Step 11: Register in the List of Providers **Explanation:** This is needed to be able to use the provider in the generic checks. The provider must be registered in the `init_global_provider` method to handle CLI arguments and initialization. @@ -2649,7 +2649,7 @@ Main registration makes your provider discoverable by Prowler's core system. It' This step is the same as the [SDK providers](#step-10-register-in-main). -#### Step 7: Register in the list of providers +#### Step 7: Register in the List of Providers **Explanation:** This is needed to be able to use the provider in the generic checks. The provider must be registered in the `init_global_provider` method to handle CLI arguments and initialization. @@ -2809,7 +2809,7 @@ def validate_your_provider_uid(value): **Provider Model:** The `Provider` model already exists and supports all provider types. Ensure your provider type is included in the choices. -### 2.2. Add the provider to the Provider Choices +### 2.2. Add the Provider to the Provider Choices Update the `return_prowler_provider` function to include your provider. This function is crucial for the API to instantiate the correct provider class. @@ -3210,7 +3210,7 @@ class YourProviderAPITestCase(APITestCase): self.assertEqual(response.status_code, 201) ``` -#### 2.6.1. Add your mocked provider to the tests +#### 2.6.1. Add Your Mocked Provider to the Tests If needed, add a named provider fixture or extend the provider factory defaults so tests can request only the provider they need. @@ -3273,7 +3273,7 @@ Your provider will be available through these endpoints: - `DELETE /api/v1/providers/{id}/` - Delete provider - `POST /api/v1/providers/secrets/` - Add provider credentials -### 2.9. Update the provider if needed +### 2.9. Update the Provider If Needed Depending on your provider's authentication requirements, you may need to add new authentication methods that are compatible with the API. This involves updating the provider class to support additional credential types beyond the basic ones. diff --git a/docs/developer-guide/security-compliance-framework.mdx b/docs/developer-guide/security-compliance-framework.mdx index b75c501845..1d9e33cee9 100644 --- a/docs/developer-guide/security-compliance-framework.mdx +++ b/docs/developer-guide/security-compliance-framework.mdx @@ -19,7 +19,7 @@ A compliance framework must represent the **complete state** of the source catal Requirement coverage feeds the compliance percentage calculations and the metadata surfaces (dashboards, widgets, exports). Missing requirements skew those metrics and break the report as a faithful snapshot of the framework. -### Two supported schemas +### Two Supported Schemas | Schema | When to use | File location | Discovered as | | --- | --- | --- | --- | @@ -46,7 +46,7 @@ Before adding a new framework, complete the following checks: ## Universal Compliance Framework -### Where the file lives +### Where the File Lives Place the file at the top level of the compliance directory: @@ -58,7 +58,7 @@ Examples in the repository: `prowler/compliance/csa_ccm_4.0.json`, `prowler/comp The file is auto-discovered — there is **no** need to register it in any `__init__.py`, modify `prowler/lib/outputs/`, or update any other Python module. The framework key Prowler CLI accepts via `--compliance` is the basename of the JSON file without `.json` (`dora_2022_2554.json` → `dora_2022_2554`). -### Top-level structure +### Top-Level Structure ```json { @@ -199,7 +199,7 @@ Per requirement: For MITRE-style frameworks, additional optional fields are available on the requirement: `tactics`, `sub_techniques`, `platforms`, `technique_url` (these are populated automatically when adapting a legacy MITRE JSON to the universal model). -### Multi-provider frameworks +### Multi-Provider Frameworks A single universal file can cover any number of providers. The framework appears under each provider's `--list-compliance` output as long as **at least one** requirement has that provider key in its `checks` dict. @@ -227,7 +227,7 @@ The legacy schema spans **four layers** — a complete contribution must touch e The universal schema collapses Layers 3 and 4 into declarative configuration inside the JSON — that is the main reason it is preferred for new contributions. -### Directory structure and file naming +### Directory Structure and File Naming Compliance frameworks live at: @@ -260,7 +260,7 @@ prowler/lib/outputs/compliance// └── __init__.py ``` -### JSON schema reference +### JSON Schema Reference Every legacy compliance file is a JSON document with the following top-level keys. `Framework`, `Name` and `Provider` are validated non-empty by the root validator `framework_and_provider_must_not_be_empty` (`compliance_models.py`). @@ -363,7 +363,7 @@ For the remaining attribute classes (`AWS_Well_Architected_Requirement_Attribute The `Attributes` field is a Pydantic `Union`. The generic attribute model **must** remain the last element of that Union — otherwise Pydantic v1 silently coerces every framework into the generic shape and your specialized fields are dropped. Adding a brand-new attribute shape requires inserting the Pydantic class **before** `Generic_Compliance_Requirement_Attribute`. -#### Minimal working example +#### Minimal Working Example The following snippet is a complete, valid framework file named `my_framework_1.0_aws.json`, saved at `prowler/compliance/aws/my_framework_1.0_aws.json`. It uses the generic attribute shape for simplicity. @@ -409,7 +409,7 @@ The following snippet is a complete, valid framework file named `my_framework_1. } ``` -### Mapping checks to requirements +### Mapping Checks to Requirements Each requirement links to the Prowler checks that, together, produce a PASS or FAIL verdict for that control. @@ -426,7 +426,7 @@ To discover available checks: uv run python prowler-cli.py --list-checks ``` -### Supporting multiple providers (legacy) +### Supporting Multiple Providers (Legacy) The legacy schema binds each file to a single provider. To cover several providers with the same framework, ship one JSON file per provider: @@ -440,7 +440,7 @@ Keep the `Framework` and `Version` values identical across the files so the disp For a brand-new framework that spans several providers, **prefer the universal schema** — it covers every provider from a single file. If you must use the legacy schema, add one transformer per provider in `prowler/lib/outputs/compliance//` and extend the summary-table dispatcher accordingly. See [Output Formatter](#output-formatter). -### Output formatter +### Output Formatter Legacy frameworks render in two forms: a detailed CSV report written to disk, and a summary table printed in the CLI. Both are produced by the output formatter package for the framework. Universal frameworks do **not** need a Python output formatter — the `outputs` config inside the JSON drives rendering — so this section applies only to the legacy schema. @@ -454,19 +454,19 @@ prowler/lib/outputs/compliance/my_framework/ └── models.py # CSV row Pydantic model ``` -#### Step 1 — Define the CSV row model +#### Step 1 — Define the CSV Row Model In `models.py`, declare a Pydantic v1 model with one field per CSV column. Use existing models such as `AWSCISModel` in `prowler/lib/outputs/compliance/cis/models.py` as the reference. Fields typically include `Provider`, `Description`, `AccountId`, `Region`, `AssessmentDate`, `Requirements_Id`, `Requirements_Description`, one `Requirements_Attributes_*` field per attribute key, plus the finding fields `Status`, `StatusExtended`, `ResourceId`, `ResourceName`, `CheckId`, `Muted`, `Framework`, `Name`. -#### Step 2 — Implement the transformer +#### Step 2 — Implement the Transformer In `my_framework_aws.py`, subclass `ComplianceOutput` from `prowler.lib.outputs.compliance.compliance_output` and implement `transform(findings, compliance, compliance_name)`. Iterate over `findings`, match each finding to the requirements it satisfies through `finding.compliance.get(compliance_name, [])`, and append one row per attribute to `self._data`. -#### Step 3 — Add the summary-table dispatcher +#### Step 3 — Add the Summary-Table Dispatcher In `my_framework.py`, implement `get_my_framework_table(findings, bulk_checks_metadata, compliance_framework, output_filename, output_directory, compliance_overview)` following the pattern in `prowler/lib/outputs/compliance/cis/cis.py`. -#### Step 4 — Register the framework in the dispatchers +#### Step 4 — Register the Framework in the Dispatchers - Add the dispatcher call in `prowler/lib/outputs/compliance/compliance.py`, inside `display_compliance_table`, with a branch such as `elif "my_framework" in compliance_framework:`. - Register the CSV model and transformer in `prowler/lib/outputs/compliance/compliance_output.py` so the CSV file is emitted during the scan. @@ -475,7 +475,7 @@ In `my_framework.py`, implement `get_my_framework_table(findings, bulk_checks_me For NIST-style catalogs that use `Generic_Compliance_Requirement_Attribute`, no custom formatter is needed. The generic formatter in `prowler/lib/outputs/compliance/generic/` handles them automatically, provided the JSON validates against the generic attribute schema. -### Legacy-to-universal adapter +### Legacy-to-Universal Adapter At load time, every legacy file is transparently adapted to a `ComplianceFramework` via `adapt_legacy_to_universal()` (`compliance_models.py`), which: (a) flattens the first element of `Attributes` into a flat `attributes` dict, (b) wraps `Checks` as `{provider_lower: [...]}`, (c) infers `attributes_metadata` from the matched Pydantic class via `_infer_attribute_metadata()`. The rest of Prowler (CSV/OCSF/PDF output, CLI table) then treats both formats identically. @@ -498,7 +498,7 @@ Configuration guardrails close that gap. A requirement declares the configuratio Guardrails are an **optional** safety net for configurable checks. A requirement that maps only to non-configurable checks does not need them. When the field is absent, behavior is unchanged. -### Where guardrails are declared +### Where Guardrails Are Declared The field is attached to each requirement and exists in both schemas: @@ -507,7 +507,7 @@ The field is attached to each requirement and exists in both schemas: When a legacy file is adapted to the universal model, `adapt_legacy_to_universal()` copies `ConfigRequirements` into `config_requirements` (`compliance_models.py`), so downstream code only ever reads one shape. -### Constraint schema +### Constraint Schema Each entry in the list is a single constraint with the following fields: @@ -534,7 +534,7 @@ Each entry in the list is a single constraint with the following fields: `subset` / `superset` require both the applied value and `Value` to be lists; any other type is treated as not satisfied. For `eq` against a boolean, declare `Value` as a JSON boolean (`false`, not `0`) — the model keeps booleans distinct from integers. -### How guardrails are evaluated +### How Guardrails Are Evaluated All evaluation lives in one shared module, `prowler/lib/check/compliance_config_eval.py`, consumed by every compliance output (CSV, OCSF, and the CLI tables) and reused by the Prowler API backend so the rule is defined exactly once. @@ -548,7 +548,7 @@ All evaluation lives in one shared module, `prowler/lib/check/compliance_config_ Guardrails only ever make a result **stricter** (they can turn PASS into FAIL); they never relax a real FAIL into PASS. A requirement with no constraints, or whose keys all use defaults, is reported exactly as before. -### Example: legacy framework +### Example: Legacy Framework From `prowler/compliance/aws/cis_6.0_aws.json`, requirement 2.11 declares two guardrails — one per configurable check it maps to: @@ -591,7 +591,7 @@ A boolean guardrail from the same file: requirement 2.5 (IAM Access Analyzer) on ] ``` -### Example: universal framework +### Example: Universal Framework The universal schema uses the lowercase `config_requirements` key with the identical object shape: @@ -617,7 +617,7 @@ The universal schema uses the lowercase `config_requirements` key with the ident Each constraint declares the `Provider` it targets so the guardrail is only evaluated on scans of that provider — essential for universal frameworks like CSA CCM and DORA, where one requirement maps checks across `aws`, `azure`, `gcp` and more. Because the operator is `subset`, adding `"TLS 1.0"` to `recommended_minimal_tls_versions` widens the allowlist beyond `["TLS 1.2", "TLS 1.3"]` and the requirement is forced to FAIL. -### What the user sees +### What the User Sees With a loosened config, the affected requirement's findings report: @@ -631,7 +631,7 @@ StatusExtended: Configuration not valid for this requirement. The check The same `Configuration not valid for this requirement.` message appears identically across the CSV, OCSF, and console-table outputs. -### Authoring guidelines +### Authoring Guidelines - Declare a guardrail only for keys whose value actually changes whether the requirement is met. Most configurable checks do not need one. - Set `Value` to the **strictest** configuration the control tolerates — the same number the control text cites (CIS 45 days, NIST ≤90, and so on). @@ -640,7 +640,7 @@ The same `Configuration not valid for this requirement.` message appears identic - Pick the operator from the value's role: a max threshold is `lte`, a min threshold is `gte`, a toggle is `eq`, an allowlist is `subset`, a denylist is `superset`. - An unrecognized operator does **not** block the requirement — a malformed constraint is treated as satisfied rather than failing the whole framework. Validate your JSON with the tests below. -### Testing guardrails +### Testing Guardrails The shared evaluator and the per-output integration are covered by: @@ -671,7 +671,7 @@ Prowler matches frameworks by concatenating `Framework` and `Version`. A missing Before opening a PR, validate the JSON loads cleanly against the model and that every referenced check actually exists. -### 1. Schema validation +### 1. Schema Validation For **universal** frameworks, load the file and inspect what was parsed. The framework key inside `bulk` is the **basename of the JSON file** (without `.json`); for `prowler/compliance/dora_2022_2554.json` that key is `dora_2022_2554`, for `prowler/compliance/aws/cis_5.0_aws.json` it is `cis_5.0_aws`. @@ -689,7 +689,7 @@ bulk = get_bulk_compliance_frameworks_universal("aws") assert "" in bulk ``` -### 2. Check existence cross-check +### 2. Check Existence Cross-Check There is **no automatic check-existence validation** at load time. Cross-check that every check name in your framework maps to a real check directory: @@ -709,7 +709,7 @@ missing = referenced - real assert not missing, f"checks referenced in framework but not found in repo: {sorted(missing)}" ``` -### 3. CLI smoke test +### 3. CLI Smoke Test ```bash uv run python prowler-cli.py --list-compliance @@ -729,7 +729,7 @@ Verify that: - The CLI summary table lists every section / pillar of the framework. - Findings roll up under the expected requirements. -### 4. Inspect the CSV output +### 4. Inspect the CSV Output Open the generated CSV and confirm: diff --git a/docs/developer-guide/server-sent-events.mdx b/docs/developer-guide/server-sent-events.mdx index 580fd24922..a44bb7b16e 100644 --- a/docs/developer-guide/server-sent-events.mdx +++ b/docs/developer-guide/server-sent-events.mdx @@ -13,7 +13,7 @@ This guide explains how to add a **Server-Sent Events (SSE)** endpoint to the Pr The platform ships the SSE **infrastructure** (`api.sse`) and wiring. No feature endpoint streams over SSE out of the box — this guide shows how to build one on top of the shared base. -## When to use SSE +## When to Use SSE | Need | Use | |------|-----| @@ -23,7 +23,7 @@ The platform ships the SSE **infrastructure** (`api.sse`) and wiring. No feature SSE is the right tool when the **client only consumes**: scan progress, long-running job checkpoints, streamed LLM tokens, cross-client resource-sync notifications. It rides on plain HTTP, reconnects automatically in the browser via the native [`EventSource`](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) API, and needs no extra protocol. -## How it works +## How It Works SSE is wired through [`django-eventstream`](https://github.com/fanout/django_eventstream) and a small platform layer in `api/src/backend/api/sse/`: @@ -35,11 +35,11 @@ SSE is wired through [`django-eventstream`](https://github.com/fanout/django_eve | `make_channel_name` / `tenant_id_from_channel` | `api/sse/utils.py` | Single source of truth for the channel-name format, so publishers and the channel manager agree byte-for-byte. | | Settings | `config/settings/eventstream.py` | Valkey Pub/Sub backend (dedicated DB), channel manager, allowed headers. | -### Transport: the server runs on ASGI +### Transport: The Server Runs on ASGI SSE connections are long-lived. Holding one open per synchronous worker would exhaust the worker pool, so the API runs under Gunicorn's native **`asgi` worker** (`config.asgi:application`). Streams are parked on the event loop while ordinary CRUD endpoints keep their synchronous execution (Django runs sync views in a thread-sensitive executor under ASGI). This is configured in `config/guniconf.py` and used by both the dev and production entrypoints — no separate server process is needed. -### The data flow +### The Data Flow ``` publisher (Celery task / view) subscriber (browser, CLI) @@ -54,7 +54,7 @@ publisher (Celery task / view) subscriber (browser, CLI) A publisher anywhere in the system (most often a Celery task) calls `send_event(channel, event_type, payload)`. `django-eventstream` fans it out over Valkey Pub/Sub to every connection subscribed to that channel. -## Adding an SSE endpoint to your feature +## Adding an SSE Endpoint to Your Feature The example below streams progress for a long-running **scan**. Adapt the resource, prefix, and event names to your feature. @@ -162,7 +162,7 @@ publish_end(channel, scan_id=str(scan.id)) -## Event naming convention +## Event Naming Convention Every event uses an event type of the form **`.`** (lowercased, dot-separated). The verb comes from this platform-wide vocabulary — if you need a verb that is not listed, document the addition in this guide so the catalog stays discoverable. @@ -198,7 +198,7 @@ curl -N -H "Authorization: Bearer $JWT" \ https:///api/v1/scans/$SCAN_ID/event-stream ``` -## Tenant isolation & security model +## Tenant Isolation & Security Model Authorization is enforced at two layers: @@ -207,7 +207,7 @@ Authorization is enforced at two layers: Because the tenant id lives inside the channel name, this gate works for any feature without the platform knowing anything about it. -## Reconnect & state recovery +## Reconnect & State Recovery The platform deliberately ships **without server-side replay** (`is_channel_reliable` returns `False`). When a client reconnects, it does **not** receive missed events. Instead: @@ -216,7 +216,7 @@ The platform deliberately ships **without server-side replay** (`is_channel_reli Design your event payloads accordingly: deltas are ephemeral and concatenated in-flight; the durable truth always lives behind a REST resource. -## Local development +## Local Development - The dev and production entrypoints both launch Gunicorn with the `asgi` worker (`config.asgi:application`). In dev, `DJANGO_DEBUG=True` enables hot reload; `preload_app` is automatically disabled under debug so edited code is picked up. - SSE uses a **dedicated Valkey database** (`EVENTSTREAM_VALKEY_DB`, default `2`) kept separate from the Celery broker so a noisy broker cannot crowd out streaming traffic. It reuses the same `VALKEY_*` connection settings as the rest of the platform. diff --git a/docs/developer-guide/services.mdx b/docs/developer-guide/services.mdx index 4bd9cce36a..6d710bafe0 100644 --- a/docs/developer-guide/services.mdx +++ b/docs/developer-guide/services.mdx @@ -538,7 +538,7 @@ This architecture allows Prowler to efficiently scan AWS accounts with resources ## Best Practices -- When available in the provider, use threading or parallelization utilities for all methods that can be parallelized by to maximize performance and reduce scan time. +- When available in the provider, use threading or parallelization utilities for all methods that can be parallelized to maximize performance and reduce scan time. - Define a Pydantic `BaseModel` for every resource you manage, and use these models for all resource data handling. - Log every major step (start, success, error) in resource discovery and attribute collection for traceability and debugging; include as much context as possible. - Catch and log all exceptions, providing detailed context (region, subscription, resource, error type, line number) to aid troubleshooting. diff --git a/docs/developer-guide/unit-testing.mdx b/docs/developer-guide/unit-testing.mdx index 447ac20db0..430ec69235 100644 --- a/docs/developer-guide/unit-testing.mdx +++ b/docs/developer-guide/unit-testing.mdx @@ -155,7 +155,7 @@ Failing to update this table when adding cross-service dependencies may result i For AWS provider, different testing approaches apply based on API coverage based on several criteria. -Prowler leverages and contributes to the[Moto](https://github.com/getmoto/moto) library for mocking AWS infrastructure in tests. +Prowler leverages and contributes to the [Moto](https://github.com/getmoto/moto) library for mocking AWS infrastructure in tests. - AWS API Calls Covered by [Moto](https://github.com/getmoto/moto): @@ -409,7 +409,7 @@ In all above scenarios, check execution must occur within the context of mocked When a service requires API calls that are partially covered by the Moto decorator, additional mocking is necessary. In such cases, custom mocked API calls must be implemented alongside Moto to ensure full coverage. -To achieve this, mock the `botocore.client.BaseClient._make_api_call` function—the method responsible for making actual API requests to AWS—using `mock.patch `: +To achieve this, mock the `botocore.client.BaseClient._make_api_call` function—the method responsible for making actual API requests to AWS—using [`mock.patch`](https://docs.python.org/3/library/unittest.mock.html#patch): ```python @@ -476,7 +476,7 @@ However, if additional `moto` decorators are applied alongside the patch, Moto w -The source of the above implementation can be found here:[Patch Other Services with Moto](https://docs.getmoto.org/en/latest/docs/services/patching\_other\_services.html) +The source of the above implementation can be found here: [Patch Other Services with Moto](https://docs.getmoto.org/en/latest/docs/services/patching_other_services.html) #### Mocking Several Services @@ -604,7 +604,7 @@ with mock.patch( will cause that the service is initialized only once—at the moment of mocking out `set_mocked_aws_provider([])` using `mock.patch`. -Later, when Python attempts to import the client at the check level, the execution continues using`from prowler.providers..services.._client`. As a result of it being already mocked out, the execution will continue using `service_client` without getting into `_client.py`. +Later, when Python attempts to import the client at the check level, the execution continues using `from prowler.providers..services.._client`. As a result of it being already mocked out, the execution will continue using `service_client` without getting into `_client.py`. ### Testing AWS Services diff --git a/docs/getting-started/basic-usage/prowler-cli.mdx b/docs/getting-started/basic-usage/prowler-cli.mdx index 51d2b88c48..ea6de589a2 100644 --- a/docs/getting-started/basic-usage/prowler-cli.mdx +++ b/docs/getting-started/basic-usage/prowler-cli.mdx @@ -3,9 +3,11 @@ title: 'Run scans with the Prowler CLI' description: 'Learn the essential Prowler CLI commands to run multi-cloud security scans, list checks and services, and export CSV, JSON-OCSF, and HTML reports.' --- +import { VersionBadge } from "/snippets/version-badge.mdx" + ## Running Prowler -Running Prowler requires specifying the provider (e.g `aws`, `gcp`, `azure`, `kubernetes`, `m365`, `github`, `iac` or `mongodbatlas`): +Running Prowler requires specifying the provider (e.g. `aws`, `gcp`, `azure`, `kubernetes`, `m365`, `github`, `iac` or `mongodbatlas`): If no provider is specified, AWS is used by default for backward compatibility with Prowler v2. @@ -92,6 +94,18 @@ By default, `prowler` will scan all AWS regions. See more details about AWS Authentication in the [Authentication Section](/user-guide/providers/aws/authentication) section. +- **AWS Retrier and Timeout Configuration** + + + + Tune the Boto3 standard retrier and the endpoint timeouts when AWS throttles the scan or when some endpoints are unreachable from the network Prowler runs in: + + ```console + prowler aws --aws-retries-max-attempts 5 --aws-connect-timeout 5 --aws-read-timeout 30 + ``` + + See the [Boto3 configuration](/user-guide/providers/aws/boto3-configuration) page for defaults and environment variables. + ## Azure Azure requires specifying the auth method: diff --git a/docs/getting-started/installation/prowler-app.mdx b/docs/getting-started/installation/prowler-app.mdx index c7c51b9f5c..dd6d60614b 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.40.0" -PROWLER_API_VERSION="5.40.0" +PROWLER_UI_VERSION="5.42.0" +PROWLER_API_VERSION="5.42.0" ``` diff --git a/docs/getting-started/products/prowler-mcp.mdx b/docs/getting-started/products/prowler-mcp.mdx index 54ea8e1530..5c8c300618 100644 --- a/docs/getting-started/products/prowler-mcp.mdx +++ b/docs/getting-started/products/prowler-mcp.mdx @@ -162,7 +162,7 @@ The Prowler MCP Server enables powerful workflows through AI assistants: - "What authentication methods does Prowler support for Azure?" - "How can I contribute with a new security check to Prowler?" -### Example: Creating a custom dashboard with Prowler extracted data +### Example: Creating a Custom Dashboard with Prowler Extracted Data In the next example you can see how to create a dashboard using Prowler MCP Server and Claude Desktop. diff --git a/docs/images/providers/cloudflare-token-permissions.png b/docs/images/providers/cloudflare-token-permissions.png index 49926f0415..59634d0df1 100644 Binary files a/docs/images/providers/cloudflare-token-permissions.png and b/docs/images/providers/cloudflare-token-permissions.png differ diff --git a/docs/images/prowler-app/alerts/create-alert-modal.png b/docs/images/prowler-app/alerts/create-alert-modal.png index 57f7bd32c8..910c4b30d6 100644 Binary files a/docs/images/prowler-app/alerts/create-alert-modal.png and b/docs/images/prowler-app/alerts/create-alert-modal.png differ diff --git a/docs/images/prowler-app/alerts/edit-alert-test.png b/docs/images/prowler-app/alerts/edit-alert-test.png index ecbf038dd1..e36b86113e 100644 Binary files a/docs/images/prowler-app/alerts/edit-alert-test.png and b/docs/images/prowler-app/alerts/edit-alert-test.png differ diff --git a/docs/images/prowler-app/import-findings/import-findings-button.png b/docs/images/prowler-app/import-findings/import-findings-button.png new file mode 100644 index 0000000000..adc7b2571f Binary files /dev/null and b/docs/images/prowler-app/import-findings/import-findings-button.png differ diff --git a/docs/images/prowler-app/import-findings/import-findings-dialog.png b/docs/images/prowler-app/import-findings/import-findings-dialog.png new file mode 100644 index 0000000000..66fdd44b30 Binary files /dev/null and b/docs/images/prowler-app/import-findings/import-findings-dialog.png differ diff --git a/docs/images/prowler-app/slack/channel-picker.png b/docs/images/prowler-app/slack/channel-picker.png index 5b02a986ae..bb1bf33bc0 100644 Binary files a/docs/images/prowler-app/slack/channel-picker.png and b/docs/images/prowler-app/slack/channel-picker.png differ diff --git a/docs/images/prowler-app/slack/connected-workspace.png b/docs/images/prowler-app/slack/connected-workspace.png index ec99ccb880..ae3437d84e 100644 Binary files a/docs/images/prowler-app/slack/connected-workspace.png and b/docs/images/prowler-app/slack/connected-workspace.png differ diff --git a/docs/images/prowler-app/slack/disconnect-confirmation.png b/docs/images/prowler-app/slack/disconnect-confirmation.png index 830847e7f7..77e732a846 100644 Binary files a/docs/images/prowler-app/slack/disconnect-confirmation.png and b/docs/images/prowler-app/slack/disconnect-confirmation.png differ diff --git a/docs/images/prowler-app/slack/no-workspace-connected.png b/docs/images/prowler-app/slack/no-workspace-connected.png index de0c47f4b7..513eff04ad 100644 Binary files a/docs/images/prowler-app/slack/no-workspace-connected.png and b/docs/images/prowler-app/slack/no-workspace-connected.png differ diff --git a/docs/troubleshooting.mdx b/docs/troubleshooting.mdx index b10489ef89..ee20785e48 100644 --- a/docs/troubleshooting.mdx +++ b/docs/troubleshooting.mdx @@ -23,7 +23,7 @@ See section [Logging](/user-guide/cli/tutorials/logging) for further information Common issues with the Docker Compose installation of Prowler Local Server. -### Problem adding AWS Provider using "Connect assuming IAM Role" in Docker +### Problem Adding AWS Provider Using "Connect assuming IAM Role" in Docker See [GitHub Issue #7745](https://github.com/prowler-cloud/prowler/issues/7745) for more details. diff --git a/docs/user-guide/cli/tutorials/configuration_file.mdx b/docs/user-guide/cli/tutorials/configuration_file.mdx index 7ad646821c..1df2dc2117 100644 --- a/docs/user-guide/cli/tutorials/configuration_file.mdx +++ b/docs/user-guide/cli/tutorials/configuration_file.mdx @@ -52,6 +52,7 @@ The following list includes all the AWS checks with configurable variables that | `cloudtrail_threat_detection_privilege_escalation` | `threat_detection_privilege_escalation_actions` | List of Strings | See `config.yaml` | | `cloudtrail_threat_detection_privilege_escalation` | `threat_detection_privilege_escalation_minutes` | Integer | `1440` | | `cloudtrail_threat_detection_privilege_escalation` | `threat_detection_privilege_escalation_threshold` | Float | `0.2` | +| `cloudwatch_log_group_agentcore_data_protection_policy_enabled` | `agentcore_log_group_name_prefixes` | List of Strings | See `config.yaml` | | `cloudwatch_log_group_no_secrets_in_logs` | `secrets_ignore_patterns` | List of Strings | `[]` | | `cloudwatch_log_group_retention_policy_specific_days_enabled` | `log_group_retention_days` | Integer | `365` | | `codebuild_project_no_secrets_in_variables` | `excluded_sensitive_environment_variables` | List of Strings | `[]` | @@ -91,6 +92,8 @@ The following list includes all the AWS checks with configurable variables that | `iam_user_access_not_stale_to_sagemaker` | `max_unused_sagemaker_access_days` | Integer | `90` | | `iam_user_accesskey_unused` | `max_unused_access_keys_days` | Integer | `45` | | `iam_user_console_access_unused` | `max_console_access_days` | Integer | `45` | +| `inspector2_active_findings_within_max_age` | `inspector2_active_finding_max_age_days` | Integer | `192` | +| `inspector2_coverage_recently_scanned` | `inspector2_max_days_since_last_scan` | Integer | `3` | | `kinesis_stream_data_retention_period` | `min_kinesis_stream_retention_hours` | Integer | `168` | | `neptune_cluster_backup_enabled` | `minimum_backup_retention_period` | Integer | `7` | | `opensearch_service_domains_not_publicly_accessible` | `trusted_ips` | List of Strings | `[]` | @@ -490,6 +493,14 @@ aws: # MEDIUM ecr_repository_vulnerability_minimum_severity: "MEDIUM" + # AWS Inspector2 + # aws.inspector2_coverage_recently_scanned + # Maximum days since Inspector2 last scanned an actively covered resource + inspector2_max_days_since_last_scan: 3 + # aws.inspector2_active_findings_within_max_age + # Maximum days an Inspector2 finding can stay active since it was first observed + inspector2_active_finding_max_age_days: 192 + # AWS Trusted Advisor # aws.trustedadvisor_premium_support_plan_subscribed verify_premium_support_plans: True diff --git a/docs/user-guide/cli/tutorials/fixer.mdx b/docs/user-guide/cli/tutorials/fixer.mdx index 3be3af7d4b..0e03800bdf 100644 --- a/docs/user-guide/cli/tutorials/fixer.mdx +++ b/docs/user-guide/cli/tutorials/fixer.mdx @@ -106,7 +106,7 @@ def fixer(resource_id: str) -> bool: return True ``` -## Fixer Config file +## Fixer Config File For some fixers, you can have configurable parameters depending on your use case. You can either use the default config file in `prowler/config/fixer_config.yaml` or create a custom config file and pass it to the fixer with the `--fixer-config` flag. The config file should be a YAML file with the following structure: diff --git a/docs/user-guide/cli/tutorials/misc.mdx b/docs/user-guide/cli/tutorials/misc.mdx index 58bb590c55..bb1cedf40f 100644 --- a/docs/user-guide/cli/tutorials/misc.mdx +++ b/docs/user-guide/cli/tutorials/misc.mdx @@ -5,7 +5,7 @@ description: 'Show Prowler version, enable verbose mode, tune execution behavior ## Prowler Version -### Showing the Prowler version: +### Showing the Prowler Version ```console prowler -V/-v/--version @@ -23,7 +23,7 @@ To enable verbose mode in Prowler, similar to Version 2, use: prowler --verbose ``` -### Filter findings by status +### Filter Findings by Status Prowler allows filtering findings based on their status, ensuring reports and CLI display only relevant findings: diff --git a/docs/user-guide/cli/tutorials/mutelist.mdx b/docs/user-guide/cli/tutorials/mutelist.mdx index e03c76dee3..b4af6def9e 100644 --- a/docs/user-guide/cli/tutorials/mutelist.mdx +++ b/docs/user-guide/cli/tutorials/mutelist.mdx @@ -269,7 +269,7 @@ Accounts: ## AWS Mutelist -### Muting specific AWS regions +### Muting Specific AWS Regions If you want to mute failed findings only in specific regions, create a file with the following syntax and run it with `prowler aws -w mutelist.yaml`: diff --git a/docs/user-guide/cli/tutorials/pentesting.mdx b/docs/user-guide/cli/tutorials/pentesting.mdx index 72f6b77b0b..4eec08c754 100644 --- a/docs/user-guide/cli/tutorials/pentesting.mdx +++ b/docs/user-guide/cli/tutorials/pentesting.mdx @@ -44,8 +44,7 @@ prowler --categories secrets Several checks analyse resources that are exposed to the Internet, these are: -1. apigateway\_restapi\_public - +- apigateway\_restapi\_public - appstream\_fleet\_default\_internet\_access\_disabled - awslambda\_function\_not\_publicly\_accessible - ec2\_ami\_public @@ -59,8 +58,6 @@ Several checks analyse resources that are exposed to the Internet, these are: - ecr\_repositories\_not\_publicly\_accessible - eks\_control\_plane\_endpoint\_access\_restricted - eks\_endpoints\_not\_publicly\_accessible -- eks\_control\_plane\_endpoint\_access\_restricted -- eks\_endpoints\_not\_publicly\_accessible - elbv2\_internet\_facing - kms\_key\_not\_publicly\_accessible - opensearch\_service\_domains\_not\_publicly\_accessible diff --git a/docs/user-guide/providers/alibabacloud/getting-started-alibabacloud.mdx b/docs/user-guide/providers/alibabacloud/getting-started-alibabacloud.mdx index c2c55921f9..a71da37024 100644 --- a/docs/user-guide/providers/alibabacloud/getting-started-alibabacloud.mdx +++ b/docs/user-guide/providers/alibabacloud/getting-started-alibabacloud.mdx @@ -145,25 +145,25 @@ prowler alibabacloud --ecs-ram-role RoleName ### Step 2: Run the First Scan -#### Scan all regions +#### Scan All Regions ```bash prowler alibabacloud ``` -#### Scan specific regions +#### Scan Specific Regions ```bash prowler alibabacloud --region cn-hangzhou cn-shanghai ``` -#### Run specific checks +#### Run Specific Checks ```bash prowler alibabacloud --checks ram_no_root_access_key ram_user_mfa_enabled_console_access ``` -#### Run a compliance framework +#### Run a Compliance Framework ```bash prowler alibabacloud --compliance cis_2.0_alibabacloud diff --git a/docs/user-guide/providers/aws/boto3-configuration.mdx b/docs/user-guide/providers/aws/boto3-configuration.mdx index 9afb936acc..e32ee3b58c 100644 --- a/docs/user-guide/providers/aws/boto3-configuration.mdx +++ b/docs/user-guide/providers/aws/boto3-configuration.mdx @@ -3,13 +3,38 @@ title: 'Boto3 Retry Configuration for AWS Scans' description: 'Tune Prowler AWS scans with Boto3 standard retry mode: adjust max attempts, handle throttling errors, and validate retries with debug logs.' --- +import { VersionBadge } from "/snippets/version-badge.mdx" + Prowler's AWS Provider leverages Boto3's [Standard](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/retries.html) retry mode to automatically retry client calls to AWS services when encountering errors or exceptions. +## Timeout Configuration + + + +Every AWS API call is bounded by two timeouts: + +- Connect timeout: seconds to wait to establish a connection (TCP, proxy tunnel and TLS handshake) to the AWS endpoint. Prowler's default is 10 seconds, configurable via `--aws-connect-timeout 5`. +- Read timeout: seconds to wait for a response once connected. Prowler's default is 60 seconds, configurable via `--aws-read-timeout 30`. + +Both timeouts can also be set through environment variables, which is the way to tune them in Prowler Cloud and other deployments without a CLI: + +```console +export PROWLER_AWS_BOTO3_CONNECT_TIMEOUT=5 +export PROWLER_AWS_BOTO3_READ_TIMEOUT=30 +``` + +CLI flags take precedence over the environment variables. Prowler sets both timeouts explicitly, so `AWS_DEFAULTS_MODE` and a `connect_timeout` in `~/.aws/config` are ignored; use the flag or the environment variable instead. + + +Boto3 defaults both timeouts to 60 seconds. In networks with restricted egress (for example VPC endpoints for a subset of services, GovCloud or private deployments), every AWS service without a reachable endpoint used to cost up to 4 attempts × 60 seconds (the first call plus the 3 retries) for each region. Prowler lowers the connect timeout to 10 seconds so unreachable endpoints fail fast; lower it further together with `--aws-retries-max-attempts 0`, which disables retries and leaves a single attempt per call, if a scan still spends most of its time waiting on unreachable services. + + + ## Retry Behavior Overview Boto3's Standard retry mode includes the following mechanisms: -- Maximum Retry Attempts: Default value set to 3, configurable via the `--aws-retries-max-attempts 5` argument. +- Maximum Retry Attempts: Default value set to 3, configurable via the `--aws-retries-max-attempts 5` argument. `0` disables retries. - Expanded Error Handling: Retries occur for a comprehensive set of errors. diff --git a/docs/user-guide/providers/aws/organizations.mdx b/docs/user-guide/providers/aws/organizations.mdx index 2d23e575bf..84b5cc6878 100644 --- a/docs/user-guide/providers/aws/organizations.mdx +++ b/docs/user-guide/providers/aws/organizations.mdx @@ -168,7 +168,7 @@ Include the `ExternalId` parameter in the StackSet if required by the organizati When encountering issues during deployment or needing to target specific OUs or environments (e.g., dev/staging/prod), reach out to the Prowler team via [Slack Community](https://prowler.com/slack) or [Support](mailto:support@prowler.com). -## Extra: Run Prowler across all accounts in AWS Organizations by assuming roles +## Extra: Run Prowler Across All Accounts in AWS Organizations by Assuming Roles ### Running Prowler Across All AWS Organization Accounts diff --git a/docs/user-guide/providers/aws/regions-and-partitions.mdx b/docs/user-guide/providers/aws/regions-and-partitions.mdx index 9cbbcaa0bf..52539c90a6 100644 --- a/docs/user-guide/providers/aws/regions-and-partitions.mdx +++ b/docs/user-guide/providers/aws/regions-and-partitions.mdx @@ -22,10 +22,30 @@ When scanning the China (`aws-cn`), European Sovereign Cloud (`aws-eusc`) or Gov - Specify the regions to audit within that partition using the `-f/--region` flag. +- Declare the partition with the `PROWLER_AWS_PARTITION` environment variable, set to `aws`, `aws-cn`, `aws-eusc` or `aws-us-gov`. + Refer to: https://boto3.amazonaws.com/v1/documentation/api/latest/guide/credentials.html#configuring-credentials for more information about the AWS credential configuration. +### Declaring the Partition + +`PROWLER_AWS_PARTITION` tells Prowler which partition the scan runs against, without relying on a region being configured: + +```bash +export PROWLER_AWS_PARTITION="aws-us-gov" +``` + +It matters most where nothing else says. Resolving an identity means calling STS before anything is known about the credentials, and with no region configured Prowler would otherwise start from the commercial endpoints. Declaring the partition makes that first call go to the right place, which is the difference between a scan that starts and one that fails on an endpoint the credentials cannot use. + +A region configured for the session still wins when it belongs to the declared partition, so a deployment in `us-gov-west-1` is not sent to `us-gov-east-1`. A region belonging to a different partition is ignored, since a partition that has been declared explicitly is the more deliberate statement of the two. + +When no configured region says which one to prefer, the first region of the partition is tried, and up to two more follow if it cannot be reached. A network that routes to only one region of its partition therefore works without having to declare which one that is. Only a connection failure moves on to the next region: a credential error is reported from the first, since it would be the same everywhere. A region excluded from the scan is tried last, so it is avoided whenever another region of the partition answers. + + +Set it wherever the scan runs. For deployments that scan from containers, that means the environment of the containers doing the scanning, not only the one accepting the request. + + ### Scanning Specific Regions To scan a particular AWS region with Prowler, use: diff --git a/docs/user-guide/providers/cloudflare/authentication.mdx b/docs/user-guide/providers/cloudflare/authentication.mdx index 66796224ae..ca6c7df7bb 100644 --- a/docs/user-guide/providers/cloudflare/authentication.mdx +++ b/docs/user-guide/providers/cloudflare/authentication.mdx @@ -23,9 +23,12 @@ Prowler requires read-only access to Cloudflare zones and their settings. The fo | Resource | Permission | Access | Description | |----------|------------|--------|-------------| | `Account` | `Account Settings` | `Read` | Required to list accounts and verify user identity | -| `Zone` | `Zone` | `Read` | Required to list zones, rulesets, bot management, and SSL settings | -| `Zone` | `Zone Settings` | `Read` | Required to read zone security settings (TLS, HSTS, WAF, etc.) | -| `Zone` | `DNS` | `Read` | Required to read DNS records and DNSSEC status | +| `Zone` | `Zone` | `Read` | Required to list zones | +| `Zone` | `Zone Settings` | `Read` | Required to read zone security settings (SSL/TLS mode, TLS versions, HSTS, Always Use HTTPS, WAF, etc.) | +| `Zone` | `DNS` | `Read` | Required to read DNS records (SPF, DMARC, DKIM, CAA) and DNSSEC status | +| `Zone` | `SSL and Certificates` | `Read` | Required to read Universal SSL settings | +| `Zone` | `Bot Management` | `Read` | Required to read Bot Fight Mode | +| `Zone` | `Zone WAF` | `Read` | Required to read WAF custom, rate limiting, and managed rulesets | Ensure the API Token has access to all zones targeted for scanning. Missing permissions may cause some checks to fail or return incomplete results. @@ -47,8 +50,8 @@ Create a **User API Token**, not an Account API Token. User API Tokens are creat **Quick Setup:** Use these pre-configured links to open the Cloudflare Dashboard with the required permissions already selected: -- [Create User API Token](https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22read%22%7D%5D&accountId=%2A&zoneId=all&name=Prowler%20Security%20Scanner) — creates a **User API Token** (recommended). Opens the **Create Custom Token** form prefilled with the four required read-only scopes (`Account Settings`, `Zone`, `Zone Settings`, `DNS`) and the name `Prowler Security Scanner`. Adjust **Account Resources** and **Zone Resources** to match the accounts and zones you want to scan, then click **Create Token**. -- [Create Account-Owned API Token](https://dash.cloudflare.com/?to=/:account/api-tokens&permissionGroupKeys=%5B%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22read%22%7D%5D&name=Prowler%20Security%20Scanner) — creates an [account-owned token](https://developers.cloudflare.com/fundamentals/api/how-to/account-owned-token-template/) instead. Use this for automation or CI/CD where the token should not depend on a specific user account remaining active. Requires the **Super Administrator** or **Administrator** role on the account. +- [Create User API Token](https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22ssl_and_certificates%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22bot_management%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_waf%22%2C%22type%22%3A%22read%22%7D%5D&accountId=%2A&zoneId=all&name=Prowler%20Security%20Scanner) — creates a **User API Token** (recommended). Opens the **Create Custom Token** form prefilled with the seven required read-only scopes (`Account Settings`, `Zone`, `Zone Settings`, `DNS`, `SSL and Certificates`, `Bot Management`, `Zone WAF`) and the name `Prowler Security Scanner`. Adjust **Account Resources** and **Zone Resources** to match the accounts and zones you want to scan, then click **Create Token**. +- [Create Account-Owned API Token](https://dash.cloudflare.com/?to=/:account/api-tokens&permissionGroupKeys=%5B%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22ssl_and_certificates%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22bot_management%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_waf%22%2C%22type%22%3A%22read%22%7D%5D&name=Prowler%20Security%20Scanner) — creates an [account-owned token](https://developers.cloudflare.com/fundamentals/api/how-to/account-owned-token-template/) instead. Use this for automation or CI/CD where the token should not depend on a specific user account remaining active. Requires the **Super Administrator** or **Administrator** role on the account. Template URLs only pre-fill the token creation form. Review the permissions, configure resources, and click **Create Token** to complete the process. @@ -67,6 +70,9 @@ Template URLs only pre-fill the token creation form. Review the permissions, con - `Zone` — `Zone` — `Read` - `Zone` — `Zone Settings` — `Read` - `Zone` — `DNS` — `Read` + - `Zone` — `SSL and Certificates` — `Read` + - `Zone` — `Bot Management` — `Read` + - `Zone` — `Zone WAF` — `Read` - **Zone Resources:** Select either: - **Include → All zones** (to scan all zones in the account) - **Include → Specific zone** (to limit access to specific zones) diff --git a/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx b/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx index 891480ef8d..a8011b4f47 100644 --- a/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx +++ b/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx @@ -12,16 +12,16 @@ Prowler for Cloudflare scans zones for security misconfigurations, including SSL Set up authentication for Cloudflare with the [Cloudflare Authentication](/user-guide/providers/cloudflare/authentication) guide before starting either path: - Create a Cloudflare User API Token (recommended) or locate the Global API Key -- Grant the required read-only permissions (`Account Settings:Read`, `Zone:Read`, `Zone Settings:Read`, `DNS:Read`) +- Grant the required read-only permissions (`Account Settings:Read`, `Zone:Read`, `Zone Settings:Read`, `DNS:Read`, `SSL and Certificates:Read`, `Bot Management:Read`, `Zone WAF:Read`) - Identify the Cloudflare Account ID to use as the provider identifier **Quick Setup:** Use these pre-configured links to create a token with the required permissions already selected: -- [Create User API Token](https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22read%22%7D%5D&accountId=%2A&zoneId=all&name=Prowler%20Security%20Scanner) — creates a User API Token (recommended). -- [Create Account-Owned API Token](https://dash.cloudflare.com/?to=/:account/api-tokens&permissionGroupKeys=%5B%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22read%22%7D%5D&name=Prowler%20Security%20Scanner) — creates an [account-owned token](https://developers.cloudflare.com/fundamentals/api/how-to/account-owned-token-template/), better suited for automation and CI/CD. +- [Create User API Token](https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22ssl_and_certificates%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22bot_management%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_waf%22%2C%22type%22%3A%22read%22%7D%5D&accountId=%2A&zoneId=all&name=Prowler%20Security%20Scanner) — creates a User API Token (recommended). +- [Create Account-Owned API Token](https://dash.cloudflare.com/?to=/:account/api-tokens&permissionGroupKeys=%5B%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22ssl_and_certificates%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22bot_management%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_waf%22%2C%22type%22%3A%22read%22%7D%5D&name=Prowler%20Security%20Scanner) — creates an [account-owned token](https://developers.cloudflare.com/fundamentals/api/how-to/account-owned-token-template/), better suited for automation and CI/CD. -Both links open the Cloudflare Dashboard with the four required read-only scopes (`Account Settings`, `Zone`, `Zone Settings`, `DNS`) and the name `Prowler Security Scanner` prefilled. See [Cloudflare Authentication](/user-guide/providers/cloudflare/authentication#api-token-recommended) for the equivalent manual steps. +Both links open the Cloudflare Dashboard with the seven required read-only scopes (`Account Settings`, `Zone`, `Zone Settings`, `DNS`, `SSL and Certificates`, `Bot Management`, `Zone WAF`) and the name `Prowler Security Scanner` prefilled. See [Cloudflare Authentication](/user-guide/providers/cloudflare/authentication#api-token-recommended) for the equivalent manual steps. diff --git a/docs/user-guide/providers/image/getting-started-image.mdx b/docs/user-guide/providers/image/getting-started-image.mdx index b94a394c7b..da650ac065 100644 --- a/docs/user-guide/providers/image/getting-started-image.mdx +++ b/docs/user-guide/providers/image/getting-started-image.mdx @@ -97,6 +97,29 @@ Install Trivy using one of the following methods: For additional installation methods, see the [Trivy installation guide](https://trivy.dev/latest/getting-started/installation/). +### Vulnerability Database Cache + + + +Trivy keeps its vulnerability database in a cache directory. By default Prowler gives it a temporary one and removes it when the scan ends, so the database is downloaded again for every scan. + +Set `TRIVY_CACHE_DIR` to a directory that persists and the database is downloaded once and reused: + +```bash +export TRIVY_CACHE_DIR="$HOME/.cache/trivy" +prowler image --image +``` + +Prowler never deletes a directory you supply. Trivy still creates and updates its cache and database files inside it. + + +A host with no internet access needs a pre-populated vulnerability database in a persistent directory, with `TRIVY_CACHE_DIR` pointing at it. Populate the directory on a machine that does have access and copy it across. + +Trivy tries to refresh the database when it considers it stale, and that download fails without network access. Set `TRIVY_SKIP_DB_UPDATE=true` (and `TRIVY_SKIP_JAVA_DB_UPDATE=true` if Java scanning is enabled) so it uses the supplied database as is. + +The database ages. A scan run against an old one reports only the vulnerabilities known when it was built, and nothing in the output says so, so keep track of when it was last refreshed. + + ### Supported Scanners @@ -307,9 +330,21 @@ prowler image --registry internal-registry.local --registry-insecure ``` -Skipping TLS verification disables certificate validation for registry connections. Use this flag only for trusted internal registries with self-signed certificates. +Skipping TLS verification disables certificate validation for registry connections, including the Trivy image pull (`TRIVY_INSECURE`). Use this flag only for trusted internal registries with self-signed certificates. +#### On-Premises Registries and Private Networks + + + +By default, Prowler rejects registry-provided URLs (token endpoints, pagination links) that resolve to non-public addresses, as an SSRF defense. On-premises registries live on private networks by definition, so to scan them declare the trusted ranges explicitly: + +```bash +export PROWLER_IMAGE_PROVIDER_ALLOWED_PRIVATE_NETWORKS="192.168.65.254/32,10.20.0.0/16" +``` + +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. The variable applies to registry enumeration and to the connection test. Malformed entries fail at startup, and a non-empty allowlist is logged as a relaxed security control. When unset, behavior is unchanged: only public addresses are followed. + #### Supported Registries Registry Scan Mode supports the following registry types: diff --git a/docs/user-guide/providers/mongodbatlas/getting-started-mongodbatlas.mdx b/docs/user-guide/providers/mongodbatlas/getting-started-mongodbatlas.mdx index e6c89023ff..d08944fbb7 100644 --- a/docs/user-guide/providers/mongodbatlas/getting-started-mongodbatlas.mdx +++ b/docs/user-guide/providers/mongodbatlas/getting-started-mongodbatlas.mdx @@ -37,7 +37,7 @@ If **Require IP Access List for the Atlas Administration API** is enabled in the -### Step 1: Add the provider +### Step 1: Add the Provider 1. Navigate to **Providers** and click **Add Provider**. ![Add provider list](./img/add-provider-list.png) @@ -46,13 +46,13 @@ If **Require IP Access List for the Atlas Administration API** is enabled in the ![Add organization ID](./img/add-org-id.png) 4. (Optional) Add a friendly alias to identify this organization in dashboards. -### Step 2: Provide API credentials +### Step 2: Provide API Credentials 1. Click **Next** to open the credentials form. 2. Paste the **Atlas Public Key** and **Atlas Private Key** generated in the Atlas console. ![Add credentials](./img/add-credentials.png) -### Step 3: Test the connection and start scanning +### Step 3: Test the Connection and Start Scanning 1. Click **Test connection** to ensure Prowler Cloud can reach the Atlas API. 2. Save the credentials. The provider will appear in the list with its current connection status. @@ -67,11 +67,11 @@ If **Require IP Access List for the Atlas Administration API** is enabled in the You can also run MongoDB Atlas assessments directly from the CLI. Both command-line flags and environment variables are supported. -### Step 1: Select an authentication method +### Step 1: Select an Authentication Method Choose one of the following authentication methods: -#### Command-line arguments +#### Command-Line Arguments ```bash prowler mongodbatlas \ @@ -79,7 +79,7 @@ prowler mongodbatlas \ --atlas-private-key ``` -#### Environment variables +#### Environment Variables ```bash export ATLAS_PUBLIC_KEY= @@ -87,9 +87,9 @@ export ATLAS_PRIVATE_KEY= prowler mongodbatlas ``` -### Step 2: Run the first scan +### Step 2: Run the First Scan -#### Scan all projects and clusters +#### Scan All Projects and Clusters ```bash prowler mongodbatlas @@ -97,7 +97,7 @@ prowler mongodbatlas This command enumerates all projects accessible to the API key and scans every cluster. -#### Scan a specific project +#### Scan a Specific Project Add the `--atlas-project-id` flag when you only want to assess one project: @@ -105,7 +105,7 @@ Add the `--atlas-project-id` flag when you only want to assess one project: prowler mongodbatlas --atlas-project-id ``` -### Additional tips +### Additional Tips - Combine flags (for example, `--checks` or `--services`) just like with other providers. - Use `--output-modes` to export findings in JSON, CSV, ASFF, etc. diff --git a/docs/user-guide/providers/okta/authentication.mdx b/docs/user-guide/providers/okta/authentication.mdx index baba932e13..c4aabe3894 100644 --- a/docs/user-guide/providers/okta/authentication.mdx +++ b/docs/user-guide/providers/okta/authentication.mdx @@ -68,7 +68,7 @@ The service application must be assigned **one** of the following Okta admin rol Okta's Management API enforces a two-layer authorization model: an OAuth **scope** decides which API endpoints the token can call, and an **admin role** decides whether the call returns data. With only a scope granted, the token mint succeeds but every read returns `403 Forbidden`. Read-Only Administrator is the minimum role that lets the granted `okta.*.read` scopes return configuration data to Prowler's checks; without it, the credential probe at provider startup fails and the scan never gets to evaluate any check. -#### When Super Administrator is required +#### When Super Administrator Is Required Four checks need to resolve the Authentication Policy bound to Okta's first-party apps (Okta Admin Console, Okta Dashboard) and depend on `/api/v1/apps` returning those system apps — which Okta restricts to Super Administrator: @@ -93,17 +93,17 @@ Read-Only Administrator stays the recommended default for the least-privilege fr ## Step-by-Step Setup -### 1. Go to the admin console +### 1. Go to the Admin Console ![Okta — admin console page](/user-guide/providers/okta/images/select-admin-console.png) -### 2. [Optional] - Disable the privilege-escalation bypass (org-wide, one-time) +### 2. [Optional] - Disable the Privilege-Escalation Bypass (Org-Wide, One-Time) In the Okta Admin Console, go to **Settings → Account → Public client app admins** and ensure it is **off**. When enabled, every API Services app can be auto-assigned the Super Administrator role after scopes are granted, which would invalidate the read-only premise of this integration. ![Okta — disable Public client app admins](/user-guide/providers/okta/images/public-client-app-admins.png) -### 3. Create the API Services app +### 3. Create the API Services App 1. Go to **Applications → Applications**. @@ -119,7 +119,7 @@ In the Okta Admin Console, go to **Settings → Account → Public client app ad ![Okta — copy client id](/user-guide/providers/okta/images/copy-client-id.png) -### 4. Switch to private-key authentication and generate a keypair +### 4. Switch to Private-Key Authentication and Generate a Keypair On the new app's **General** tab, scroll to **Client Credentials**: @@ -137,13 +137,13 @@ Okta displays the private key **only once**. If you close the modal without copy ![Okta — create Public Key](/user-guide/providers/okta/images/create-public-key.png) -### 5. Grant the required OAuth scopes +### 5. Grant the Required OAuth Scopes On the app, open the **Okta API Scopes** tab and click **Grant** on every scope Prowler needs. The bundled checks require `okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.authenticators.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read`. ![Okta — grant OAuth scopes](/user-guide/providers/okta/images/grant-permissions.png) -### 6. Assign an admin role +### 6. Assign an Admin Role On the app, open the **Admin roles** tab and click **Edit assignments → Add assignment**: @@ -156,7 +156,7 @@ To additionally evaluate the first-party application checks (Okta Admin Console ![Okta — grant Read-Only role](/user-guide/providers/okta/images/grant-roles.png) -### 7. [Optional] Verify DPoP setting +### 7. [Optional] Verify DPoP Setting Prowler sends DPoP (Demonstrating Proof of Possession) proofs on every token request. The integration works whether the **Require Demonstrating Proof of Possession (DPoP) header in token requests** setting on the service app is on or off — but enabling it is the more secure default. @@ -207,20 +207,20 @@ The org domain must be `.okta.com` (or `.oktapreview.com` / `.okta-emea.com The file at `OKTA_PRIVATE_KEY_FILE` is missing, unreadable, or empty. Confirm the path and that the file contains a non-empty PEM block or JWK JSON document. -### `OktaInvalidCredentialsError` at provider init +### `OktaInvalidCredentialsError` at Provider Init Prowler validates credentials at startup by listing one sign-on policy. This error indicates the credential material itself was rejected: - **`invalid_client`** — the public key registered in Okta does not match the private key on disk. Generate a fresh keypair and try again. -### `OktaInsufficientPermissionsError` at provider init +### `OktaInsufficientPermissionsError` at Provider Init Raised when the credential probe succeeds at the OAuth layer but the request is rejected because the service app lacks the required scope or admin role: - **`invalid_scope`** — one of the requested scopes (`okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.authenticators.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read`) is not granted on the service app. Grant the missing scope from **Okta API Scopes**. - **`Forbidden` / `not authorized`** — no admin role is assigned to the service app. Assign **Read-Only Administrator** (or **Super Administrator** for the first-party application checks) from **Admin roles**. -### Application-service checks return MANUAL on first-party apps +### Application-Service Checks Return MANUAL on First-Party Apps When the service app runs with Read-Only Administrator, the five application-service checks targeting the Okta Admin Console and Okta Dashboard return MANUAL. This is by design — Okta restricts the underlying endpoints (`/api/v1/first-party-app-settings/{appName}` and `/api/v1/apps` for first-party app `name` values `saasure` / `okta_enduser`) to **Super Administrator**. Assign the Super Administrator role to the service app to evaluate those checks. See [Required Admin Role](#required-admin-role) for the full list. diff --git a/docs/user-guide/tutorials/prowler-app-findings-triage.mdx b/docs/user-guide/tutorials/prowler-app-findings-triage.mdx index 2f4defe40d..a4cd03ceea 100644 --- a/docs/user-guide/tutorials/prowler-app-findings-triage.mdx +++ b/docs/user-guide/tutorials/prowler-app-findings-triage.mdx @@ -140,18 +140,18 @@ Muting a finding does not fix the underlying configuration. Review the finding b ## Troubleshooting -### Triage controls do not appear +### Triage Controls Do Not Appear Make sure the row is an individual finding row. Finding Groups rows do not show triage controls. Expand a group to see affected resources and their triage controls. -### Changes cannot be saved +### Changes Cannot Be Saved Confirm that the user role has **Manage Scans** permission. Prowler Local Server does not support Findings Triage writes. -### Resolved or Reopened is missing from the selector +### Resolved or Reopened Is Missing from the Selector **Reopened** is always automatic. **Resolved** is set automatically from scan result changes and appears as a selector option only on `MANUAL` findings, where it records a [Manual Pass](#verify-a-manual-finding-as-pass). On findings with any other status, this is expected. -### Risk Accepted or False Positive muted a finding +### Risk Accepted or False Positive Muted a Finding This is expected. Those statuses create a mute rule through Mutelist. diff --git a/docs/user-guide/tutorials/prowler-app-github-action.mdx b/docs/user-guide/tutorials/prowler-app-github-action.mdx index b6e8788962..aac5151d21 100644 --- a/docs/user-guide/tutorials/prowler-app-github-action.mdx +++ b/docs/user-guide/tutorials/prowler-app-github-action.mdx @@ -28,7 +28,7 @@ Source: [`prowler-cloud/prowler`](https://github.com/prowler-cloud/prowler) · M ## Usage -### AWS scan +### AWS Scan ```yaml - uses: prowler-cloud/prowler@5.25 @@ -41,7 +41,7 @@ Source: [`prowler-cloud/prowler`](https://github.com/prowler-cloud/prowler) · M AWS_SESSION_TOKEN: ${{ secrets.AWS_SESSION_TOKEN }} ``` -### Push findings to Prowler Cloud +### Push Findings to Prowler Cloud Send scan results directly to [Prowler Cloud](/user-guide/tutorials/prowler-import-findings) for centralized visibility, compliance tracking, and team collaboration. @@ -97,7 +97,7 @@ jobs: - GitHub Code Scanning is free for public repositories. Private repositories require a [GitHub Code Security](https://docs.github.com/en/get-started/learning-about-github/about-github-advanced-security) license. -### Combine push-to-cloud with SARIF upload +### Combine Push-to-Cloud with SARIF Upload ```yaml - uses: prowler-cloud/prowler@5.25 @@ -114,7 +114,7 @@ jobs: PROWLER_CLOUD_API_KEY: ${{ secrets.PROWLER_CLOUD_API_KEY }} ``` -### Scan the current repository with the GitHub provider +### Scan the Current Repository with the GitHub Provider ```yaml name: Prowler GitHub Scan @@ -142,7 +142,7 @@ jobs: `--repository` scans a single repo. Use `--organization ` instead to include org-level checks (MFA, security policies, etc.). See the [GitHub provider authentication](/user-guide/providers/github/authentication) for required token permissions. -### Fail the PR on findings +### Fail the PR on Findings By default the action tolerates findings (exit code 3) and succeeds. Set `fail-on-findings: true` to fail the workflow step when Prowler detects findings. Combine with `--severity` to control which severity levels trigger the failure: @@ -241,7 +241,7 @@ steps: ### Cloudflare -Create a Cloudflare API Token with `Zone:Read`, `Zone Settings:Read`, and `DNS:Read` permissions ([provider auth docs](/user-guide/providers/cloudflare/authentication)). Then: +Create a Cloudflare API Token with the `Account Settings:Read`, `Zone:Read`, `Zone Settings:Read`, `DNS:Read`, `SSL and Certificates:Read`, `Bot Management:Read`, and `Zone WAF:Read` permissions ([provider auth docs](/user-guide/providers/cloudflare/authentication)). Then: ```yaml - uses: prowler-cloud/prowler@5.25 @@ -258,7 +258,7 @@ Scan results are written to `output/` in the workspace and uploaded as artifacts When `upload-sarif` is enabled, SARIF results are also uploaded to GitHub Code Scanning and appear on the repository's **Security → Code scanning** tab, filtered by the branch that ran the scan. -### Step summary +### Step Summary The action writes a summary to the run page with a per-severity breakdown of failing checks, artifact and Code Scanning links, and (when `push-to-cloud: false`) a pointer to [Prowler Cloud](https://cloud.prowler.com) for continuous monitoring. diff --git a/docs/user-guide/tutorials/prowler-app-jira-integration.mdx b/docs/user-guide/tutorials/prowler-app-jira-integration.mdx index f539aaff4f..c0300b9dd5 100644 --- a/docs/user-guide/tutorials/prowler-app-jira-integration.mdx +++ b/docs/user-guide/tutorials/prowler-app-jira-integration.mdx @@ -124,6 +124,18 @@ To manually send individual Findings to Jira: ![Send to Jira modal](/images/prowler-app/jira/send-to-jira-modal.png) +### Finding Reference in the Jira Issue + + + +Every Jira issue created from a single Finding carries a stable reference back to that Finding, so issues can be filtered, searched with Jira Query Language (JQL), or matched by automation: + +* **Labels**: `prowler`, `prowler-`, `prowler-`, `prowler-` and `prowler-finding-`. Labels are sanitized deterministically: whitespace becomes `_`, control characters are removed, and values are truncated to Jira's 255-character label limit. +* **Finding URL**: a link that opens the Finding in Prowler, filtered by its unique identifier (UID) so it keeps working after later scans. +* **Tenant Info**: the name of the Prowler organization that sent the Finding. + +Prowler Cloud always includes the Finding URL. In Prowler Local Server, set `DJANGO_UI_BASE_URL` in the API environment (for example, `https://prowler.example.com`) to enable it. When the variable is empty, the issue is created without the link. + ## Integration Status Monitor and manage your Jira integrations through the management interface: @@ -159,13 +171,13 @@ Support for custom field mapping is planned for a future release. ## Troubleshooting -### Connection test fails +### Connection Test Fails * Verify Jira instance domain is correct and accessible * Confirm API token or credentials are valid * Ensure API access is enabled in Jira settings and the needed scopes are granted -### Check task status (API) +### Check Task Status (API) If the Jira issue does not appear in your Jira project, follow these steps to verify the export task status via the API. diff --git a/docs/user-guide/tutorials/prowler-app-rbac.mdx b/docs/user-guide/tutorials/prowler-app-rbac.mdx index 5a0737a4eb..e086f659b4 100644 --- a/docs/user-guide/tutorials/prowler-app-rbac.mdx +++ b/docs/user-guide/tutorials/prowler-app-rbac.mdx @@ -257,11 +257,11 @@ The **Scope** column indicates where each permission applies. **All** means the To grant all administrative permissions, select the **Grant all admin permissions** option. -### Prowler Cloud exclusive permissions +### Prowler Cloud Exclusive Permissions The following permissions are available exclusively in **Prowler Cloud**: -**Manage Ingestions:** Submit and manage findings ingestion jobs via the API. Required to upload OCSF scan results using the `--push-to-cloud` CLI flag or the ingestion endpoints. See [Import Findings](/user-guide/tutorials/prowler-import-findings) for details. +**Manage Ingestions:** Submit and manage findings ingestion jobs. Required to upload OCSF scan results from the Scans page, with the `--push-to-cloud` CLI flag or through the ingestion endpoints. See [Import Findings](/user-guide/tutorials/prowler-import-findings) for details. **Manage Billing:** Access and manage billing settings, subscription plans, and payment methods. diff --git a/docs/user-guide/tutorials/prowler-app-slack-integration.mdx b/docs/user-guide/tutorials/prowler-app-slack-integration.mdx index 65be6a94ac..f9ae3f99b1 100644 --- a/docs/user-guide/tutorials/prowler-app-slack-integration.mdx +++ b/docs/user-guide/tutorials/prowler-app-slack-integration.mdx @@ -58,12 +58,14 @@ Two of these read more broadly than they behave, and both are worth understandin On the consent screen, `chat:write.public` reads as permission to post in any public channel. Prowler never uses it that way: **Prowler only ever posts to the channels authorized on the integration.** The scope exists so that authorizing a public channel does not also require someone to invite the Prowler app to it first. +{/* The Prowler UI deep-links to this heading's anchor, so rewording the heading breaks that link. */} + ### Why a Private Channel Is Missing From the Channel List -`groups:read` reveals only the private channels the Prowler app is already a member of. A private channel therefore appears in the channel list only after someone invites `@Prowler` to it in Slack: +`groups:read` reveals only the private channels the Prowler app is already a member of. A private channel therefore appears in the channel list only after someone invites `@Prowler Cloud` to it in Slack: ```text -/invite @Prowler +/invite @Prowler Cloud ``` That invite is issued in Slack, by that channel's own members, and **the invite itself is the permission grant** — no scope bypasses it. Prowler ships no in-product flow to get the app invited, because the decision belongs to the channel's members. After inviting the app, click **Refresh channels** to re-read the list. @@ -100,14 +102,14 @@ Prowler posts to the channels authorized on the integration. Several channels ca ![Destination channels selection listing public channels and an invited private channel marked Private](/images/prowler-app/slack/channel-picker.png) -2. Select one or more channels. A selected private channel keeps its lock and **Private** identification with the list closed, so the authorized set stays readable at a glance. +2. Select one or more channels. A selected private channel keeps the same **Private** marking with the list closed, so the authorized set stays readable at a glance. 3. Click **Save channels**. Prowler validates the selection against Slack and derives each channel name itself, so a recorded name can never drift from the channel it belongs to. Once the set is saved, the page reports where Prowler posts and runs the connection check over it. -If the selection reports that no channels are available, the workspace exposes nothing Prowler can see. Create a public channel, or invite `@Prowler` to a private one, then click **Refresh channels**. +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` 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 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. 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. @@ -159,7 +161,7 @@ The Slack management page reports the state of the connection and offers these a | Button | Purpose | Notes | |--------|---------|-------| | **Test connection** | Verify the credential and every authorized channel, and confirm the ones not confirmed yet | Posts the confirmation message once per channel and updates the last-checked time. Cannot be run until at least one channel is authorized | -| **Refresh channels** | Re-read the workspace's channel list | Use after inviting `@Prowler` to a private channel | +| **Refresh channels** | Re-read the workspace's channel list | Use after inviting `@Prowler Cloud` to a private channel | | **Save channels** | Record the selected channels as the integration's authorized set | Enabled once the selection differs from the authorized set | | **Disconnect** | Remove the integration and attempt to revoke access at Slack | ⚠️ **Cannot be undone** — confirm before disconnecting | @@ -171,7 +173,7 @@ The Prowler Slack app is not configured for the deployment being used, so no wor ### A Private Channel Does Not Appear in the Channel List -The Prowler app has not been invited to it. In Slack, run `/invite @Prowler` in that channel, then click **Refresh channels**. Membership is the permission: no scope reveals a private channel the app is not in. +The Prowler app has not been invited to it. In Slack, run `/invite @Prowler Cloud` in that channel, then click **Refresh channels**. Membership is the permission: no scope reveals a private channel the app is not in. ### Connection Test Fails diff --git a/docs/user-guide/tutorials/prowler-app.mdx b/docs/user-guide/tutorials/prowler-app.mdx index 59f6a18f94..3aec46b2e4 100644 --- a/docs/user-guide/tutorials/prowler-app.mdx +++ b/docs/user-guide/tutorials/prowler-app.mdx @@ -166,6 +166,6 @@ Once your scan has finished, you don’t need to grab the entire ZIP—just pull **API Note** -To fetch a single compliance report via API, see the Retrieve compliance report as CSV endpoint in the Prowler API Reference.[Prowler API Reference - Retrieve compliance report as CSV](https://api.prowler.com/api/v1/docs#tag/Scan/operation/scans_compliance_retrieve) +To fetch a single compliance report via API, see the Retrieve compliance report as CSV endpoint in the Prowler API Reference. [Prowler API Reference - Retrieve compliance report as CSV](https://api.prowler.com/api/v1/docs#tag/Scan/operation/scans_compliance_retrieve) diff --git a/docs/user-guide/tutorials/prowler-import-findings.mdx b/docs/user-guide/tutorials/prowler-import-findings.mdx index 23da957c34..964bcce48e 100644 --- a/docs/user-guide/tutorials/prowler-import-findings.mdx +++ b/docs/user-guide/tutorials/prowler-import-findings.mdx @@ -8,7 +8,7 @@ import { SubscriptionBanner } from "/snippets/subscription-banner.mdx" -Findings Ingestion enables uploading OCSF (Open Cybersecurity Schema Framework) scan results to Prowler Cloud. This feature supports importing findings from Prowler CLI output files that use the [Detection Finding](https://schema.ocsf.io/classes/detection_finding) class. +Findings Ingestion enables uploading OCSF (Open Cybersecurity Schema Framework) scan results to Prowler Cloud. This feature supports importing findings from Prowler CLI output files that use the [Detection Finding](https://schema.ocsf.io/classes/detection_finding) class. Reports can be imported from the Scans page in the Prowler Cloud UI, pushed by the CLI with `--push-to-cloud`, or submitted through the API. @@ -131,10 +131,32 @@ Only **Detection Finding** (`class_uid: 2004`) records are accepted. Other OCSF ## Required Permissions -The **Manage Ingestions** RBAC permission controls access to the ingestion endpoints. Without this permission, findings cannot be submitted via the API or `--push-to-cloud`. +The **Manage Ingestions** RBAC permission controls access to the ingestion endpoints. Without this permission, findings cannot be submitted from the Scans page, via the API or with `--push-to-cloud`. For more information about RBAC permissions, refer to the [Prowler Cloud RBAC documentation](/user-guide/tutorials/prowler-app-rbac). +## Using the UI + + + +The Scans page imports a Prowler OCSF report from the browser, with no CLI or API key involved. The import runs as a regular ingestion job, so the [status values](#ingestion-status-values), the [billing impact](#billing-impact) and the [errors endpoint](#get-ingestion-errors) apply as they do for the CLI and the API. + +1. Go to **Scans** and click **Import Findings**. The button is shown only to roles with the **Manage Ingestions** permission. + + ![Import Findings button on the Scans page](/images/prowler-app/import-findings/import-findings-button.png) + +2. Drag a `.ocsf.json` report onto the drop area, or click **Select File** to pick one. The dialog takes one file per import. A file whose name does not end in `.ocsf.json`, or an empty file, is rejected before the upload starts. + + ![Import findings dialog with the drag-and-drop area](/images/prowler-app/import-findings/import-findings-dialog.png) + +3. Click **Start import**. The dialog uploads the report, creates the ingestion job and follows its status until the job finishes. On completion it reports the total number of records, how many were processed and how many were invalid. + +Closing the dialog while an import is running does not cancel the job. When the job completes in the background, a notification confirms it and the imported findings appear in Scans. + +If the upload is rejected or the job fails, the dialog shows the reason and a **Retry import** button that sends the same file again. A failed job also shows the progress it reported before failing. A different file can be selected instead of retrying. If the status check fails after the upload was accepted, **Retry status** resumes tracking the same job without uploading the file again. + +Invalid records are counted in the summary but not listed in the dialog. To see why each one was rejected, [list the ingestion jobs](#list-ingestion-jobs) through the API and query the [errors endpoint](#get-ingestion-errors) for that job. + ## Using the CLI The `--push-to-cloud` flag uploads scan results directly to Prowler Cloud after a scan completes. This approach automates the ingestion process without manual file uploads. @@ -443,7 +465,7 @@ For pricing details, see [Prowler Cloud Pricing](https://prowler.com/pricing). - The user associated with the API key lacks the **Manage Ingestions** permission - Contact the tenant administrator to grant the required permission -### Ingestion job status is "failed" +### Ingestion Job Status Is "failed" - Check the `/api/v1/ingestions/{id}/errors` endpoint for details - Verify the OCSF file format is valid diff --git a/mcp_server/CHANGELOG.md b/mcp_server/CHANGELOG.md index 62e0446bb0..80bdabd28a 100644 --- a/mcp_server/CHANGELOG.md +++ b/mcp_server/CHANGELOG.md @@ -4,6 +4,31 @@ All notable changes to the **Prowler MCP Server** are documented in this file. +## [0.12.1] (Prowler v5.42.0) + +### 🔐 Security + +- `libuuid` upgraded to 2.41.6-r1 in the container image, patching CVE-2026-53612, CVE-2026-53613, CVE-2026-53614, CVE-2026-76642, CVE-2026-78408 and CVE-2026-78410 [(#12780)](https://github.com/prowler-cloud/prowler/pull/12780) + +--- + +## [0.12.0] (Prowler v5.41.0) + +### 🚀 Added + +- Prowler App tools now report a failure as an MCP tool execution error (`isError: true`, explanation in `content`) instead of as a successful result carrying an `{"error": ...}` object, which clients and models read as a success [(#12532)](https://github.com/prowler-cloud/prowler/pull/12532) + +### 🔄 Changed + +- `prowler_get_compliance_framework_state_details` now rejects a call that passes both `scan_id` and `provider_id` instead of silently ignoring the provider, which could report on a scan belonging to a different provider than the one that was asked about [(#12532)](https://github.com/prowler-cloud/prowler/pull/12532) + +### 🐞 Fixed + +- `prowler_hub_get_check_code` and `prowler_hub_get_check_fixer` now report a check ID that belongs to another provider as such, naming that provider, instead of reporting the ID as one that does not exist [(#12533)](https://github.com/prowler-cloud/prowler/pull/12533) +- `prowler_docs_search` no longer reports a failed search as zero matches or an unreadable answer as a bad search term, and `prowler_docs_get_document` no longer reports a failed fetch as a missing page [(#12534)](https://github.com/prowler-cloud/prowler/pull/12534) + +--- + ## [0.11.0] (Prowler v5.40.0) ### 🚀 Added diff --git a/mcp_server/Dockerfile b/mcp_server/Dockerfile index a2ba13ff6c..7dad9e1303 100644 --- a/mcp_server/Dockerfile +++ b/mcp_server/Dockerfile @@ -32,6 +32,8 @@ LABEL maintainer="https://github.com/prowler-cloud" # High CVEs fixed in Alpine 3.23 but not yet in the pinned base image: # sqlite-libs 3.53.4-r0 CVE-2026-11822, CVE-2026-11824 (image ships 3.51.2-r0) # libcrypto3/libssl3 3.5.8-r0 CVE-2026-14456 (image ships 3.5.7-r0) +# libuuid 2.41.6-r1 CVE-2026-53612, -53613, -53614, -76642, -78408, -78410 +# (image ships 2.41.4-r0; -78408 is the one that needs -r1 rather than -r0) # The base image pins python 3.13.14, which has not been rebuilt since those # packages were published, so the upgrade is taken here rather than by moving # the pin -- the newest published python:3.13-alpine3.23 carries the same @@ -43,7 +45,8 @@ LABEL maintainer="https://github.com/prowler-cloud" RUN apk add --no-cache --upgrade \ "sqlite-libs>=3.53.4-r0" \ "libcrypto3>=3.5.8-r0" \ - "libssl3>=3.5.8-r0" + "libssl3>=3.5.8-r0" \ + "libuuid>=2.41.6-r1" # Create non-root user for security # Using specific UID/GID for consistency across environments diff --git a/mcp_server/prowler_mcp_server/lib/errors.py b/mcp_server/prowler_mcp_server/lib/errors.py index 29e2db6481..1aa3a25607 100644 --- a/mcp_server/prowler_mcp_server/lib/errors.py +++ b/mcp_server/prowler_mcp_server/lib/errors.py @@ -19,10 +19,17 @@ class ProwlerAPIError(Exception): Attributes: status_code: HTTP status the API answered with detail: JSON:API `errors[0].detail`, None when there is none to trust + payload: Parsed JSON body, for a tool that has to read the answer rather + than only report it """ def __init__( - self, message: str, status_code: int, *, detail: str | None = None + self, + message: str, + status_code: int, + *, + detail: str | None = None, + payload: dict[str, Any] | None = None, ) -> None: super().__init__(message) self.status_code: int = status_code @@ -32,6 +39,12 @@ class ProwlerAPIError(Exception): # that must never be repeated to a model -- and None for a 5xx, see # `jsonapi_detail`. self.detail: str | None = detail + # Not every error status means the request failed: Prowler answers 404 + # with the result itself when a query ran and matched nothing. A tool + # reads this to tell such an answer apart from a real failure. It is the + # upstream body, so it is read structurally and never relayed as text -- + # `detail` above is the only part of it that may be repeated to a model. + self.payload: dict[str, Any] | None = payload class ProwlerAPIUnreachable(Exception): @@ -42,6 +55,59 @@ class ProwlerAPIInvalidResponse(Exception): """The API answered, but with a body this server could not read as JSON.""" +class UpstreamInvalidResponse(Exception): + """An upstream this server reads directly answered with a body that is not JSON. + + Raised in place of the `json.JSONDecodeError` httpx would otherwise let out. + That one is a ValueError this module reads as a malformed argument, which is + the opposite story: it sends a model off to fix a call that was fine. + + Attributes: + host: Host that answered, so the message can name what has to be fixed + """ + + def __init__(self, message: str, *, host: str) -> None: + super().__init__(message) + self.host: str = host + + +def parse_json_response(response: httpx.Response) -> Any: + """Parse an upstream answer as JSON, telling an unreadable body from a bad + argument. + + For every upstream a sub-server reads with an httpx client of its own -- + Prowler Hub, the documentation site. `httpx` lets a body it cannot decode + out as a `json.JSONDecodeError`, which is a ValueError this module reads as + a malformed argument. Coming from an upstream -- an HTML error page from an + edge, a truncated body -- that is the wrong story, and the caller has no + argument to fix. + + The Prowler API client parses its own answers and raises + `ProwlerAPIInvalidResponse` instead: it also carries writes, where an + unreadable answer leaves the outcome unknown rather than merely absent. + + Args: + response: The answer to parse. + + Returns: + The parsed body. + + Raises: + UpstreamInvalidResponse: The body is not JSON. + """ + try: + return response.json() + except ValueError as e: + # `.request` raises rather than returning None when it was never set. + request = getattr(response, "_request", None) + host = request.url.host if request is not None else "The upstream service" + # Status only: the decoder's own message quotes the body it choked on, + # and that body is the upstream text this server never relays. + raise UpstreamInvalidResponse( + f"{response.status_code} body is not JSON", host=host + ) from e + + def jsonapi_detail(response: httpx.Response) -> str | None: """Return the API's own JSON:API error detail, when there is one to trust. @@ -71,6 +137,10 @@ class InvalidArgument(ValueError): """An argument this server rejected before any request went out.""" +class CredentialError(Exception): + """The credential the caller sent is missing, malformed or expired.""" + + # ------------------------------------------------------------------- messages @@ -154,6 +224,27 @@ def _describe_failure(exc: BaseException) -> str | None: "current state before sending it again." ) + if isinstance(exc, UpstreamInvalidResponse): + # The counterpart of the `json.JSONDecodeError` branch below: the same + # decode failure is a malformed argument on one side of this server and + # an upstream fault on the other, and only the type tells them apart. + return ( + f"{exc.host} answered with a body this server could not read as JSON, " + "so the call has no result to return. Nothing in the arguments caused " + f"this and changing them will not help -- {exc.host} is answering with " + "something other than the JSON it documents. Retry later." + ) + + if isinstance(exc, CredentialError): + # Not an argument problem, so it is worth saying that plainly: the + # answer is a credential the user has to fix, not another attempt. + return ( + f"This request carried no usable credential: {exc}. Retrying or " + "changing the arguments will not help -- the client has to send an " + "'Authorization: Bearer ' header holding a valid Prowler API " + "key or an unexpired JWT." + ) + if isinstance(exc, ProwlerAPIUnreachable): # The only failure a model can turn into a duplicate write by repeating. return ( diff --git a/mcp_server/prowler_mcp_server/lib/types.py b/mcp_server/prowler_mcp_server/lib/types.py new file mode 100644 index 0000000000..5381b21e8a --- /dev/null +++ b/mcp_server/prowler_mcp_server/lib/types.py @@ -0,0 +1,18 @@ +"""Argument types shared by every tool in this server.""" + +from typing import Annotated + +from pydantic import StringConstraints + +# The identifiers tools take -- a scan UUID, a query id, a Jira project key -- +# are required because there is nothing sensible to do without them. A model +# that does not have one to hand tends to send an empty string rather than omit +# the argument, and an empty string is not caught by "required": it travels into +# a URL path or a request body and comes back as a 404 or an opaque API error +# ("This field may not be blank") that says nothing about which argument was at +# fault. Rejecting it here names the argument instead, and `minLength` puts the +# constraint in the tool schema so a client can see it before calling. +# +# Whitespace is stripped first, so " abc " is accepted as "abc" and " " is +# rejected like "". +NonBlankStr = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] diff --git a/mcp_server/prowler_mcp_server/lib/urls.py b/mcp_server/prowler_mcp_server/lib/urls.py new file mode 100644 index 0000000000..0c24a04417 --- /dev/null +++ b/mcp_server/prowler_mcp_server/lib/urls.py @@ -0,0 +1,36 @@ +"""URL construction shared by every sub-server. + +An identifier joined into a path unencoded is not sent as itself: httpx resolves +the URL per RFC 3986, so "../" walks the request onto another endpoint. +""" + +from urllib.parse import quote + +_DOT_SEGMENTS = frozenset({".", ".."}) + + +def path_segment(value: str) -> str: + """Encode one path segment, so an identifier names a resource and nothing else. + + Args: + value: The segment to encode, taken as a name in full. + + Returns: + The segment percent-encoded, with the dots escaped when it is only dots. + """ + encoded = quote(value, safe="") + # A dot is legal in a name, so `quote` keeps it: a segment of nothing but + # dots would still resolve away rather than name anything. + return encoded.replace(".", "%2E") if encoded in _DOT_SEGMENTS else encoded + + +def url_path(*segments: str) -> str: + """Build a URL path from one argument per segment, each of them encoded. + + Args: + *segments: The path segments, in order. + + Returns: + The joined path, with a leading slash. + """ + return "/" + "/".join(path_segment(segment) for segment in segments) diff --git a/mcp_server/prowler_mcp_server/prowler_app/models/attack_paths.py b/mcp_server/prowler_mcp_server/prowler_app/models/attack_paths.py index bbe2eb7401..acaacac540 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/models/attack_paths.py +++ b/mcp_server/prowler_mcp_server/prowler_app/models/attack_paths.py @@ -354,6 +354,14 @@ class AttackPathQueryResult(MinimalSerializerMixin, BaseModel): relationships: list[AttackPathsGraphRelationship] = Field( default_factory=list, description="Relationships connecting the nodes" ) + # A graph with nothing in it serializes to `{}`, since the mixin drops empty + # lists. That reads as an answer that went missing rather than as the finding + # it is -- the query ran and this account has no such attack path -- so the + # empty case carries a sentence saying so. + message: str | None = Field( + default=None, + description="Present only when the query matched nothing, to say the query ran and found no attack path rather than leaving an empty result to interpret", + ) @classmethod def from_api_response( @@ -368,7 +376,15 @@ class AttackPathQueryResult(MinimalSerializerMixin, BaseModel): Returns: AttackPathQueryResult with parsed data and summary """ - attributes = response.get("data", {}).get("attributes") + data = response.get("data") + attributes = data.get("attributes") if data is not None else None + # Prowler spells a graph with nothing in it either as empty lists or as + # a null `attributes`. Both say the same thing -- the query ran and + # matched nothing -- so the null reads as the empty graph it stands for + # instead of crashing the parse. + if attributes is None: + attributes = {} + nodes_data = attributes.get("nodes", []) relationships_data = attributes.get("relationships", []) diff --git a/mcp_server/prowler_mcp_server/prowler_app/models/providers.py b/mcp_server/prowler_mcp_server/prowler_app/models/providers.py index af9509a963..322d85186c 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/models/providers.py +++ b/mcp_server/prowler_mcp_server/prowler_app/models/providers.py @@ -2,7 +2,7 @@ from typing import Any, Literal -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict, Field from prowler_mcp_server.prowler_app.models.base import MinimalSerializerMixin @@ -104,6 +104,29 @@ class ProvidersListResponse(BaseModel): ) +class ProviderDeletionResult(MinimalSerializerMixin, BaseModel): + """Outcome of a provider deletion. + + Prowler deletes a provider in a background task, so the answer is not always + a finished deletion. A deletion that never started is raised as an error + instead of being reported here: this model only describes a deletion Prowler + accepted and began. + """ + + model_config = ConfigDict(frozen=True) + + status: Literal["deleted", "in_progress"] = Field( + description="Outcome of the deletion: 'deleted' when Prowler finished removing the provider, 'in_progress' when the background task was accepted and is still running, which is normal for a provider with many scans and findings" + ) + task_id: str | None = Field( + default=None, + description="UUIDv4 of the background deletion task, present when the deletion did not finish within the polling window so its state can be checked later", + ) + message: str = Field( + description="Human-readable description of what happened and what to do next" + ) + + class ProviderConnectionStatus(MinimalSerializerMixin, BaseModel): """Result of provider connection operation.""" diff --git a/mcp_server/prowler_mcp_server/prowler_app/models/scans.py b/mcp_server/prowler_mcp_server/prowler_app/models/scans.py index f8eef988ce..3fc095eaa8 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/models/scans.py +++ b/mcp_server/prowler_mcp_server/prowler_app/models/scans.py @@ -191,18 +191,18 @@ class ScansListResponse(BaseModel): class ScanCreationResult(MinimalSerializerMixin, BaseModel): - """Result of scan creation operation. + """Result of a scan creation that succeeded. - Used by trigger_scan() to communicate the outcome of scan creation. - Status indicates whether scan was created successfully or failed. + Used by trigger_scan(). A scan that was not created leaves the tool as an + error instead of being reported here, so this model only ever describes a + scan that exists -- which is why it carries no success flag: a field with + one reachable value says nothing, and inviting a reader to branch on it + suggests there is a failure shape to look for here. There is not; the + failure is the error. """ - scan: DetailedScan | None = Field( - default=None, - description="Detailed scan information if creation succeeded, None otherwise", - ) - status: Literal["success", "failed"] = Field( - description="Outcome of scan creation: success (scan created successfully) or failed (error)" + scan: DetailedScan = Field( + description="Detailed information about the scan that was created" ) message: str = Field( description="Human-readable message describing the scan creation result" @@ -210,13 +210,26 @@ class ScanCreationResult(MinimalSerializerMixin, BaseModel): class ScheduleCreationResult(MinimalSerializerMixin, BaseModel): - """Result of async schedule creation operation. + """Result of a daily schedule creation that succeeded. - Used by schedule_daily_scan() to communicate scheduling outcome. + Used by schedule_daily_scan(). Prowler commits the schedule inside the + request that creates it, so an answer means it exists; a provider that + already has one is refused with a 409 and leaves the tool as an error. That + leaves nothing for a success flag to distinguish, so there is none. """ - scheduled: bool = Field( - description="Whether the daily scan schedule was created successfully" + first_run_state: ( + Literal[ + "available", "scheduled", "executing", "completed", "failed", "cancelled" + ] + | None + ) = Field( + default=None, + description=( + "State of the first scan Prowler starts immediately alongside the schedule. " + "This describes that one run, not the recurring schedule, which stands " + "regardless of it" + ), ) message: str = Field( description="Human-readable message describing the scheduling result" diff --git a/mcp_server/prowler_mcp_server/prowler_app/server.py b/mcp_server/prowler_mcp_server/prowler_app/server.py index e8e854144f..3129a50d09 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/server.py +++ b/mcp_server/prowler_mcp_server/prowler_app/server.py @@ -3,7 +3,7 @@ from fastmcp import FastMCP from prowler_mcp_server.prowler_app.utils.tool_loader import load_all_tools # Initialize MCP server -app_mcp_server = FastMCP("prowler-app") +app_mcp_server = FastMCP("prowler-app", mask_error_details=True) # Auto-discover and load all tools from the tools package load_all_tools(app_mcp_server) diff --git a/mcp_server/prowler_mcp_server/prowler_app/tools/attack_paths.py b/mcp_server/prowler_mcp_server/prowler_app/tools/attack_paths.py index 5bd66760fa..b1914dc795 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/tools/attack_paths.py +++ b/mcp_server/prowler_mcp_server/prowler_app/tools/attack_paths.py @@ -7,8 +7,11 @@ through cloud infrastructure relationships. from typing import Any, Literal +from fastmcp.exceptions import ToolError from pydantic import Field +from prowler_mcp_server.lib.errors import ProwlerAPIError +from prowler_mcp_server.lib.types import NonBlankStr from prowler_mcp_server.prowler_app.models.attack_paths import ( AttackPathCartographySchema, AttackPathQuery, @@ -76,50 +79,47 @@ class AttackPathsTools(BaseTool): 2. Use prowler_list_attack_paths_queries to see available queries for a scan 3. Use prowler_run_attack_paths_query to execute analysis """ - try: - # Validate pagination - self.api_client.validate_page_size(page_size) + # Validate pagination + self.api_client.validate_page_size(page_size) - # Build query parameters - params: dict[str, Any] = { - "page[size]": page_size, - "page[number]": page_number, - } + # Build query parameters + params: dict[str, Any] = { + "page[size]": page_size, + "page[number]": page_number, + } - # Apply provider filters - if provider_id: - params["filter[provider__in]"] = provider_id - if provider_type: - params["filter[provider_type__in]"] = provider_type + # Apply provider filters + if provider_id: + params["filter[provider__in]"] = provider_id + if provider_type: + params["filter[provider_type__in]"] = provider_type - # Apply state filter - if state: - params["filter[state__in]"] = state + # Apply state filter + if state: + params["filter[state__in]"] = state - clean_params = self.api_client.build_filter_params(params) + clean_params = self.api_client.build_filter_params(params) - api_response = await self.api_client.get( - "/attack-paths-scans", params=clean_params - ) - simplified_response = AttackPathScansListResponse.from_api_response( - api_response - ) + api_response = await self.api_client.get( + "/attack-paths-scans", params=clean_params + ) + simplified_response = AttackPathScansListResponse.from_api_response( + api_response + ) - return simplified_response.model_dump() - except Exception as e: - self.logger.error(f"Failed to list attack paths scans: {e}") - return {"error": f"Failed to list attack paths scans: {str(e)}"} + return simplified_response.model_dump() async def list_attack_paths_queries( self, - scan_id: str = Field( - description="UUID of a COMPLETED attack paths scan. Use `prowler_list_attack_paths_scans` with state=['completed'] to find scan IDs" + scan_id: NonBlankStr = Field( + description="UUID of a COMPLETED attack paths scan, as returned by `prowler_list_attack_paths_scans` with state=['completed']. This is NOT a regular scan ID: an ID from `prowler_search_scans` or `prowler_get_scan` names a different resource and is rejected here" ), ) -> list[dict[str, Any]]: """Discover available Attack Paths queries for a completed scan. IMPORTANT: The scan must be in 'completed' state to list queries. - Queries are provider-specific + Attack Paths covers AWS providers only, so only an AWS provider has an + Attack Paths scan to name here, and every query is an AWS one. Each query includes: - id: Query identifier to use with run_attack_paths_query @@ -141,23 +141,32 @@ class AttackPathsTools(BaseTool): api_response = await self.api_client.get( f"/attack-paths-scans/{scan_id}/queries" ) + except ProwlerAPIError as e: + # A 404 here is Prowler failing to resolve `scan_id` to an Attack + # Paths scan, and its own reason for it -- a bare "Not found." -- + # does not say what kind of ID it was looking for. The mistake it + # stands for is a regular scan ID: an Attack Paths scan is a separate + # resource with IDs of its own, and Prowler only creates one for an + # AWS provider, so a scan of any other provider has none to pass. + # + # The endpoint answers 404 for a second thing -- a provider type with + # no query catalog -- but that one cannot happen: a scan only exists + # where Attack Paths runs, which is AWS, and AWS has a catalog. + if e.status_code == 404: + raise self._unknown_scan_error(scan_id) + raise - return [ - AttackPathQuery.from_api_response(query).model_dump() - for query in api_response.get("data", []) - ] - except Exception as e: - self.logger.error( - f"Failed to list attack paths queries for scan {scan_id}: {e}" - ) - return [{"error": f"Failed to list attack paths queries: {str(e)}"}] + return [ + AttackPathQuery.from_api_response(query).model_dump() + for query in api_response.get("data", []) + ] async def run_attack_paths_query( self, - scan_id: str = Field( + scan_id: NonBlankStr = Field( description="UUID of a COMPLETED attack paths scan. The scan must be in 'completed' state" ), - query_id: str = Field( + query_id: NonBlankStr = Field( description="Query ID to execute (e.g., 'aws-internet-exposed-ec2-sensitive-s3-access'). Use `prowler_list_attack_paths_queries` to discover available queries" ), parameters: dict[str, str] = Field( @@ -198,39 +207,61 @@ class AttackPathsTools(BaseTool): 3. Execute this tool with appropriate parameters 4. Analyze the returned graph for security insights """ - try: - # Build the request payload following JSON:API format - request_data: dict[str, Any] = { - "data": { - "type": "attack-paths-query-run-requests", - "attributes": { - "id": query_id, - }, + # Build the request payload following JSON:API format + request_data: dict[str, Any] = { + "data": { + "type": "attack-paths-query-run-requests", + "attributes": { + "id": query_id, }, - } + }, + } - # Add parameters if provided - if parameters: - request_data["data"]["attributes"]["parameters"] = parameters + # Add parameters if provided + if parameters: + request_data["data"]["attributes"]["parameters"] = parameters + try: api_response = await self.api_client.post( f"/attack-paths-scans/{scan_id}/queries/run", json_data=request_data, ) + except ProwlerAPIError as e: + # Prowler answers a query that matched nothing with 404 and the empty + # result as the body. That is an answer -- this account has no such + # attack path, which is the good outcome -- so it is returned rather + # than raised: reporting it as a failure invites a retry of a call + # whose arguments were right, and hides a clean result. + if e.status_code == 404 and isinstance(e.payload, dict): + if "data" in e.payload: + api_response = e.payload + else: + # No result body, so `scan_id` did not resolve to an Attack + # Paths scan. An unknown query_id is a 400, not this. + raise self._unknown_scan_error(scan_id) + else: + raise - # Parse the response - query_result = AttackPathQueryResult.from_api_response(api_response) + # Parse the response + query_result = AttackPathQueryResult.from_api_response(api_response) - return query_result.model_dump() - except Exception as e: - self.logger.error( - f"Failed to run attack paths query '{query_id}' on scan {scan_id}: {e}" + if not query_result.nodes: + query_result = query_result.model_copy( + update={ + "message": ( + f"The query '{query_id}' ran against scan {scan_id} and matched " + "nothing, so this provider has no attack path of that shape. " + "The scan and the query ID were both valid; running it again " + "will return the same thing." + ) + } ) - return {"error": f"Failed to run attack paths query '{query_id}': {str(e)}"} + + return query_result.model_dump() async def get_attack_paths_cartography_schema( self, - scan_id: str = Field( + scan_id: NonBlankStr = Field( description="UUID of a COMPLETED attack paths scan. Use `prowler_list_attack_paths_scans` with state=['completed'] to find scan IDs" ), ) -> dict[str, Any]: @@ -262,18 +293,43 @@ class AttackPathsTools(BaseTool): api_response = await self.api_client.get( f"/attack-paths-scans/{scan_id}/schema" ) + except ProwlerAPIError as e: + # Two 404s again, told apart by whether Prowler wrote a JSON:API + # error. Absent means the scan resolved and its graph simply records + # no Cartography module, so the ID is not the thing to change. + if e.status_code == 404: + if e.detail is None: + raise ToolError( + f"Scan {scan_id} has no Cartography schema recorded, so there is " + "nothing to write custom queries against. Use " + "prowler_list_attack_paths_queries for the ready-made queries of " + "this scan, which do not need the schema." + ) + else: + raise self._unknown_scan_error(scan_id) + raise - schema = AttackPathCartographySchema.from_api_response(api_response) + schema = AttackPathCartographySchema.from_api_response(api_response) - schema_content = await self.api_client.fetch_external_url( - schema.raw_schema_url - ) + schema_content = await self.api_client.fetch_external_url(schema.raw_schema_url) - return schema.model_copy( - update={"schema_content": schema_content} - ).model_dump() - except Exception as e: - self.logger.error( - f"Failed to get cartography schema for scan {scan_id}: {e}" - ) - return {"error": f"Failed to get cartography schema: {str(e)}"} + return schema.model_copy(update={"schema_content": schema_content}).model_dump() + + # Private helper methods + + @staticmethod + def _unknown_scan_error(scan_id: str) -> ToolError: + """Describe a scan ID Prowler could not resolve to an Attack Paths scan. + + Returns: + The ``ToolError`` for the caller to raise. Built without a ``from`` + clause on purpose: the sentence is the final word, not a wrapper + around the API's. + """ + return ToolError( + f"Prowler has no Attack Paths scan with ID {scan_id}. These are a " + "different resource from regular scans and only exist for AWS " + "providers, so an ID from prowler_search_scans or prowler_get_scan " + "never resolves here. Use prowler_list_attack_paths_scans to get an " + "ID these tools take." + ) diff --git a/mcp_server/prowler_mcp_server/prowler_app/tools/compliance.py b/mcp_server/prowler_mcp_server/prowler_app/tools/compliance.py index 33cdd22a69..b7f06be9a8 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/tools/compliance.py +++ b/mcp_server/prowler_mcp_server/prowler_app/tools/compliance.py @@ -6,8 +6,11 @@ across all cloud providers. from typing import Any +from fastmcp.exceptions import ToolError from pydantic import Field +from prowler_mcp_server.lib.errors import InvalidArgument +from prowler_mcp_server.lib.types import NonBlankStr from prowler_mcp_server.prowler_app.models.compliance import ( ComplianceFrameworksListResponse, ComplianceRequirementAttributesListResponse, @@ -34,7 +37,7 @@ class ComplianceTools(BaseTool): The scan_id of the latest completed scan for the provider. Raises: - ValueError: If no completed scans are found for the provider. + ToolError: If no completed scans are found for the provider """ scan_params = { "filter[provider]": provider_id, @@ -48,7 +51,7 @@ class ComplianceTools(BaseTool): scans_data = scans_response.get("data", []) if not scans_data: - raise ValueError( + raise ToolError( f"No completed scans found for provider {provider_id}. " "Run a scan first using prowler_trigger_scan." ) @@ -93,18 +96,15 @@ class ComplianceTools(BaseTool): 2. Use prowler_get_compliance_framework_state_details with a specific compliance_id to see which requirements failed """ if not scan_id and not provider_id: - return { - "error": "Either scan_id or provider_id must be provided. Use prowler_search_providers to find provider IDs or prowler_list_scans to find scan IDs." - } + raise InvalidArgument( + "Either scan_id or provider_id must be provided. Use prowler_search_providers to find provider IDs or prowler_list_scans to find scan IDs." + ) elif scan_id and provider_id: - return { - "error": "Provide either scan_id or provider_id, not both. To get compliance data for a specific scan, use scan_id. To get data for the latest scan of a provider, use provider_id." - } + raise InvalidArgument( + "Provide either scan_id or provider_id, not both. To get compliance data for a specific scan, use scan_id. To get data for the latest scan of a provider, use provider_id." + ) elif not scan_id and provider_id: - try: - scan_id = await self._get_latest_scan_id_for_provider(provider_id) - except ValueError as e: - return {"error": str(e)} + scan_id = await self._get_latest_scan_id_for_provider(provider_id) params: dict[str, Any] = {"filter[scan_id]": scan_id} @@ -253,16 +253,16 @@ class ComplianceTools(BaseTool): async def get_compliance_framework_state_details( self, - compliance_id: str = Field( + compliance_id: NonBlankStr = Field( description="Compliance framework ID to get details for (e.g., 'cis_1.5_aws', 'pci_dss_v4.0_aws'). You can get compliance IDs from prowler_get_compliance_overview or consulting Prowler Hub/Prowler Documentation that you can also find in form of tools in this MCP Server", ), scan_id: str | None = Field( default=None, - description="UUID of a specific scan to get compliance data for. Required if provider_id is not specified.", + description="UUID of a specific scan to get compliance data for. Required if provider_id is not specified. Do not pass it together with provider_id.", ), provider_id: str | None = Field( default=None, - description="Prowler's internal UUID (v4) for a specific provider. If provided without scan_id, the tool will automatically find the latest completed scan for this provider. Use `prowler_search_providers` tool to find provider IDs.", + description="Prowler's internal UUID (v4) for a specific provider. The tool will automatically find the latest completed scan for this provider. Use `prowler_search_providers` tool to find provider IDs. Do not pass it together with scan_id.", ), ) -> dict[str, Any]: """Get detailed requirement-level breakdown for a specific compliance framework. @@ -283,8 +283,8 @@ class ComplianceTools(BaseTool): - Use prowler_get_finding_details with these finding IDs for more details and remediation guidance Default behavior: - - Requires either scan_id OR provider_id - - With provider_id (no scan_id): Automatically finds the latest completed scan for that provider + - Requires exactly one of scan_id OR provider_id; providing both is rejected + - With provider_id: Automatically finds the latest completed scan for that provider - With scan_id: Uses that specific scan's compliance data - Only shows failed requirements with their associated failed finding IDs @@ -293,21 +293,22 @@ class ComplianceTools(BaseTool): 2. Use this tool with the compliance_id to see failed requirements and their finding IDs 3. Use prowler_get_finding_details with the finding IDs to get remediation guidance """ - # Validate that either scan_id or provider_id is provided + # Exactly one of the two: taking scan_id and ignoring provider_id would + # answer for whatever provider that scan belongs to, which is not + # necessarily the one the caller named. if not scan_id and not provider_id: - return { - "error": "Either scan_id or provider_id must be provided. Use prowler_search_providers to find provider IDs or prowler_list_scans to find scan IDs." - } + raise InvalidArgument( + "Either scan_id or provider_id must be provided. Use prowler_search_providers to find provider IDs or prowler_list_scans to find scan IDs." + ) + elif scan_id and provider_id: + raise InvalidArgument( + "Provide either scan_id or provider_id, not both. To get compliance data for a specific scan, use scan_id. To get data for the latest scan of a provider, use provider_id." + ) # Resolve provider_id to latest scan_id if needed resolved_scan_id = scan_id if not scan_id and provider_id: - try: - resolved_scan_id = await self._get_latest_scan_id_for_provider( - provider_id - ) - except ValueError as e: - return {"error": str(e)} + resolved_scan_id = await self._get_latest_scan_id_for_provider(provider_id) # Build params for requirements endpoint params: dict[str, Any] = { diff --git a/mcp_server/prowler_mcp_server/prowler_app/tools/finding_groups.py b/mcp_server/prowler_mcp_server/prowler_app/tools/finding_groups.py index 05adf8db2b..54bc3bcbcd 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/tools/finding_groups.py +++ b/mcp_server/prowler_mcp_server/prowler_app/tools/finding_groups.py @@ -6,8 +6,10 @@ This module provides read-only tools for finding group triage and drill-downs. from typing import Any, Literal from urllib.parse import quote +from fastmcp.exceptions import ToolError from pydantic import Field +from prowler_mcp_server.lib.types import NonBlankStr from prowler_mcp_server.prowler_app.models.finding_groups import ( DetailedFindingGroup, FindingGroupResourcesListResponse, @@ -236,50 +238,46 @@ class FindingGroupsTools(BaseTool): prowler_get_finding_group_details for complete counters or prowler_list_finding_group_resources to drill into affected resources. """ - try: - self.api_client.validate_page_size(page_size) - date_range, params = self._base_date_params(date_from, date_to) - endpoint = self._group_endpoint(date_range) + self.api_client.validate_page_size(page_size) + date_range, params = self._base_date_params(date_from, date_to) + endpoint = self._group_endpoint(date_range) - self._apply_common_filters( - params, - provider, - provider_type, - provider_uid, - provider_alias, - region, - service, - resource_type, - resource_name, - resource_uid, - resource_group, - category, - check_id, - check_title, - severity, - status, - muted, - delta, - ) + self._apply_common_filters( + params, + provider, + provider_type, + provider_uid, + provider_alias, + region, + service, + resource_type, + resource_name, + resource_uid, + resource_group, + category, + check_id, + check_title, + severity, + status, + muted, + delta, + ) - params["filter[include_muted]"] = self._bool_value(include_muted) - params["page[size]"] = page_size - params["page[number]"] = page_number - params["fields[finding-groups]"] = GROUP_LIST_FIELDS - if sort: - params["sort"] = sort + params["filter[include_muted]"] = self._bool_value(include_muted) + params["page[size]"] = page_size + params["page[number]"] = page_number + params["fields[finding-groups]"] = GROUP_LIST_FIELDS + if sort: + params["sort"] = sort - clean_params = self.api_client.build_filter_params(params) - api_response = await self.api_client.get(endpoint, params=clean_params) - response = FindingGroupsListResponse.from_api_response(api_response) - return response.model_dump() - except Exception as e: - self.logger.error(f"Error listing finding groups: {e}") - return {"error": str(e), "status": "failed"} + clean_params = self.api_client.build_filter_params(params) + api_response = await self.api_client.get(endpoint, params=clean_params) + response = FindingGroupsListResponse.from_api_response(api_response) + return response.model_dump() async def get_finding_group_details( self, - check_id: str = Field( + check_id: NonBlankStr = Field( description="Public check ID that identifies the finding group. This is not a UUID." ), date_from: str | None = Field( @@ -297,39 +295,37 @@ class FindingGroupsTools(BaseTool): or historical data when dates are provided. Fully muted groups are included by default so accepted risk does not look like a missing group. """ - try: - date_range, params = self._base_date_params(date_from, date_to) - endpoint = self._group_endpoint(date_range) + date_range, params = self._base_date_params(date_from, date_to) + endpoint = self._group_endpoint(date_range) - params.update( - { - "filter[check_id]": check_id, - "filter[include_muted]": True, - "page[size]": 1, - "page[number]": 1, - "fields[finding-groups]": GROUP_DETAIL_FIELDS, - } + params.update( + { + "filter[check_id]": check_id, + "filter[include_muted]": True, + "page[size]": 1, + "page[number]": 1, + "fields[finding-groups]": GROUP_DETAIL_FIELDS, + } + ) + + clean_params = self.api_client.build_filter_params(params) + api_response = await self.api_client.get(endpoint, params=clean_params) + data = api_response.get("data", []) + + if not data: + # No `from`: this names the check and the tool that lists valid ones, + # neither of which the shared classifier can know. + raise ToolError( + f"No finding group exists for check '{check_id}' in this scan. Use " + "prowler_list_finding_groups to see the checks that have findings." ) - clean_params = self.api_client.build_filter_params(params) - api_response = await self.api_client.get(endpoint, params=clean_params) - data = api_response.get("data", []) - - if not data: - return { - "error": f"Finding group '{check_id}' not found.", - "status": "not_found", - } - - group = DetailedFindingGroup.from_api_response(data[0]) - return group.model_dump() - except Exception as e: - self.logger.error(f"Error getting finding group details: {e}") - return {"error": str(e), "status": "failed"} + group = DetailedFindingGroup.from_api_response(data[0]) + return group.model_dump() async def list_finding_group_resources( self, - check_id: str = Field( + check_id: NonBlankStr = Field( description="Public check ID that identifies the finding group. This is not a UUID." ), provider: list[str] = Field( @@ -426,45 +422,41 @@ class FindingGroupsTools(BaseTool): `finding_id`. Use `prowler_get_finding_details(finding_id)` to retrieve complete remediation guidance for a specific resource finding. """ - try: - self.api_client.validate_page_size(page_size) - date_range, params = self._base_date_params(date_from, date_to) - endpoint = self._resource_endpoint(check_id, date_range) + self.api_client.validate_page_size(page_size) + date_range, params = self._base_date_params(date_from, date_to) + endpoint = self._resource_endpoint(check_id, date_range) - if muted is None and not self._bool_value(include_muted): - muted = False + if muted is None and not self._bool_value(include_muted): + muted = False - self._apply_common_filters( - params, - provider, - provider_type, - provider_uid, - provider_alias, - region, - service, - resource_type, - resource_name, - resource_uid, - resource_group, - category, - [], - None, - severity, - status, - muted, - delta, - ) + self._apply_common_filters( + params, + provider, + provider_type, + provider_uid, + provider_alias, + region, + service, + resource_type, + resource_name, + resource_uid, + resource_group, + category, + [], + None, + severity, + status, + muted, + delta, + ) - params["page[size]"] = page_size - params["page[number]"] = page_number - params["fields[finding-group-resources]"] = RESOURCE_FIELDS - if sort: - params["sort"] = sort + params["page[size]"] = page_size + params["page[number]"] = page_number + params["fields[finding-group-resources]"] = RESOURCE_FIELDS + if sort: + params["sort"] = sort - clean_params = self.api_client.build_filter_params(params) - api_response = await self.api_client.get(endpoint, params=clean_params) - response = FindingGroupResourcesListResponse.from_api_response(api_response) - return response.model_dump() - except Exception as e: - self.logger.error(f"Error listing finding group resources: {e}") - return {"error": str(e), "status": "failed"} + clean_params = self.api_client.build_filter_params(params) + api_response = await self.api_client.get(endpoint, params=clean_params) + response = FindingGroupResourcesListResponse.from_api_response(api_response) + return response.model_dump() diff --git a/mcp_server/prowler_mcp_server/prowler_app/tools/findings.py b/mcp_server/prowler_mcp_server/prowler_app/tools/findings.py index b556101cab..860dfa806f 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/tools/findings.py +++ b/mcp_server/prowler_mcp_server/prowler_app/tools/findings.py @@ -8,6 +8,7 @@ from typing import Any, Literal from pydantic import Field +from prowler_mcp_server.lib.types import NonBlankStr from prowler_mcp_server.prowler_app.models.findings import ( DetailedFinding, FindingsListResponse, @@ -180,7 +181,7 @@ class FindingsTools(BaseTool): async def get_finding_details( self, - finding_id: str = Field( + finding_id: NonBlankStr = Field( description="UUID of the finding to retrieve (must be a valid UUID format, e.g., '019ac0d6-90d5-73e9-9acf-c22e256f1bac'). Returns an error if the finding ID is invalid or not found." ), ) -> dict[str, Any]: diff --git a/mcp_server/prowler_mcp_server/prowler_app/tools/integrations.py b/mcp_server/prowler_mcp_server/prowler_app/tools/integrations.py index d0aac0182a..f458a2ee5f 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/tools/integrations.py +++ b/mcp_server/prowler_mcp_server/prowler_app/tools/integrations.py @@ -9,8 +9,11 @@ This module provides tools for managing where Prowler sends its results, includi import json from typing import Any +from fastmcp.exceptions import ToolError from pydantic import Field +from prowler_mcp_server.lib.errors import CredentialError, InvalidArgument +from prowler_mcp_server.lib.types import NonBlankStr from prowler_mcp_server.prowler_app.models.integrations import ( DetailedIntegration, IntegrationConnectionStatus, @@ -126,7 +129,7 @@ class IntegrationsTools(BaseTool): async def get_integration( self, - integration_id: str = Field( + integration_id: NonBlankStr = Field( description="UUID of the integration to retrieve. Must be a valid UUID format (e.g., '019ac0d6-90d5-73e9-9acf-c22e256f1bac'). Use prowler_list_integrations to find it." ), ) -> dict[str, Any]: @@ -157,7 +160,7 @@ class IntegrationsTools(BaseTool): async def create_amazon_s3_integration( self, - bucket_name: str = Field( + bucket_name: NonBlankStr = Field( description="Name of the S3 bucket where Prowler will upload the scan outputs (CSV, HTML, OCSF JSON and compliance reports)." ), output_directory: str = Field( @@ -168,15 +171,15 @@ class IntegrationsTools(BaseTool): default=[], description="Prowler UUIDs of the providers whose scan outputs are exported to this bucket. Use prowler_search_providers to find them. Leave empty to attach no provider yet.", ), - role_arn: str | None = Field( + role_arn: NonBlankStr | None = Field( default=None, description="ARN of the IAM role Prowler assumes to write to the bucket (e.g. 'arn:aws:iam::123456789012:role/ProwlerS3Integration'). Recommended over static keys.", ), - external_id: str | None = Field( + external_id: NonBlankStr | None = Field( default=None, description="External ID required by the trust policy of the assumed role. In Prowler Cloud this is the tenant ID.", ), - role_session_name: str | None = Field( + role_session_name: NonBlankStr | None = Field( default=None, description="Identifier for the role session, useful to track it in AWS logs. Only letters, digits and the characters =,.@_- are allowed.", ), @@ -184,15 +187,15 @@ class IntegrationsTools(BaseTool): default=3600, description="Duration of the assumed role session in seconds. Must be between 900 and 43200. Defaults to 3600 when omitted.", ), - aws_access_key_id: str | None = Field( + aws_access_key_id: NonBlankStr | None = Field( default=None, description="AWS access key ID. Only needed when the Prowler deployment has no ambient AWS credentials.", ), - aws_secret_access_key: str | None = Field( + aws_secret_access_key: NonBlankStr | None = Field( default=None, description="AWS secret access key. Required when 'aws_access_key_id' is provided.", ), - aws_session_token: str | None = Field( + aws_session_token: NonBlankStr | None = Field( default=None, description="AWS session token, only for temporary credentials.", ), @@ -244,34 +247,30 @@ class IntegrationsTools(BaseTool): """ self.logger.info(f"Creating Amazon S3 integration for bucket {bucket_name}...") - try: - credentials = self._build_aws_credentials( - role_arn=role_arn, - external_id=external_id, - role_session_name=role_session_name, - session_duration=session_duration, - aws_access_key_id=aws_access_key_id, - aws_secret_access_key=aws_secret_access_key, - aws_session_token=aws_session_token, - ) + credentials = self._build_aws_credentials( + role_arn=role_arn, + external_id=external_id, + role_session_name=role_session_name, + session_duration=session_duration, + aws_access_key_id=aws_access_key_id, + aws_secret_access_key=aws_secret_access_key, + aws_session_token=aws_session_token, + ) - return await self._create_integration( - integration_type="amazon_s3", - configuration={ - "bucket_name": bucket_name, - "output_directory": output_directory, - }, - credentials=credentials, - provider_ids=provider_ids, - enabled=enabled, - ) - except Exception as e: - self.logger.error(f"Amazon S3 integration creation failed: {e}") - return {"error": str(e), "status": "failed"} + return await self._create_integration( + integration_type="amazon_s3", + configuration={ + "bucket_name": bucket_name, + "output_directory": output_directory, + }, + credentials=credentials, + provider_ids=provider_ids, + enabled=enabled, + ) async def create_aws_security_hub_integration( self, - provider_id: str = Field( + provider_id: NonBlankStr = Field( description="Prowler UUID of the AWS provider whose findings are sent to Security Hub. It must be an AWS provider, and it can only have one Security Hub integration. Use prowler_search_providers with provider_type=['aws'] to find it." ), send_only_fails: bool = Field( @@ -282,15 +281,15 @@ class IntegrationsTools(BaseTool): default=False, description="When true, findings that are no longer present in the latest scan are archived in Security Hub.", ), - role_arn: str | None = Field( + role_arn: NonBlankStr | None = Field( default=None, description="ARN of a dedicated IAM role Prowler assumes to write to Security Hub. Leave every credential parameter empty to reuse the credentials already stored for the provider, which is the recommended setup.", ), - external_id: str | None = Field( + external_id: NonBlankStr | None = Field( default=None, description="External ID required by the trust policy of the assumed role.", ), - role_session_name: str | None = Field( + role_session_name: NonBlankStr | None = Field( default=None, description="Identifier for the role session, useful to track it in AWS logs. Only letters, digits and the characters =,.@_- are allowed.", ), @@ -298,14 +297,14 @@ class IntegrationsTools(BaseTool): default=None, description="Duration of the assumed role session in seconds. Must be between 900 and 43200. Defaults to 3600 when omitted.", ), - aws_access_key_id: str | None = Field( + aws_access_key_id: NonBlankStr | None = Field( default=None, description="AWS access key ID for dedicated credentials." ), - aws_secret_access_key: str | None = Field( + aws_secret_access_key: NonBlankStr | None = Field( default=None, description="AWS secret access key. Required when 'aws_access_key_id' is provided.", ), - aws_session_token: str | None = Field( + aws_session_token: NonBlankStr | None = Field( default=None, description="AWS session token, only for temporary credentials.", ), @@ -344,40 +343,36 @@ class IntegrationsTools(BaseTool): f"Creating AWS Security Hub integration for provider {provider_id}..." ) - try: - credentials = self._build_aws_credentials( - role_arn=role_arn, - external_id=external_id, - role_session_name=role_session_name, - session_duration=session_duration, - aws_access_key_id=aws_access_key_id, - aws_secret_access_key=aws_secret_access_key, - aws_session_token=aws_session_token, - ) + credentials = self._build_aws_credentials( + role_arn=role_arn, + external_id=external_id, + role_session_name=role_session_name, + session_duration=session_duration, + aws_access_key_id=aws_access_key_id, + aws_secret_access_key=aws_secret_access_key, + aws_session_token=aws_session_token, + ) - return await self._create_integration( - integration_type="aws_security_hub", - configuration={ - "send_only_fails": send_only_fails, - "archive_previous_findings": archive_previous_findings, - }, - credentials=credentials, - provider_ids=[provider_id], - enabled=enabled, - ) - except Exception as e: - self.logger.error(f"AWS Security Hub integration creation failed: {e}") - return {"error": str(e), "status": "failed"} + return await self._create_integration( + integration_type="aws_security_hub", + configuration={ + "send_only_fails": send_only_fails, + "archive_previous_findings": archive_previous_findings, + }, + credentials=credentials, + provider_ids=[provider_id], + enabled=enabled, + ) async def create_jira_integration( self, - domain: str = Field( + domain: NonBlankStr = Field( description="Atlassian site name, without the '.atlassian.net' suffix. For the site 'https://acme.atlassian.net' the value is 'acme'. Full URLs are accepted and normalized automatically." ), - user_mail: str = Field( + user_mail: NonBlankStr = Field( description="Email address of the Atlassian account that owns the API token." ), - api_token: str = Field( + api_token: NonBlankStr = Field( description="Atlassian API token, created from the account settings. It needs the 'read:jira-user', 'read:jira-work' and 'write:jira-work' scopes." ), enabled: bool = Field( @@ -416,31 +411,25 @@ class IntegrationsTools(BaseTool): 3. Use prowler_get_jira_issue_types with that project key to pick an issue type 4. Use prowler_send_findings_to_jira to create the work items """ - try: - normalized_domain = self._normalize_atlassian_domain(domain) - self.logger.info( - f"Creating Jira integration for domain {normalized_domain}..." - ) + normalized_domain = self._normalize_atlassian_domain(domain) + self.logger.info(f"Creating Jira integration for domain {normalized_domain}...") - return await self._create_integration( - integration_type="jira", - # Jira rejects any configuration in the payload, the API generates it - configuration={}, - credentials={ - "domain": normalized_domain, - "user_mail": user_mail, - "api_token": api_token, - }, - provider_ids=[], - enabled=enabled, - ) - except Exception as e: - self.logger.error(f"Jira integration creation failed: {e}") - return {"error": str(e), "status": "failed"} + return await self._create_integration( + integration_type="jira", + # Jira rejects any configuration in the payload, the API generates it + configuration={}, + credentials={ + "domain": normalized_domain, + "user_mail": user_mail, + "api_token": api_token, + }, + provider_ids=[], + enabled=enabled, + ) async def update_integration( self, - integration_id: str = Field( + integration_id: NonBlankStr = Field( description="UUID of the integration to update. Use prowler_list_integrations to find it." ), enabled: bool | None = Field( @@ -494,96 +483,86 @@ class IntegrationsTools(BaseTool): """ self.logger.info(f"Updating integration {integration_id}...") - try: - current = DetailedIntegration.from_api_response( - await self._get_integration_raw(integration_id) - ) - integration_type = current.integration_type + current = DetailedIntegration.from_api_response( + await self._get_integration_raw(integration_id) + ) + integration_type = current.integration_type - if provider_ids is not None: - if integration_type == "jira": - raise ValueError( - "Jira integrations are tenant-wide and cannot be attached to providers." - ) - if integration_type == "aws_security_hub" and len(provider_ids) != 1: - raise ValueError( - "AWS Security Hub integrations must stay attached to exactly one AWS " - f"provider, got {len(provider_ids)}. Pass a single provider ID, or use " - "prowler_delete_integration to stop sending findings to Security Hub." - ) - - attributes: dict[str, Any] = {} - if enabled is not None: - attributes["enabled"] = enabled - - if credentials is not None: - attributes["credentials"] = self._validate_credentials( - integration_type, self._as_dict(credentials, "credentials") + if provider_ids is not None: + if integration_type == "jira": + raise InvalidArgument( + "Jira integrations are tenant-wide and cannot be attached to providers." + ) + if integration_type == "aws_security_hub" and len(provider_ids) != 1: + raise InvalidArgument( + "AWS Security Hub integrations must stay attached to exactly one AWS " + f"provider, got {len(provider_ids)}. Pass a single provider ID, or use " + "prowler_delete_integration to stop sending findings to Security Hub." ) - if configuration is not None: - if integration_type == "jira": - raise ValueError( - "Jira integrations do not accept a configuration: it is generated by Prowler. " - "Update the credentials instead, or run prowler_test_integration_connection to " - "refresh the available projects and issue types." - ) - merged = dict(current.configuration) - merged.update(self._as_dict(configuration, "configuration")) - # Server-owned, the API repopulates it from the connection check - merged.pop("regions", None) - merged.pop("enabled_regions", None) - attributes["configuration"] = merged + attributes: dict[str, Any] = {} + if enabled is not None: + attributes["enabled"] = enabled - if not attributes and provider_ids is None: - self.logger.info("No changes provided, returning the current state") - return current.model_dump() + if credentials is not None: + attributes["credentials"] = self._validate_credentials( + integration_type, self._as_dict(credentials, "credentials") + ) - update_body: dict[str, Any] = { - "data": { - "type": "integrations", - "id": integration_id, - "attributes": attributes, - } + if configuration is not None: + if integration_type == "jira": + raise InvalidArgument( + "Jira integrations do not accept a configuration: it is generated by Prowler. " + "Update the credentials instead, or run prowler_test_integration_connection to " + "refresh the available projects and issue types." + ) + merged = dict(current.configuration) + merged.update(self._as_dict(configuration, "configuration")) + # Server-owned, the API repopulates it from the connection check + merged.pop("regions", None) + merged.pop("enabled_regions", None) + attributes["configuration"] = merged + + if not attributes and provider_ids is None: + self.logger.info("No changes provided, returning the current state") + return current.model_dump() + + update_body: dict[str, Any] = { + "data": { + "type": "integrations", + "id": integration_id, + "attributes": attributes, } - if provider_ids is not None: - update_body["data"]["relationships"] = _providers_relationship( - provider_ids - ) + } + if provider_ids is not None: + update_body["data"]["relationships"] = _providers_relationship(provider_ids) - await self.api_client.patch( - f"/integrations/{integration_id}", json_data=update_body - ) + await self.api_client.patch( + f"/integrations/{integration_id}", json_data=update_body + ) - # A different provider means different effective credentials and different - # discovered configuration, so the stored connection state is stale too - providers_changed = provider_ids is not None and set(provider_ids) != set( - current.provider_ids - ) - recheck_connection = ( - credentials is not None - or configuration is not None - or providers_changed - ) - connection_status = ( - await self._test_connection(integration_id) - if recheck_connection - else None - ) + # A different provider means different effective credentials and different + # discovered configuration, so the stored connection state is stale too + providers_changed = provider_ids is not None and set(provider_ids) != set( + current.provider_ids + ) + recheck_connection = ( + credentials is not None or configuration is not None or providers_changed + ) + connection_status = ( + await self._test_connection(integration_id) if recheck_connection else None + ) - updated = await self._get_integration_raw(integration_id) - if connection_status is not None: - return IntegrationConnectionStatus.create( - updated, connection_status - ).model_dump() - return DetailedIntegration.from_api_response(updated).model_dump() - except Exception as e: - self.logger.error(f"Integration update failed: {e}") - return {"error": str(e), "status": "failed"} + updated = await self._get_integration_raw(integration_id) + if connection_status is not None: + return IntegrationConnectionStatus.create( + updated, connection_status + ).model_dump() + return DetailedIntegration.from_api_response(updated).model_dump() async def delete_integration( self, - integration_id: str = Field( + integration_id: NonBlankStr = Field( description="UUID of the integration to permanently remove. Use prowler_list_integrations to find it." ), ) -> dict[str, Any]: @@ -606,22 +585,15 @@ class IntegrationsTools(BaseTool): """ self.logger.info(f"Deleting integration {integration_id}...") - try: - await self.api_client.delete(f"/integrations/{integration_id}") - return { - "deleted": True, - "message": f"Integration {integration_id} deleted successfully", - } - except Exception as e: - self.logger.error(f"Integration deletion failed: {e}") - return { - "deleted": False, - "message": f"Integration {integration_id} deletion failed: {str(e)}", - } + await self.api_client.delete(f"/integrations/{integration_id}") + # No `deleted` flag: an integration that was not deleted leaves this tool + # as an error, so the flag could only ever be True and a reader branching + # on it would be looking for a shape that does not exist. + return {"message": f"Integration {integration_id} deleted successfully"} async def test_integration_connection( self, - integration_id: str = Field( + integration_id: NonBlankStr = Field( description="UUID of the integration to check. Use prowler_list_integrations to find it." ), ) -> dict[str, Any]: @@ -654,10 +626,10 @@ class IntegrationsTools(BaseTool): async def get_jira_issue_types( self, - integration_id: str = Field( + integration_id: NonBlankStr = Field( description="UUID of the Jira integration. Use prowler_list_integrations with integration_type=['jira'] to find it." ), - project_key: str = Field( + project_key: NonBlankStr = Field( description="Key of the Jira project to read the issue types from (e.g. 'PROJ'). It must be one of the keys in the 'projects' mapping of the integration configuration." ), ) -> dict[str, Any]: @@ -692,13 +664,13 @@ class IntegrationsTools(BaseTool): async def send_findings_to_jira( self, - integration_id: str = Field( + integration_id: NonBlankStr = Field( description="UUID of the Jira integration to send the findings through. It must be enabled." ), - project_key: str = Field( + project_key: NonBlankStr = Field( description="Key of the Jira project the work items are created in (e.g. 'PROJ'). It must be one of the keys in the 'projects' mapping of the integration configuration." ), - issue_type: str = Field( + issue_type: NonBlankStr = Field( description="Jira issue type for the created work items (e.g. 'Task', 'Bug', 'Story'). It must be one of the values returned by prowler_get_jira_issue_types for this project." ), finding_ids: list[str] = Field( @@ -783,20 +755,26 @@ class IntegrationsTools(BaseTool): return self._jira_dispatch_unknown( task_id=None, error=( - f"the request that starts the dispatch failed on the server: {e} " + "the request that starts the dispatch failed on Prowler's side. " "It may have been queued anyway." ), ) self.logger.error(f"Jira dispatch was rejected by Prowler: {e}") return self._jira_dispatch_rejected(str(e)) + except CredentialError: + # Authentication happens before the request goes out, so nothing was + # queued. It is raised rather than reported as a dispatch outcome: + # there is no partial state to describe, and the shared classifier + # says what has to be fixed, which no retry of this call can. + raise except Exception as e: # No answer came back, so the request may still have been accepted self.logger.error(f"Jira dispatch could not be started: {e}") return self._jira_dispatch_unknown( task_id=None, error=( - f"the request that starts the dispatch got no answer: {e} " + "the request that starts the dispatch got no answer. " "It may have been accepted anyway." ), ) @@ -866,7 +844,7 @@ class IntegrationsTools(BaseTool): normalized = normalized.removesuffix(".atlassian.net") if not normalized: - raise ValueError( + raise InvalidArgument( f"Invalid Jira domain: {domain}. Provide the Atlassian site name, for example " "'acme' for the site 'https://acme.atlassian.net'." ) @@ -890,7 +868,7 @@ class IntegrationsTools(BaseTool): if not isinstance(credentials.get(key), str) or not credentials[key].strip() ] if missing: - raise ValueError( + raise InvalidArgument( "Jira credentials are replaced as a whole, so 'domain', 'user_mail' and " f"'api_token' are all required. Missing or empty: {', '.join(missing)}. " "Sending an incomplete object would destroy the stored credentials and break " @@ -908,29 +886,33 @@ class IntegrationsTools(BaseTool): try: value = json.loads(value) except json.JSONDecodeError as e: - raise ValueError(f"Invalid JSON for {param_name}: {e}") + raise InvalidArgument(f"Invalid JSON for {param_name}: {e}") from e if not isinstance(value, dict): - raise ValueError(f"{param_name} must be a JSON object.") + raise InvalidArgument(f"{param_name} must be a JSON object.") return value async def _get_integration_raw(self, integration_id: str) -> dict[str, Any]: """Fetch the raw JSON:API resource of an integration. Raises: - ValueError: If the payload does not contain a usable integration resource + ToolError: If the payload does not contain a usable integration resource. + Raised without a ``from`` clause because these messages name the + integration and the tool that lists valid IDs, and the two cases + are reported differently: a missing resource is the caller's + mistake, a resource without attributes is the API's. """ response = await self.api_client.get(f"/integrations/{integration_id}") integration = response.get("data") if not isinstance(integration, dict) or not integration.get("id"): - raise ValueError( + raise ToolError( f"Integration {integration_id} was not found. Use prowler_list_integrations " "to get a valid integration ID." ) if not isinstance(integration.get("attributes"), dict): - raise ValueError( + raise ToolError( f"Prowler returned integration {integration_id} without its attributes, so " "its state cannot be read." ) @@ -970,7 +952,9 @@ class IntegrationsTools(BaseTool): integration_id = api_response.get("data", {}).get("id") if not integration_id: - raise ValueError( + # The integration may well exist, so this must not read as "nothing + # happened" and invite a duplicate. + raise ToolError( "Prowler accepted the integration creation but did not return its ID, so the " "connection could not be checked. Use prowler_list_integrations to see whether " "the integration exists before creating it again." @@ -981,11 +965,17 @@ class IntegrationsTools(BaseTool): try: integration = await self._get_integration_raw(integration_id) except Exception as e: - # The integration exists, so surface its ID instead of a plain read failure - raise ValueError( - f"Integration {integration_id} was created, but reading its state failed: {e} " + # The integration exists, so surface its ID instead of a plain read + # failure. No `from` clause: a cause would let the shared classifier + # replace this with a sentence that does not mention the ID. The + # failure text stays in the log, where the classifier would keep it. + self.logger.error( + f"Integration {integration_id} could not be read back: {e}" + ) + raise ToolError( + f"Integration {integration_id} was created, but reading its state failed. " "Use prowler_get_integration with that ID to check it." - ) from e + ) return IntegrationConnectionStatus.create( integration, connection_status @@ -1030,7 +1020,7 @@ class IntegrationsTools(BaseTool): return { "connected": None, "error": ( - f"The connection check could not be completed: {e} This says nothing " + "The connection check could not be completed. This says nothing " "about the stored credentials, run prowler_test_integration_connection " "to check them again." ), diff --git a/mcp_server/prowler_mcp_server/prowler_app/tools/muting.py b/mcp_server/prowler_mcp_server/prowler_app/tools/muting.py index 37e1504165..69a5f8884c 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/tools/muting.py +++ b/mcp_server/prowler_mcp_server/prowler_app/tools/muting.py @@ -8,8 +8,11 @@ This module provides tools for managing finding muting in Prowler, including: import json from typing import Any +from fastmcp.exceptions import ToolError from pydantic import Field +from prowler_mcp_server.lib.errors import InvalidArgument +from prowler_mcp_server.lib.types import NonBlankStr from prowler_mcp_server.prowler_app.models.muting import ( DetailedMuteRule, MutelistResponse, @@ -28,10 +31,31 @@ class MutingTools(BaseTool): # ===== MUTELIST TOOLS ===== + async def _get_mutelist_raw(self) -> dict[str, Any] | None: + """Return the tenant's mutelist, or None when it has none. + + Returns: + The mutelist configuration, or None when the tenant has none + """ + params = { + "filter[processor_type]": "mutelist", + "fields[processors]": "processor_type,configuration,inserted_at,updated_at", + } + + clean_params = self.api_client.build_filter_params(params) + api_response = await self.api_client.get("/processors", params=clean_params) + + data = api_response.get("data", []) + if not data: + return None + + # Only one mutelist can exist per tenant + return MutelistResponse.from_api_response(data[0]).model_dump() + async def get_mutelist(self) -> dict[str, Any]: """Retrieve the current mutelist configuration for the tenant. - IMPORTANT: Only one mutelist can exist per tenant. Returns an error message if no mutelist exists. + IMPORTANT: Only one mutelist can exist per tenant. Fails with a message saying so if no mutelist exists. For detailed information about mutelist structure and configuration, search Prowler documentation using prowler_docs_search tool available in this MCP Server. @@ -47,26 +71,15 @@ class MutingTools(BaseTool): """ self.logger.info("Retrieving mutelist configuration...") - # Query processors filtered by type=mutelist - params = { - "filter[processor_type]": "mutelist", - "fields[processors]": "processor_type,configuration,inserted_at,updated_at", - } - - clean_params = self.api_client.build_filter_params(params) - api_response = await self.api_client.get("/processors", params=clean_params) - - data = api_response.get("data", []) - - if len(data) == 0: - return { - "error": "No mutelist found", - "message": "No mutelist configuration exists for this tenant. Use prowler_set_mutelist to create one.", - } - - # Return the first (and only) mutelist - mutelist = MutelistResponse.from_api_response(data[0]) - return mutelist.model_dump() + mutelist = await self._get_mutelist_raw() + if mutelist is None: + # No `from`: this names the tool that creates one, which the shared + # classifier cannot know. + raise ToolError( + "No mutelist configuration exists for this tenant. Use " + "prowler_set_mutelist to create one." + ) + return mutelist async def set_mutelist( self, @@ -128,9 +141,9 @@ Structure: configuration = json.loads(configuration) # Check if mutelist already exists - existing_mutelist = await self.get_mutelist() + existing_mutelist = await self._get_mutelist_raw() - if "error" in existing_mutelist: + if existing_mutelist is None: # Create new mutelist self.logger.info("Creating new mutelist...") create_body = { @@ -183,21 +196,22 @@ Structure: self.logger.info("Deleting mutelist configuration...") # Get existing mutelist - existing_mutelist = await self.get_mutelist() + existing_mutelist = await self._get_mutelist_raw() - if "error" in existing_mutelist: - return { - "success": False, - "message": "No mutelist found to delete", - } + if existing_mutelist is None: + raise ToolError( + "There is no mutelist configuration to delete. Use " + "prowler_get_mutelist to confirm the current state." + ) # Delete the mutelist mutelist_id = existing_mutelist["id"] await self.api_client.delete(f"/processors/{mutelist_id}") + # No success flag: a deletion that did not happen leaves this tool as an + # error, so there is no second shape for one to distinguish. return { - "success": True, - "message": "Mutelist deleted successfully", + "message": "Mutelist deleted successfully. Findings it had muted stay muted." } # ===== MUTE RULES TOOLS ===== @@ -268,7 +282,7 @@ Structure: elif enabled.lower() == "false": params["filter[enabled]"] = False else: - raise ValueError( + raise InvalidArgument( f"Invalid enabled value: {enabled}. Valid values are True, False, 'true', 'false' or None." ) if search: @@ -282,7 +296,7 @@ Structure: async def get_mute_rule( self, - rule_id: str = Field( + rule_id: NonBlankStr = Field( description="UUID of the mute rule to retrieve. Must be a valid UUID format (e.g., '019ac0d6-90d5-73e9-9acf-c22e256f1bac')." ), ) -> dict[str, Any]: @@ -316,10 +330,10 @@ Structure: async def create_mute_rule( self, - name: str = Field( + name: NonBlankStr = Field( description="Name for the mute rule. Should be descriptive and meaningful (e.g., 'Dev S3 Public Access', 'Test Environment IMDSv1')." ), - reason: str = Field( + reason: NonBlankStr = Field( description="Reason for muting these findings. Document why this security issue is acceptable or intentional (e.g., 'Development environment with controlled access', 'Legacy application requires IMDSv1')." ), finding_ids: list[str] = Field( @@ -367,14 +381,14 @@ Structure: async def update_mute_rule( self, - rule_id: str = Field( + rule_id: NonBlankStr = Field( description="UUID of the mute rule to update. Must be a valid UUID format." ), - name: str | None = Field( + name: NonBlankStr | None = Field( default=None, description="New name for the rule. If not specified, name remains unchanged.", ), - reason: str | None = Field( + reason: NonBlankStr | None = Field( default=None, description="New reason for the rule. If not specified, reason remains unchanged.", ), @@ -435,7 +449,7 @@ Structure: async def delete_mute_rule( self, - rule_id: str = Field( + rule_id: NonBlankStr = Field( description="UUID of the mute rule to delete. Must be a valid UUID format." ), ) -> dict[str, Any]: @@ -457,15 +471,18 @@ Structure: """ self.logger.info(f"Deleting mute rule {rule_id}...") - result = await self.api_client.delete(f"/mute-rules/{rule_id}") + # A deletion that did not happen answers with an error status, which + # leaves this tool as an error. Reaching this line means Prowler accepted + # it, whether it answered 204 with no body or 200 with the deleted + # resource, so there is no second outcome to report: the previous + # "Failed to delete mute rule" fired on the shape of the answer rather + # than on anything having gone wrong, and said nothing a caller could act + # on. + await self.api_client.delete(f"/mute-rules/{rule_id}") - if result.get("success"): - return { - "success": True, - "message": "Mute rule deleted successfully", - } - else: - return { - "success": False, - "message": "Failed to delete mute rule", - } + return { + "message": ( + f"Mute rule {rule_id} deleted successfully. The findings it muted stay " + "muted." + ) + } diff --git a/mcp_server/prowler_mcp_server/prowler_app/tools/providers.py b/mcp_server/prowler_mcp_server/prowler_app/tools/providers.py index 3ba417d677..542289c652 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/tools/providers.py +++ b/mcp_server/prowler_mcp_server/prowler_app/tools/providers.py @@ -6,10 +6,14 @@ including searching, connecting, and deleting providers. from typing import Any +from fastmcp.exceptions import ToolError from pydantic import Field +from prowler_mcp_server.lib.errors import InvalidArgument +from prowler_mcp_server.lib.types import NonBlankStr from prowler_mcp_server.prowler_app.models.providers import ( ProviderConnectionStatus, + ProviderDeletionResult, ProvidersListResponse, ) from prowler_mcp_server.prowler_app.tools.base import BaseTool @@ -95,7 +99,7 @@ class ProvidersTools(BaseTool): elif connected.lower() == "false": params["filter[connected]"] = False else: - raise ValueError( + raise InvalidArgument( f"Invalid connected value: {connected}. Valid values are True, False, 'true', 'false' or None." ) @@ -128,13 +132,13 @@ class ProvidersTools(BaseTool): async def connect_provider( self, - provider_uid: str = Field( + provider_uid: NonBlankStr = Field( description="Provider's unique identifier. For supported UID provider formats, please refer to Prowler Hub/Prowler Documentation that you can also find in form of tools in this MCP Server" ), - provider_type: str = Field( + provider_type: NonBlankStr = Field( description="Type of provider to be scanned with Prowler. Valid values include: 'aws', 'azure', 'gcp', 'kubernetes'... For more valid values, please refer to Prowler Hub/Prowler Documentation that you can also find in form of tools in this MCP Server." ), - alias: str | None = Field( + alias: NonBlankStr | None = Field( default=None, description="Human-friendly name for this provider. Optional but recommended for easy identification. Use descriptive names to distinguish multiple accounts of the same type.", ), @@ -291,7 +295,7 @@ class ProvidersTools(BaseTool): async def delete_provider( self, - provider_id: str = Field( + provider_id: NonBlankStr = Field( description="Prowler's internal UUID (v4) for the provider to permanently remove, generated when the provider was registered in the system. Use `prowler_search_providers` tool to find the provider_id if you only know the alias or the provider's own identifier (provider_uid)" ), ) -> dict[str, Any]: @@ -300,33 +304,120 @@ class ProvidersTools(BaseTool): WARNING: This is a destructive operation that cannot be undone. The provider will need to be re-added with prowler_connect_provider if you want to scan it again. - The tool always returns the deletion status and message. + Prowler removes the provider and everything attached to it (its scans, findings and + resources) in a background task, so a large provider can take longer than the time + this tool waits for it. + + The result includes: + - status: 'deleted' when Prowler finished removing the provider, 'in_progress' when + the deletion was accepted and is still running + - task_id: the background task, present when the deletion was still running + + NEVER send the deletion again while status='in_progress'. Use prowler_search_providers + to check whether the provider is gone. """ self.logger.info(f"Deleting provider {provider_id}...") - try: - # Initiate the deletion task - task_response = await self.api_client.delete(f"/providers/{provider_id}") - task_id = task_response.get("data", {}).get("id") - # Poll until task completes (with 60 second timeout) + # A failure of the request itself is left to the shared classifier: the + # deletion never started, so there is no partial state to describe. + task_response = await self.api_client.delete(f"/providers/{provider_id}") + task_id = task_response.get("data", {}).get("id") + + if not task_id: + # The deletion may well be running, so this must not read as "nothing + # happened". No `from` clause: this names the provider and the tool + # that checks it, neither of which the shared classifier can know. + raise ToolError( + f"Prowler accepted the deletion of provider {provider_id} but did not " + "return the ID of the background task, so its outcome cannot be checked. " + "Use prowler_search_providers to see whether the provider is still there " + "before sending the deletion again." + ) + + try: await self.api_client.poll_task_until_complete( task_id=task_id, timeout=60, poll_interval=1.0 ) - - # If we reach here, the task completed successfully - return { - "deleted": True, - "message": f"Provider {provider_id} deleted successfully", - } except Exception as e: - self.logger.error(f"Provider deletion failed: {e}") - return { - "deleted": False, - "message": f"Provider {provider_id} deletion failed: {str(e)}", - } + self.logger.error(f"Provider deletion did not complete cleanly: {e}") + return await self._provider_deletion_fallback(provider_id, task_id) + + return ProviderDeletionResult( + status="deleted", + message=f"Provider {provider_id} deleted successfully", + ).model_dump() # Private helper methods + async def _provider_deletion_fallback( + self, provider_id: str, task_id: str + ) -> dict[str, Any]: + """Report a provider deletion whose polling did not end on a completed task. + + Running out of the polling window is not a failure: Prowler removes the + provider together with its scans, findings and resources, which outlives + 60 seconds on a large account. The deletion was accepted and is still + going, so calling it failed would be wrong twice over -- it is not, and + it invites a retry of a destructive call already in flight. + + The task is read once more here, because polling gives up on the clock + rather than on the task: a deletion that finished just after the last + poll is a finished deletion and is reported as one. + + Only a task that actually stopped is an error, and it is raised rather + than returned, because then the provider is still there. + + Raises: + ToolError: If the deletion task ended without deleting the provider. + Raised without a ``from`` clause because the message names what + was left behind, which the shared classifier cannot know. + """ + state = None + try: + task = await self.api_client.get(f"/tasks/{task_id}") + state = task.get("data", {}).get("attributes", {}).get("state") + except Exception as e: + self.logger.error(f"Could not read the state of task {task_id}: {e}") + + if state == "completed": + # The deletion outran the polling window by a moment, not by more. + return ProviderDeletionResult( + status="deleted", + message=f"Provider {provider_id} deleted successfully", + ).model_dump() + + if state in ("failed", "cancelled"): + # The failure that got us here is logged, not relayed: it carries + # upstream text, and the classifier masks exactly this kind of + # message when a tool does not write it itself. + raise ToolError( + f"The task deleting provider {provider_id} ended as '{state}', so the " + "provider was not deleted. Prowler removes a provider together with its " + "scans, findings and resources, so part of that may already be gone. Use " + "prowler_search_providers to check the current state." + ) + + if state is None: + message = ( + f"The deletion of provider {provider_id} was accepted, but its progress " + "could not be read, so whether it finished is unknown. Do not " + "send the deletion again. Use prowler_search_providers to check whether " + "the provider is gone." + ) + else: + message = ( + f"The deletion of provider {provider_id} was accepted and is still " + f"running (task state '{state}'), which is normal for a provider with " + "many scans and findings. Do not send the deletion again. Use " + "prowler_search_providers to check whether it is gone." + ) + + return ProviderDeletionResult( + status="in_progress", + task_id=task_id, + message=message, + ).model_dump() + async def _check_provider_exists(self, provider_uid: str) -> str | None: """Check if a provider already exists by its UID. @@ -357,7 +448,7 @@ class ProvidersTools(BaseTool): return prowler_provider_id else: # Multiple providers with the same UID is a data integrity issue - raise Exception( + raise ToolError( f"Data integrity error: Found {len(providers)} providers with UID '{provider_uid}'. " f"Each provider UID should be unique. Please contact support or manually clean up duplicate providers." ) @@ -392,7 +483,11 @@ class ProvidersTools(BaseTool): provider_id = await self._check_provider_exists(provider_uid) if provider_id is None: - raise Exception(f"Provider {provider_uid} creation failed") + raise ToolError( + f"Prowler accepted the creation of provider {provider_uid} but the " + "provider cannot be found afterwards. Use prowler_search_providers to " + "check whether it exists before creating it again." + ) return provider_id async def _update_provider_alias( @@ -418,7 +513,10 @@ class ProvidersTools(BaseTool): f"/providers/{prowler_provider_id}", json_data=update_body ) if result.get("data", {}).get("attributes", {}).get("alias") != alias: - raise Exception(f"Provider {prowler_provider_id} alias update failed") + raise ToolError( + f"Provider {prowler_provider_id} exists, but its alias was not updated. " + "Use prowler_search_providers to read its current alias." + ) def _determine_secret_type(self, credentials: dict[str, Any]) -> str: """Determine the secret type from credentials structure. @@ -443,29 +541,32 @@ class ProvidersTools(BaseTool): prowler_provider_id: The Prowler-generated provider ID Returns: - The secret ID if exists, None otherwise - """ - try: - response = await self.api_client.get( - "/providers/secrets", - params={"filter[provider]": prowler_provider_id}, - ) - secrets = response.get("data", []) + The secret ID if the provider has one, None if it has none - if len(secrets) > 0: - secret_id = secrets[0].get("id") - self.logger.info( - f"Found existing secret {secret_id} for provider {prowler_provider_id}" - ) - return secret_id - else: - self.logger.info( - f"No existing secret found for provider {prowler_provider_id}" - ) - return None - except Exception as e: - self.logger.error(f"Error checking for existing secret: {e}") - return None + Raises: + Exception: If the lookup itself failed, so that "no secret" is never + reported for a provider whose secret could not be read + """ + # A failure here is not swallowed into None. None means "this provider has + # no secret", which sends `_store_credentials` down the create branch, and + # a provider holds at most one secret: creating a second one is refused, + # and the caller would be told its credentials were rejected when all that + # actually failed was this read. + response = await self.api_client.get( + "/providers/secrets", + params={"filter[provider]": prowler_provider_id}, + ) + secrets = response.get("data", []) + + if len(secrets) > 0: + secret_id = secrets[0].get("id") + self.logger.info( + f"Found existing secret {secret_id} for provider {prowler_provider_id}" + ) + return secret_id + + self.logger.info(f"No existing secret found for provider {prowler_provider_id}") + return None async def _get_secret_type(self, secret_id: str) -> str | None: """Get the secret type for a given secret ID. @@ -573,13 +674,24 @@ class ProvidersTools(BaseTool): raise async def _test_connection(self, prowler_provider_id: str) -> dict[str, Any]: - """Test connection to a provider. + """Test connection to a provider and wait for the result. + + A test that could not be run is reported as 'connected: None', which + `ProviderConnectionStatus` renders as 'not_tested', rather than as a + failure. Credentials that do not work come back as a completed task + carrying 'connected: False', so an exception here never describes them: + it means this server could not get the test run at all -- an expired + Prowler credential, a rate limit, a test that outlived the timeout. + Reporting that as 'failed' would blame the provider's credentials for + something they did not cause, and send the caller off to fix a working + role. Args: prowler_provider_id: The Prowler-generated provider ID Returns: - Connection status dictionary with 'connected' boolean and optional 'error' message + Connection status dictionary with a 'connected' boolean or None, and + an optional 'error' message """ self.logger.info(f"Testing connection for provider {prowler_provider_id}...") try: @@ -589,6 +701,11 @@ class ProvidersTools(BaseTool): ) task_id = task_response.get("data", {}).get("id") + if not task_id: + raise ValueError( + "Prowler did not return the ID of the connection test task." + ) + # Poll until task completes (with 60 second timeout) completed_task = await self.api_client.poll_task_until_complete( task_id=task_id, timeout=60, poll_interval=1.0 @@ -596,13 +713,26 @@ class ProvidersTools(BaseTool): # Extract the result from the completed task task_result = ( - completed_task.get("data", {}).get("attributes", {}).get("result", {}) + completed_task.get("data", {}).get("attributes", {}).get("result") ) + if not isinstance(task_result, dict): + raise ValueError( + "The connection test task completed without reporting a result." + ) + return task_result except Exception as e: - self.logger.error(f"Connection test failed: {e}") - return {"connected": False, "error": str(e)} + self.logger.error(f"Connection test could not be completed: {e}") + return { + "connected": None, + "error": ( + "The connection test could not be completed. This says nothing " + "about the provider's credentials, they were never tested. Use " + "prowler_search_providers to read the connection state Prowler has " + "stored for this provider." + ), + } async def _get_final_provider_state( self, prowler_provider_id: str diff --git a/mcp_server/prowler_mcp_server/prowler_app/tools/resources.py b/mcp_server/prowler_mcp_server/prowler_app/tools/resources.py index 88fcca25ae..96a24fb4b2 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/tools/resources.py +++ b/mcp_server/prowler_mcp_server/prowler_app/tools/resources.py @@ -8,6 +8,7 @@ from typing import Any from pydantic import Field +from prowler_mcp_server.lib.types import NonBlankStr from prowler_mcp_server.prowler_app.models.resources import ( DetailedResource, ResourceEventsResponse, @@ -176,7 +177,7 @@ class ResourcesTools(BaseTool): async def get_resource( self, - resource_id: str = Field( + resource_id: NonBlankStr = Field( description="Prowler's internal UUID (v4) for the resource to retrieve, generated when the resource was discovered in the system. Use `prowler_list_resources` tool to find the right ID" ), ) -> dict[str, Any]: @@ -347,7 +348,7 @@ class ResourcesTools(BaseTool): async def get_resource_events( self, - resource_id: str = Field( + resource_id: NonBlankStr = Field( description="Prowler's internal UUID (v4) for the resource. Use `prowler_list_resources` to find the right ID, or get it from a finding's resource relationship via `prowler_get_finding_details`." ), lookback_days: int = Field( diff --git a/mcp_server/prowler_mcp_server/prowler_app/tools/roles.py b/mcp_server/prowler_mcp_server/prowler_app/tools/roles.py index 113694d8e8..8fc4daa20b 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/tools/roles.py +++ b/mcp_server/prowler_mcp_server/prowler_app/tools/roles.py @@ -11,8 +11,11 @@ adding to it. from typing import Any +from fastmcp.exceptions import ToolError from pydantic import Field +from prowler_mcp_server.lib.errors import ProwlerAPIError +from prowler_mcp_server.lib.types import NonBlankStr from prowler_mcp_server.prowler_app.models.roles import ( DetailedRole, RolesListResponse, @@ -70,7 +73,7 @@ class RolesTools(BaseTool): async def get_role( self, - role_id: str = Field( + role_id: NonBlankStr = Field( description="Prowler's internal UUID (v4) for the role to retrieve. Use `prowler_list_roles` to find role IDs if you only know a name." ), ) -> dict[str, Any]: @@ -98,7 +101,7 @@ class RolesTools(BaseTool): async def get_user_roles( self, - user_id: str = Field( + user_id: NonBlankStr = Field( description="Prowler's internal UUID (v4) for the user whose roles you want. Use `prowler_list_users` to find user IDs, or `prowler_get_current_user` for the caller." ), ) -> dict[str, Any]: @@ -124,10 +127,10 @@ class RolesTools(BaseTool): async def set_user_role( self, - user_id: str = Field( + user_id: NonBlankStr = Field( description="Prowler's internal UUID (v4) for the user whose role you want to set. Use `prowler_list_users` to find user IDs." ), - role_id: str = Field( + role_id: NonBlankStr = Field( description="Prowler's internal UUID (v4) for the role the user should hold. Use `prowler_list_roles` to find role IDs." ), ) -> dict[str, Any]: @@ -166,11 +169,20 @@ class RolesTools(BaseTool): # user with no role at all. Confirm the role exists before replacing. try: await self.api_client.get(f"/roles/{role_id}") - except Exception as e: - raise ValueError( - f"Role {role_id} could not be read ({e}), so user {user_id} was left " - f"unchanged. Use `prowler_list_roles` to find a valid role ID." - ) from e + except ProwlerAPIError as e: + if e.status_code != 404: + # Only a not-found says anything about the role ID. A permission + # error, a rate limit or a server error is about the request, so + # it goes to the shared classifier rather than being reported as + # an ID the caller should replace. + raise + # No `from` clause: this says what state the user was left in, which + # the shared classifier cannot know, and a cause would let it replace + # this message with its own. + raise ToolError( + f"Role {role_id} does not exist in this tenant, so user {user_id} was " + f"left unchanged. Use `prowler_list_roles` to find a valid role ID." + ) # PATCH replaces the user's whole role set with this single role, the # same call the Prowler UI makes when changing a user's role. diff --git a/mcp_server/prowler_mcp_server/prowler_app/tools/scans.py b/mcp_server/prowler_mcp_server/prowler_app/tools/scans.py index 21d1431b71..106fe69d0d 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/tools/scans.py +++ b/mcp_server/prowler_mcp_server/prowler_app/tools/scans.py @@ -5,8 +5,10 @@ This module provides tools for managing and monitoring Prowler security scans. from typing import Any, Literal +from fastmcp.exceptions import ToolError from pydantic import Field +from prowler_mcp_server.lib.types import NonBlankStr from prowler_mcp_server.prowler_app.models.scans import ( DetailedScan, ScanCreationResult, @@ -127,7 +129,7 @@ class ScansTools(BaseTool): async def get_scan( self, - scan_id: str = Field( + scan_id: NonBlankStr = Field( description="Prowler's internal UUID (v4) for the scan to retrieve, generated when the scan was created (e.g., '123e4567-e89b-12d3-a456-426614174000'). Use `prowler_list_scans` tool to find scan IDs" ), ) -> dict[str, Any]: @@ -171,10 +173,10 @@ class ScansTools(BaseTool): async def trigger_scan( self, - provider_id: str = Field( + provider_id: NonBlankStr = Field( description="Prowler's internal UUID (v4) for the provider to scan, generated when the provider was registered in the system (e.g., '4d0e2614-6385-4fa7-bf0b-c2e2f75c6877'). Use `prowler_search_providers` tool to find the provider ID" ), - name: str | None = Field( + name: NonBlankStr | None = Field( default=None, description="Optional human-friendly name for the scan. Use descriptive names to identify scan purpose or context, e.g., 'Weekly Production Security Audit', 'Pre-Deployment Validation', 'Compliance Check Q4 2025'", ), @@ -191,60 +193,70 @@ class ScansTools(BaseTool): 3. Use `prowler_get_scan` with the returned scan 'id' to monitor progress 4. Once completed, use `prowler_search_security_findings` to analyze results """ - try: - # Build request data - request_data: dict[str, Any] = { - "data": { - "type": "scans", - "attributes": {}, - "relationships": { - "provider": { - "data": { - "type": "providers", - "id": provider_id, - }, + # Build request data + request_data: dict[str, Any] = { + "data": { + "type": "scans", + "attributes": {}, + "relationships": { + "provider": { + "data": { + "type": "providers", + "id": provider_id, }, }, }, - } - if name: - request_data["data"]["attributes"]["name"] = name + }, + } + if name: + request_data["data"]["attributes"]["name"] = name - # Create scan (returns Task) - self.logger.info(f"Creating scan for provider {provider_id}") - task_response = await self.api_client.post("/scans", json_data=request_data) + # Create scan (returns Task) + self.logger.info(f"Creating scan for provider {provider_id}") + task_response = await self.api_client.post("/scans", json_data=request_data) - scan_id = ( - task_response.get("data", {}) - .get("attributes", {}) - .get("task_args", {}) - .get("scan_id", None) + scan_id = ( + task_response.get("data", {}) + .get("attributes", {}) + .get("task_args", {}) + .get("scan_id", None) + ) + + if not scan_id: + # The scan may well have been queued, so this must not read as + # "nothing happened" and invite a duplicate run. No `from` clause: + # this names the provider and the tool that checks for the scan, + # neither of which the shared classifier can know. + raise ToolError( + "Prowler accepted the scan but did not return its ID, so it " + "cannot be looked up. Use prowler_list_scans for provider " + f"{provider_id} to see whether a scan is already running before " + "triggering another one." ) - if not scan_id: - raise Exception("No scan_id returned from scan creation") - - self.logger.info(f"Scan created successfully: {scan_id}") + # The scan exists from here on, so a failure to read it back must name + # the ID rather than read as "the scan was not created". + try: scan_response = await self.api_client.get(f"/scans/{scan_id}") scan_info = DetailedScan.from_api_response(scan_response["data"]) - - return ScanCreationResult( - scan=scan_info, - status="success", - message=f"Scan {scan_id} created successfully. The scan may take some time to complete. Use prowler_get_scan tool with this ID to monitor progress.", - ).model_dump() - except Exception as e: - self.logger.error(f"Scan creation failed: {e}") - return ScanCreationResult( - scan=None, - status="failed", - message=f"Scan creation failed: {str(e)}", - ).model_dump() + # The failure itself is logged, not relayed: what it says is the + # shared classifier's to mask, and what the caller needs is the ID. + self.logger.error(f"Scan {scan_id} could not be read back: {e}") + raise ToolError( + f"Scan {scan_id} was created for provider {provider_id}, but reading " + "its state failed. Use prowler_get_scan with that ID to monitor " + "it. Do not trigger the scan again." + ) + + return ScanCreationResult( + scan=scan_info, + message=f"Scan {scan_id} created successfully. The scan may take some time to complete. Use prowler_get_scan tool with this ID to monitor progress.", + ).model_dump() async def schedule_daily_scan( self, - provider_id: str = Field( + provider_id: NonBlankStr = Field( description="Prowler's internal UUID (v4) for the provider to scan, generated when the provider was registered in the system (e.g., '4d0e2614-6385-4fa7-bf0b-c2e2f75c6877'). Use `prowler_search_providers` tool to find the provider ID" ), ) -> dict[str, Any]: @@ -280,26 +292,49 @@ class ScansTools(BaseTool): }, }, ) - task_state = ( + + # Reaching this line means the schedule exists. Prowler commits the + # recurring schedule and its first scan inside the transaction that + # serves this request, so an answer at all means it was created; a + # provider that already has one is refused with a 409 instead, which + # leaves this tool as an error. + # + # The task in the answer is the FIRST scan run, queued to start a few + # seconds later, not the schedule. Its state therefore says nothing + # about whether the schedule was created, and reporting it as the + # outcome would call a schedule that exists a failure and invite a + # retry that can only hit that 409. + first_run_state = ( task_response.get("data", {}).get("attributes", {}).get("state", None) ) - if task_state == "available": - return_message = "Daily schedule created successfully. The schedule is being set up in the background. Use prowler_list_scans with provider_id filter to view scheduled scans." - else: - return_message = "Daily schedule creation failed. Please try again later." + message = ( + f"Daily schedule created for provider {provider_id}. Prowler will scan it " + "every 24 hours until the provider is deleted. Use prowler_list_scans with " + "this provider_id and trigger='scheduled' to view its scheduled scans." + ) + + if first_run_state in ("failed", "cancelled"): + # Worth saying: the schedule stands, but the run that was supposed to + # start now will not produce findings, and only a manual scan fills + # the gap before tomorrow. + message = ( + f"{message} Note that the first scan, which Prowler starts immediately, " + f"ended as '{first_run_state}'. The daily schedule is unaffected, but " + "use prowler_trigger_scan if you need results before the next run." + ) return ScheduleCreationResult( - scheduled=(task_state == "available"), - message=return_message, + first_run_state=first_run_state, + message=message, ).model_dump() async def update_scan( self, - scan_id: str = Field( + scan_id: NonBlankStr = Field( description="Prowler's internal UUID (v4) for the scan to update, generated when the scan was created (e.g., '123e4567-e89b-12d3-a456-426614174000'). Use `prowler_list_scans` tool to find the scan ID if you only know the provider or scan name. Returns an error if the scan ID is invalid or not found." ), - name: str = Field( + name: NonBlankStr = Field( description="New human-friendly name for the scan (3-100 characters). Use descriptive names to improve organization and tracking, e.g., 'Production Security Audit - Q4 2025', 'Post-Deployment Compliance Check'. IMPORTANT: Only the scan name can be updated - other attributes (state, progress, duration) are read-only and managed by the system." ), ) -> dict[str, Any]: diff --git a/mcp_server/prowler_mcp_server/prowler_app/tools/users.py b/mcp_server/prowler_mcp_server/prowler_app/tools/users.py index a7e31b60ad..f9b71ccd84 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/tools/users.py +++ b/mcp_server/prowler_mcp_server/prowler_app/tools/users.py @@ -9,6 +9,7 @@ from typing import Any from pydantic import Field +from prowler_mcp_server.lib.types import NonBlankStr from prowler_mcp_server.prowler_app.models.users import ( DetailedUser, UsersListResponse, @@ -79,7 +80,7 @@ class UsersTools(BaseTool): async def get_user( self, - user_id: str = Field( + user_id: NonBlankStr = Field( description="Prowler's internal UUID (v4) for the user to retrieve. Use `prowler_list_users` to find user IDs if you only know a name or email." ), ) -> dict[str, Any]: diff --git a/mcp_server/prowler_mcp_server/prowler_app/utils/api_client.py b/mcp_server/prowler_mcp_server/prowler_app/utils/api_client.py index 217496a347..cb4fbd87e8 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/utils/api_client.py +++ b/mcp_server/prowler_mcp_server/prowler_app/utils/api_client.py @@ -118,7 +118,21 @@ class ProwlerAPIClient(metaclass=SingletonMeta): if detail: message = f"{message} - {detail}" - raise ProwlerAPIError(message, status, detail=detail) from e + # Carried on the exception, not into the message: a tool needs the + # body to tell an answer with an error status -- a 404 holding the + # empty result of a query that matched nothing -- apart from a + # request that actually failed. + try: + body = e.response.json() + except ValueError: + body = None + + raise ProwlerAPIError( + message, + status, + detail=detail, + payload=body if isinstance(body, dict) else None, + ) from e except httpx.RequestError as e: # No answer came back, so whether the request was applied is unknown. logger.error(f"Error during {method.value} {path}: {e}") diff --git a/mcp_server/prowler_mcp_server/prowler_app/utils/auth.py b/mcp_server/prowler_mcp_server/prowler_app/utils/auth.py index eff5d3a117..1af63920ec 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/utils/auth.py +++ b/mcp_server/prowler_mcp_server/prowler_app/utils/auth.py @@ -6,6 +6,7 @@ from datetime import datetime from fastmcp.server.dependencies import get_http_headers from prowler_mcp_server import __version__ +from prowler_mcp_server.lib.errors import CredentialError from prowler_mcp_server.lib.logger import logger @@ -64,7 +65,12 @@ class ProwlerAppAuth: # Decode and parse JSON decoded = base64.b64decode(base64_payload).decode("utf-8") - return json.loads(decoded) + payload = json.loads(decoded) + + # A JWT payload is a JSON object. A list or a scalar decodes just as + # cleanly, so the type is checked here rather than left to blow up as + # an AttributeError on the first claim read. + return payload if isinstance(payload, dict) else None except Exception as e: logger.warning(f"Failed to parse JWT token: {e}") return None @@ -76,14 +82,16 @@ class ProwlerAppAuth: authorization_header = headers.get("authorization", None) if not authorization_header: - raise ValueError("No authorization header provided") + raise CredentialError("No Authorization header was sent") - # Extract token from Bearer header - if authorization_header.startswith("Bearer "): - token = authorization_header.replace("Bearer ", "") - else: - raise ValueError( - "Invalid authorization header format. Expected 'Bearer '" + # Extract token from Bearer header. Authentication scheme names are + # case-insensitive (RFC 7235), and only the scheme prefix is removed: + # a token that happens to contain the word again keeps it. + scheme, _, credential = authorization_header.partition(" ") + token = credential.strip() + if scheme.lower() != "bearer" or not token: + raise CredentialError( + "The Authorization header is not in 'Bearer ' form" ) # Check if it's an API key or JWT token @@ -94,17 +102,29 @@ class ProwlerAppAuth: # JWT token - validate and check expiration payload = self._parse_jwt(token) if not payload: - raise ValueError("Invalid JWT token format") + raise CredentialError("The token is not a readable JWT") + + # Check if token is expired. `exp` is a numeric date in the + # spec, so a missing or non-numeric one makes the token + # unusable rather than merely stale -- comparing it would raise + # a TypeError and leave the failure masked as unclassified. + exp = payload.get("exp") + if isinstance(exp, bool) or not isinstance(exp, (int, float)): + raise CredentialError( + "The token carries no readable 'exp' expiration claim" + ) - # Check if token is expired now = int(datetime.now().timestamp()) - exp = payload.get("exp", 0) if exp <= now: - raise ValueError("Token has expired") + raise CredentialError("The token has expired") return token else: - raise ValueError(f"Invalid mode: {self.mode}") + # PROWLER_MCP_TRANSPORT_MODE holds something this server does not + # support. Nothing about a call caused it and nothing about a call + # can fix it, so it stays unclassified: masked for the model, logged + # for whoever runs the server. + raise RuntimeError(f"Invalid mode: {self.mode}") async def get_valid_token(self) -> str: """Get a valid token (API key or JWT token).""" diff --git a/mcp_server/prowler_mcp_server/prowler_documentation/search_engine.py b/mcp_server/prowler_mcp_server/prowler_documentation/search_engine.py index 1eb1d10820..990a225239 100644 --- a/mcp_server/prowler_mcp_server/prowler_documentation/search_engine.py +++ b/mcp_server/prowler_mcp_server/prowler_documentation/search_engine.py @@ -2,6 +2,7 @@ import httpx from pydantic import BaseModel, Field from prowler_mcp_server import __version__ +from prowler_mcp_server.lib.errors import parse_json_response class SearchResult(BaseModel): @@ -58,8 +59,7 @@ class ProwlerDocsSearchEngine: ) def search(self, query: str, page_size: int = 5) -> list[SearchResult]: - """ - Search documentation using Mintlify API. + """Search documentation using Mintlify API. Args: query: Search query string @@ -69,82 +69,85 @@ class ProwlerDocsSearchEngine: Returns: list of search results + + Raises: + httpx.HTTPError: If the search request failed, which is not the same + answer as no matches + UpstreamInvalidResponse: If the answer is not JSON, which is the + documentation site's fault and not the search term's """ - try: - # Make request to Mintlify API - response = self.mintlify_client.post( - self.api_base_url, - json={"query": query, "filters": {}}, - ) - response.raise_for_status() - data = response.json() + # Make request to Mintlify API + response = self.mintlify_client.post( + self.api_base_url, + json={"query": query, "filters": {}}, + ) + response.raise_for_status() + # Not `response.json()`: the decode error it raises is a ValueError, which + # the shared classifier reads as a malformed argument and answers by + # telling the caller to fix a search term that was never the problem. + data = parse_json_response(response) - # Parse results - results = [] - for match in data.get("results", [])[:page_size]: - metadata = match.get("metadata", {}) - breadcrumbs = metadata.get("breadcrumbs", []) - doc_path = match.get("page", "") + # Parse results + results = [] + for match in data.get("results", [])[:page_size]: + metadata = match.get("metadata", {}) + breadcrumbs = metadata.get("breadcrumbs", []) + doc_path = match.get("page", "") - # A match is one section of a page rather than the page: the - # heading it was found under is its header, and the page's own - # title is the last step of its breadcrumb trail. - section = match.get("header", "") - title = breadcrumbs[-1] if breadcrumbs else section + # A match is one section of a page rather than the page: the + # heading it was found under is its header, and the page's own + # title is the last step of its breadcrumb trail. + section = match.get("header", "") + title = breadcrumbs[-1] if breadcrumbs else section - # Sent as "" for the section a page opens with and as null for - # the pages that have no anchors at all; both mean the page. - anchor = metadata.get("hash") - url = f"{self.docs_base_url}/{doc_path}" - if anchor: - url = f"{url}#{anchor}" + # Sent as "" for the section a page opens with and as null for + # the pages that have no anchors at all; both mean the page. + anchor = metadata.get("hash") + url = f"{self.docs_base_url}/{doc_path}" + if anchor: + url = f"{url}#{anchor}" - results.append( - SearchResult( - path=doc_path, - title=title, - section=section, - breadcrumbs=breadcrumbs, - url=url, - excerpt=match.get("content", ""), - score=match.get("score", 0.0), - ) + results.append( + SearchResult( + path=doc_path, + title=title, + section=section, + breadcrumbs=breadcrumbs, + url=url, + excerpt=match.get("content", ""), + score=match.get("score", 0.0), ) + ) - return results - - except Exception as e: - # Return empty list on error - print(f"Search error: {e}") - return [] + return results def get_document(self, doc_path: str) -> str | None: - """ - Get full document content from Mintlify documentation. + """Get full document content from Mintlify documentation. Args: doc_path: Path to the documentation file (e.g., "getting-started/installation") Returns: - Full markdown content of the documentation, or None if not found + Full markdown content of the documentation, or None if there is no + page at that path + + Raises: + httpx.HTTPError: If the fetch failed for any reason other than a 404 """ - try: - # Clean up the path - doc_path = doc_path.rstrip("/") + # Clean up the path + doc_path = doc_path.rstrip("/") - # Add .md extension if not present (Mintlify serves both .md and .mdx) - if not doc_path.endswith(".md"): - doc_path = f"{doc_path}.md" + # Add .md extension if not present (Mintlify serves both .md and .mdx) + if not doc_path.endswith(".md"): + doc_path = f"{doc_path}.md" - # Construct Mintlify URL - url = f"{self.docs_base_url}/{doc_path}" + # Construct Mintlify URL + url = f"{self.docs_base_url}/{doc_path}" - # Fetch the documentation page - response = self.docs_client.get(url) - response.raise_for_status() - - return response.text - - except Exception as e: - print(f"Error fetching document: {e}") + # Fetch the documentation page + response = self.docs_client.get(url) + if response.status_code == 404: return None + response.raise_for_status() + + return response.text diff --git a/mcp_server/prowler_mcp_server/prowler_documentation/server.py b/mcp_server/prowler_mcp_server/prowler_documentation/server.py index 9588302168..1121d7c419 100644 --- a/mcp_server/prowler_mcp_server/prowler_documentation/server.py +++ b/mcp_server/prowler_mcp_server/prowler_documentation/server.py @@ -1,20 +1,24 @@ from typing import Any from fastmcp import FastMCP +from fastmcp.exceptions import ToolError from pydantic import Field +from prowler_mcp_server.lib.types import NonBlankStr from prowler_mcp_server.prowler_documentation.search_engine import ( ProwlerDocsSearchEngine, ) # Initialize FastMCP server -docs_mcp_server = FastMCP("prowler-docs") +docs_mcp_server = FastMCP("prowler-docs", mask_error_details=True) prowler_docs_search_engine = ProwlerDocsSearchEngine() @docs_mcp_server.tool() def search( - term: str = Field(description="The term to search for in the documentation"), + term: NonBlankStr = Field( + description="The term to search for in the documentation" + ), page_size: int = Field( 5, description="Number of top results to return. It must be between 1 and 20.", @@ -39,7 +43,7 @@ def search( @docs_mcp_server.tool() def get_document( - doc_path: str = Field( + doc_path: NonBlankStr = Field( description="Path to the documentation file to retrieve. It is the same as the 'path' field of the search results. Use `prowler_docs_search` to find the path first." ), ) -> dict[str, str]: @@ -53,6 +57,10 @@ def get_document( """ content: str | None = prowler_docs_search_engine.get_document(doc_path) if content is None: - return {"error": f"Document '{doc_path}' not found."} - else: - return {"content": content} + # No `from`: this names the path asked for and the tool that produces a + # valid one, neither of which the shared classifier can know. + raise ToolError( + f"The Prowler documentation has no page at '{doc_path}'. Use " + "prowler_docs_search and pass the 'path' field of a result verbatim." + ) + return {"content": content} diff --git a/mcp_server/prowler_mcp_server/prowler_hub/server.py b/mcp_server/prowler_mcp_server/prowler_hub/server.py index 41e83eca90..2c78ac7c36 100644 --- a/mcp_server/prowler_mcp_server/prowler_hub/server.py +++ b/mcp_server/prowler_mcp_server/prowler_hub/server.py @@ -6,12 +6,19 @@ Provides access to Prowler Hub API for security checks and compliance frameworks import httpx from fastmcp import FastMCP +from fastmcp.exceptions import ToolError from pydantic import Field from prowler_mcp_server import __version__ +from prowler_mcp_server.lib.errors import ( + UpstreamInvalidResponse, + parse_json_response, +) +from prowler_mcp_server.lib.types import NonBlankStr +from prowler_mcp_server.lib.urls import url_path # Initialize FastMCP for Prowler Hub -hub_mcp_server = FastMCP("prowler-hub") +hub_mcp_server = FastMCP("prowler-hub", mask_error_details=True) # API base URL BASE_URL = "https://hub.prowler.com/api" @@ -26,6 +33,19 @@ prowler_hub_client = httpx.Client( }, ) +# Sentences for the not-found cases. They are authored here, and raised as a +# ToolError without a `from` clause, because they name the resource the caller +# asked for and the next tool to reach for -- neither of which the shared +# classifier in lib/errors.py can know. +_CHECK_NOT_FOUND = ( + "No check with the ID '{check_id}' exists in Prowler Hub. Use " + "prowler_hub_semantic_search_checks to find the right ID." +) +_COMPLIANCE_NOT_FOUND = ( + "No compliance framework with the ID '{compliance_id}' exists in Prowler Hub. " + "Use prowler_hub_semantic_search_compliances to find the right ID." +) + # GitHub raw content base URL for Prowler checks GITHUB_RAW_BASE = ( "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/heads/master/" @@ -42,6 +62,21 @@ github_raw_client = httpx.Client( ) +def _get_hub_endpoint( + *path_segments: str, params: dict[str, str] | None = None +) -> httpx.Response: + """GET a Prowler Hub endpoint, named as one argument per path segment. + + Args: + *path_segments: The endpoint path segments, in order. + params: Query parameters for the request. + + Returns: + The response unread, so a caller can tell a 404 from a failed request. + """ + return prowler_hub_client.get(url_path(*path_segments), params=params) + + def github_check_path(provider_id: str, check_id: str, suffix: str) -> str: """Build the GitHub raw URL for a given check artifact suffix using provider and check_id. @@ -52,7 +87,83 @@ def github_check_path(provider_id: str, check_id: str, suffix: str) -> str: service_id = check_id.split("_", 1)[0] except IndexError: service_id = check_id - return f"{GITHUB_RAW_BASE}/{provider_id}/services/{service_id}/{check_id}/{check_id}{suffix}" + path = url_path(provider_id, "services", service_id, check_id, check_id) + return f"{GITHUB_RAW_BASE}{path}{suffix}" + + +def _hub_provider_for_check(check_id: str) -> str | None: + """Ask Prowler Hub which provider it lists a check under. + + Args: + check_id: Check ID the caller asked for + + Returns: + The provider the Hub lists the check under, or None when the Hub knows + no such check. + + Raises: + httpx.HTTPError: The Hub could not be reached. + UpstreamInvalidResponse: The Hub answered with a body that is not JSON. + ValueError: The Hub answered with something that names no provider. + """ + response = _get_hub_endpoint("check", check_id) + if response.status_code == 404: + return None + response.raise_for_status() + check = parse_json_response(response) + + # An empty body is how the Hub reports an unknown ID on some routes, so it + # is read the same way get_check_details reads it: no such check. + if not isinstance(check, dict) or not check: + return None + + provider = check.get("provider") + if isinstance(provider, str) and provider.strip(): + return provider + # A check the Hub returned without a provider tells us nothing about the + # provider the caller asked for, so it counts as unanswered rather than as + # a check that does not exist. + raise ValueError(f"Prowler Hub listed check '{check_id}' without a provider") + + +def _explain_missing_check_file( + provider_id: str, + check_id: str, + *, + when_check_belongs_here: str, + when_unverified: str, +) -> str: + """Explain a 404 from GitHub for one of a check's source files. + + GitHub answers 404 to three different mistakes, an ID that exists nowhere, + an ID that exists under a different provider, and an ID that exists right + here whose file is simply absent, and cannot tell them apart. Prowler Hub + can, so it is asked before anything is claimed about the ID. + + Args: + provider_id: Provider the caller asked for + check_id: Check the caller asked for + when_check_belongs_here: Message for the case where the Hub confirms the + check does belong to this provider + when_unverified: Message for the case where the Hub could not be asked + + Returns: + The sentence to fail the tool with + """ + try: + hub_provider = _hub_provider_for_check(check_id) + except (httpx.HTTPError, UpstreamInvalidResponse, ValueError): + return when_unverified + + if hub_provider is None: + return _CHECK_NOT_FOUND.format(check_id=check_id) + if hub_provider != provider_id: + return ( + f"Provider '{provider_id}' has no check '{check_id}'. Prowler Hub lists " + f"that check under provider '{hub_provider}', so retry with " + f"provider_id='{hub_provider}'." + ) + return when_check_belongs_here # Security Check Tools @@ -122,34 +233,27 @@ async def list_checks( if compliances: params["compliances"] = ",".join(compliances) - try: - response = prowler_hub_client.get("/check", params=params) - response.raise_for_status() - checks = response.json() + response = _get_hub_endpoint("check", params=params) + response.raise_for_status() + checks = parse_json_response(response) - # Return checks as a lightweight list - checks_list = [] - for check in checks: - check_data = { - "id": check["id"], - "provider": check["provider"], - "title": check["title"], - "severity": check["severity"], - } - checks_list.append(check_data) - - return {"count": len(checks), "checks": checks_list} - except httpx.HTTPStatusError as e: - return { - "error": f"HTTP error {e.response.status_code}: {e.response.text}", + # Return checks as a lightweight list + checks_list = [] + for check in checks: + check_data = { + "id": check["id"], + "provider": check["provider"], + "title": check["title"], + "severity": check["severity"], } - except Exception as e: - return {"error": str(e)} + checks_list.append(check_data) + + return {"count": len(checks), "checks": checks_list} @hub_mcp_server.tool() async def semantic_search_checks( - term: str = Field( + term: NonBlankStr = Field( description="Search term. Examples: 'public access', 'encryption', 'MFA', 'logging'.", ), ) -> dict: @@ -181,34 +285,27 @@ async def semantic_search_checks( 2. Use `prowler_hub_list_checks` with filters for more targeted browsing 3. Use `prowler_hub_get_check_details` to get complete information for a specific check """ - try: - response = prowler_hub_client.get("/check/search", params={"term": term}) - response.raise_for_status() - checks = response.json() + response = _get_hub_endpoint("check", "search", params={"term": term}) + response.raise_for_status() + checks = parse_json_response(response) - # Return checks as a lightweight list - checks_list = [] - for check in checks: - check_data = { - "id": check["id"], - "provider": check["provider"], - "title": check["title"], - "severity": check["severity"], - } - checks_list.append(check_data) - - return {"count": len(checks), "checks": checks_list} - except httpx.HTTPStatusError as e: - return { - "error": f"HTTP error {e.response.status_code}: {e.response.text}", + # Return checks as a lightweight list + checks_list = [] + for check in checks: + check_data = { + "id": check["id"], + "provider": check["provider"], + "title": check["title"], + "severity": check["severity"], } - except Exception as e: - return {"error": str(e)} + checks_list.append(check_data) + + return {"count": len(checks), "checks": checks_list} @hub_mcp_server.tool() async def get_check_details( - check_id: str = Field( + check_id: NonBlankStr = Field( description="The check ID to retrieve details for. Example: 's3_bucket_level_public_access_block'" ), ) -> dict: @@ -273,83 +370,83 @@ async def get_check_details( 2. Use this tool with the check 'id' to get complete information including remediation guidance """ try: - response = prowler_hub_client.get(f"/check/{check_id}") + response = _get_hub_endpoint("check", check_id) response.raise_for_status() - check = response.json() - - if not check: - return {"error": f"Check '{check_id}' not found"} - - # Build response with only non-empty fields to save tokens - result = {} - - # Core fields - result["id"] = check["id"] - if check.get("title"): - result["title"] = check["title"] - if check.get("description"): - result["description"] = check["description"] - if check.get("provider"): - result["provider"] = check["provider"] - if check.get("service"): - result["service"] = check["service"] - if check.get("severity"): - result["severity"] = check["severity"] - if check.get("risk"): - result["risk"] = check["risk"] - if check.get("resource_type"): - result["resource_type"] = check["resource_type"] - - # List fields - if check.get("reference"): - result["reference"] = check["reference"] - if check.get("additional_urls"): - result["additional_urls"] = check["additional_urls"] - if check.get("services_required"): - result["services_required"] = check["services_required"] - if check.get("categories"): - result["categories"] = check["categories"] - if check.get("compliances"): - result["compliances"] = check["compliances"] - - # Other fields - if check.get("notes"): - result["notes"] = check["notes"] - if check.get("related_url"): - result["related_url"] = check["related_url"] - if check.get("fixer") is not None: - result["fixer"] = check["fixer"] - - # Remediation - filter out empty nested values - remediation = check.get("remediation", {}) - if remediation: - filtered_remediation = {} - for key, value in remediation.items(): - if value and isinstance(value, dict): - # Filter out empty values within nested dict - filtered_value = {k: v for k, v in value.items() if v} - if filtered_value: - filtered_remediation[key] = filtered_value - elif value: - filtered_remediation[key] = value - if filtered_remediation: - result["remediation"] = filtered_remediation - - return result except httpx.HTTPStatusError as e: - return { - "error": f"HTTP error {e.response.status_code}: {e.response.text}", - } - except Exception as e: - return {"error": str(e)} + if e.response.status_code == 404: + # No `from`: this names the check, which the shared classifier cannot. + raise ToolError(_CHECK_NOT_FOUND.format(check_id=check_id)) + raise + + check = parse_json_response(response) + + if not check: + raise ToolError(_CHECK_NOT_FOUND.format(check_id=check_id)) + + # Build response with only non-empty fields to save tokens + result = {} + + # Core fields + result["id"] = check["id"] + if check.get("title"): + result["title"] = check["title"] + if check.get("description"): + result["description"] = check["description"] + if check.get("provider"): + result["provider"] = check["provider"] + if check.get("service"): + result["service"] = check["service"] + if check.get("severity"): + result["severity"] = check["severity"] + if check.get("risk"): + result["risk"] = check["risk"] + if check.get("resource_type"): + result["resource_type"] = check["resource_type"] + + # List fields + if check.get("reference"): + result["reference"] = check["reference"] + if check.get("additional_urls"): + result["additional_urls"] = check["additional_urls"] + if check.get("services_required"): + result["services_required"] = check["services_required"] + if check.get("categories"): + result["categories"] = check["categories"] + if check.get("compliances"): + result["compliances"] = check["compliances"] + + # Other fields + if check.get("notes"): + result["notes"] = check["notes"] + if check.get("related_url"): + result["related_url"] = check["related_url"] + if check.get("fixer") is not None: + result["fixer"] = check["fixer"] + + # Remediation - filter out empty nested values + remediation = check.get("remediation", {}) + if remediation: + filtered_remediation = {} + for key, value in remediation.items(): + if value and isinstance(value, dict): + # Filter out empty values within nested dict + filtered_value = {k: v for k, v in value.items() if v} + if filtered_value: + filtered_remediation[key] = filtered_value + elif value: + filtered_remediation[key] = value + if filtered_remediation: + result["remediation"] = filtered_remediation + + return result @hub_mcp_server.tool() async def get_check_code( - provider_id: str = Field( + provider_id: NonBlankStr = Field( description="Prowler Provider ID. Example: 'aws', 'azure', 'gcp', 'kubernetes'. Use `prowler_hub_list_providers` to get available provider IDs.", ), - check_id: str = Field( + check_id: NonBlankStr = Field( description="The check ID. Example: 's3_bucket_public_access'. Get IDs from `prowler_hub_list_checks` or `prowler_hub_search_checks`.", ), ) -> dict: @@ -363,46 +460,54 @@ async def get_check_code( "content": "Python source code of the check implementation" } """ - if provider_id and check_id: - url = github_check_path(provider_id, check_id, ".py") - try: - resp = github_raw_client.get(url) - resp.raise_for_status() - return { - "content": resp.text, - } - except httpx.HTTPStatusError as e: - if e.response.status_code == 404: - return { - "error": f"Check {check_id} not found in Prowler", - } - else: - return { - "error": f"HTTP error {e.response.status_code}: {e.response.text}", - } - except Exception as e: - return { - "error": str(e), - } - else: - return { - "error": "Provider ID and check ID are required", - } + url = github_check_path(provider_id, check_id, ".py") + try: + resp = github_raw_client.get(url) + resp.raise_for_status() + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + # No `from`: this names the check and the provider that does have + # it, neither of which the shared classifier in lib/errors.py knows. + raise ToolError( + _explain_missing_check_file( + provider_id, + check_id, + when_check_belongs_here=( + f"Prowler Hub lists check '{check_id}' under provider " + f"'{provider_id}', but prowler-cloud/prowler has no source file " + "for it on the master branch. The check may have been renamed or " + "moved since the Hub last indexed it." + ), + when_unverified=( + f"Provider '{provider_id}' has no check '{check_id}' in " + "prowler-cloud/prowler, and Prowler Hub could not be asked which " + "provider does. Either the ID is wrong or the check belongs to " + "another provider, prowler_hub_get_check_details reports the " + "provider a check belongs to." + ), + ) + ) + raise + + return { + "content": resp.text, + } @hub_mcp_server.tool() async def get_check_fixer( - provider_id: str = Field( + provider_id: NonBlankStr = Field( description="Prowler Provider ID. Example: 'aws', 'azure', 'gcp', 'kubernetes'. Use `prowler_hub_list_providers` to get available provider IDs.", ), - check_id: str = Field( + check_id: NonBlankStr = Field( description="The check ID. Example: 's3_bucket_public_access'. Get IDs from `prowler_hub_list_checks` or `prowler_hub_search_checks`.", ), ) -> dict: """Fetch the auto-remediation (fixer) code for a Prowler security check. - IMPORTANT: Not all checks have fixers. A "fixer not found" response means the check - doesn't have auto-remediation code - this is normal for many checks. + IMPORTANT: Not all checks have fixers. A check with no auto-remediation code fails + with a message saying so - this is normal for many checks and not a problem to + report or retry. Fixer code provides automated remediation that can fix security issues detected by checks. Use this to understand how to programmatically remediate findings. @@ -411,40 +516,37 @@ async def get_check_fixer( { "content": "Python source code of the auto-remediation implementation" } - Or if no fixer exists: - { - "error": "Fixer not found for check {check_id}" - } """ - if provider_id and check_id: - url = github_check_path(provider_id, check_id, "_fixer.py") - try: - resp = github_raw_client.get(url) - if resp.status_code == 404: - return { - "error": f"Fixer not found for check {check_id}", - } - resp.raise_for_status() - return { - "content": resp.text, - } - except httpx.HTTPStatusError as e: - if e.response.status_code == 404: - return { - "error": f"Check {check_id} not found in Prowler", - } - else: - return { - "error": f"HTTP error {e.response.status_code}: {e.response.text}", - } - except Exception as e: - return { - "error": str(e), - } - else: - return { - "error": "Provider ID and check ID are required", - } + url = github_check_path(provider_id, check_id, "_fixer.py") + try: + resp = github_raw_client.get(url) + resp.raise_for_status() + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + # "No fixer" is only one of the reasons the file is missing, and the + # others are the caller's to fix, so they are told apart first. + raise ToolError( + _explain_missing_check_file( + provider_id, + check_id, + when_check_belongs_here=( + f"Check {check_id} has no auto-remediation code. Many checks do " + "not, and that is normal." + ), + when_unverified=( + f"Provider '{provider_id}' has no auto-remediation code for " + f"check '{check_id}'. Many checks have none, and that is normal, " + f"but Prowler Hub could not be asked whether the check belongs " + f"to '{provider_id}' at all. Confirm it with " + "prowler_hub_get_check_details if you expected a fixer." + ), + ) + ) + raise + + return { + "content": resp.text, + } # Compliance Framework Tools @@ -491,33 +593,26 @@ async def list_compliances( if provider: params["provider"] = ",".join(provider) - try: - response = prowler_hub_client.get("/compliance", params=params) - response.raise_for_status() - compliances = response.json() + response = _get_hub_endpoint("compliance", params=params) + response.raise_for_status() + compliances = parse_json_response(response) - # Return compliances as a lightweight list - compliances_list = [] - for compliance in compliances: - compliance_data = { - "id": compliance["id"], - "name": compliance["name"], - "provider": compliance["provider"], - } - compliances_list.append(compliance_data) - - return {"count": len(compliances), "compliances": compliances_list} - except httpx.HTTPStatusError as e: - return { - "error": f"HTTP error {e.response.status_code}: {e.response.text}", + # Return compliances as a lightweight list + compliances_list = [] + for compliance in compliances: + compliance_data = { + "id": compliance["id"], + "name": compliance["name"], + "provider": compliance["provider"], } - except Exception as e: - return {"error": str(e)} + compliances_list.append(compliance_data) + + return {"count": len(compliances), "compliances": compliances_list} @hub_mcp_server.tool() async def semantic_search_compliances( - term: str = Field( + term: NonBlankStr = Field( description="Search term. Examples: 'CIS', 'HIPAA', 'PCI', 'GDPR', 'SOC2', 'NIST'.", ), ) -> dict: @@ -542,33 +637,26 @@ async def semantic_search_compliances( ] } """ - try: - response = prowler_hub_client.get("/compliance/search", params={"term": term}) - response.raise_for_status() - compliances = response.json() + response = _get_hub_endpoint("compliance", "search", params={"term": term}) + response.raise_for_status() + compliances = parse_json_response(response) - # Return compliances as a lightweight list - compliances_list = [] - for compliance in compliances: - compliance_data = { - "id": compliance["id"], - "name": compliance["name"], - "provider": compliance["provider"], - } - compliances_list.append(compliance_data) - - return {"count": len(compliances), "compliances": compliances_list} - except httpx.HTTPStatusError as e: - return { - "error": f"HTTP error {e.response.status_code}: {e.response.text}", + # Return compliances as a lightweight list + compliances_list = [] + for compliance in compliances: + compliance_data = { + "id": compliance["id"], + "name": compliance["name"], + "provider": compliance["provider"], } - except Exception as e: - return {"error": str(e)} + compliances_list.append(compliance_data) + + return {"count": len(compliances), "compliances": compliances_list} @hub_mcp_server.tool() async def get_compliance_details( - compliance_id: str = Field( + compliance_id: NonBlankStr = Field( description="The compliance framework ID to retrieve details for. Example: 'cis_4.0_aws'. Use `prowler_hub_list_compliances` or `prowler_hub_semantic_search_compliances` to find available compliance IDs.", ), ) -> dict: @@ -598,63 +686,60 @@ async def get_compliance_details( } """ try: - response = prowler_hub_client.get(f"/compliance/{compliance_id}") + response = _get_hub_endpoint("compliance", compliance_id) response.raise_for_status() - compliance = response.json() - - if not compliance: - return {"error": f"Compliance '{compliance_id}' not found"} - - # Build response with only non-empty fields to save tokens - result = {} - - # Core fields - result["id"] = compliance["id"] - if compliance.get("name"): - result["name"] = compliance["name"] - if compliance.get("framework"): - result["framework"] = compliance["framework"] - if compliance.get("provider"): - result["provider"] = compliance["provider"] - if compliance.get("version"): - result["version"] = compliance["version"] - if compliance.get("description"): - result["description"] = compliance["description"] - - # Numeric fields - if compliance.get("total_checks"): - result["total_checks"] = compliance["total_checks"] - if compliance.get("total_requirements"): - result["total_requirements"] = compliance["total_requirements"] - - # Requirements - filter out empty nested values - requirements = compliance.get("requirements", []) - if requirements: - filtered_requirements = [] - for req in requirements: - filtered_req = {} - if req.get("id"): - filtered_req["id"] = req["id"] - if req.get("name"): - filtered_req["name"] = req["name"] - if req.get("description"): - filtered_req["description"] = req["description"] - if req.get("checks"): - filtered_req["checks"] = req["checks"] - if filtered_req: - filtered_requirements.append(filtered_req) - if filtered_requirements: - result["requirements"] = filtered_requirements - - return result except httpx.HTTPStatusError as e: if e.response.status_code == 404: - return {"error": f"Compliance '{compliance_id}' not found"} - return { - "error": f"HTTP error {e.response.status_code}: {e.response.text}", - } - except Exception as e: - return {"error": str(e)} + raise ToolError(_COMPLIANCE_NOT_FOUND.format(compliance_id=compliance_id)) + raise + + compliance = parse_json_response(response) + + if not compliance: + raise ToolError(_COMPLIANCE_NOT_FOUND.format(compliance_id=compliance_id)) + + # Build response with only non-empty fields to save tokens + result = {} + + # Core fields + result["id"] = compliance["id"] + if compliance.get("name"): + result["name"] = compliance["name"] + if compliance.get("framework"): + result["framework"] = compliance["framework"] + if compliance.get("provider"): + result["provider"] = compliance["provider"] + if compliance.get("version"): + result["version"] = compliance["version"] + if compliance.get("description"): + result["description"] = compliance["description"] + + # Numeric fields + if compliance.get("total_checks"): + result["total_checks"] = compliance["total_checks"] + if compliance.get("total_requirements"): + result["total_requirements"] = compliance["total_requirements"] + + # Requirements - filter out empty nested values + requirements = compliance.get("requirements", []) + if requirements: + filtered_requirements = [] + for req in requirements: + filtered_req = {} + if req.get("id"): + filtered_req["id"] = req["id"] + if req.get("name"): + filtered_req["name"] = req["name"] + if req.get("description"): + filtered_req["description"] = req["description"] + if req.get("checks"): + filtered_req["checks"] = req["checks"] + if filtered_req: + filtered_requirements.append(filtered_req) + if filtered_requirements: + result["requirements"] = filtered_requirements + + return result # Provider Tools @@ -683,32 +768,25 @@ async def list_providers() -> dict: ] } """ - try: - response = prowler_hub_client.get("/providers") - response.raise_for_status() - providers = response.json() + response = _get_hub_endpoint("providers") + response.raise_for_status() + providers = parse_json_response(response) - providers_list = [] - for provider in providers: - providers_list.append( - { - "id": provider["id"], - "name": provider.get("name", ""), - } - ) + providers_list = [] + for provider in providers: + providers_list.append( + { + "id": provider["id"], + "name": provider.get("name", ""), + } + ) - return {"count": len(providers), "providers": providers_list} - except httpx.HTTPStatusError as e: - return { - "error": f"HTTP error {e.response.status_code}: {e.response.text}", - } - except Exception as e: - return {"error": str(e)} + return {"count": len(providers), "providers": providers_list} @hub_mcp_server.tool() async def get_provider_services( - provider_id: str = Field( + provider_id: NonBlankStr = Field( description="The provider ID to get services for. Example: 'aws', 'azure', 'gcp', 'kubernetes'. Use `prowler_hub_list_providers` to get available provider IDs.", ), ) -> dict: @@ -727,24 +805,20 @@ async def get_provider_services( "services": ["s3", "ec2", "iam", "rds", "lambda", ...] } """ - try: - response = prowler_hub_client.get("/providers") - response.raise_for_status() - providers = response.json() + response = _get_hub_endpoint("providers") + response.raise_for_status() + providers = parse_json_response(response) - for provider in providers: - if provider["id"] == provider_id: - return { - "provider_id": provider["id"], - "provider_name": provider.get("name", ""), - "count": len(provider.get("services", [])), - "services": provider.get("services", []), - } + for provider in providers: + if provider["id"] == provider_id: + return { + "provider_id": provider["id"], + "provider_name": provider.get("name", ""), + "count": len(provider.get("services", [])), + "services": provider.get("services", []), + } - return {"error": f"Provider '{provider_id}' not found"} - except httpx.HTTPStatusError as e: - return { - "error": f"HTTP error {e.response.status_code}: {e.response.text}", - } - except Exception as e: - return {"error": str(e)} + known = ", ".join(sorted(str(provider["id"]) for provider in providers)) + raise ToolError( + f"Prowler has no provider with the ID '{provider_id}'. Available: {known}." + ) diff --git a/mcp_server/tests/lib/test_errors.py b/mcp_server/tests/lib/test_errors.py index 5986d883c4..a682034bc8 100644 --- a/mcp_server/tests/lib/test_errors.py +++ b/mcp_server/tests/lib/test_errors.py @@ -7,11 +7,18 @@ reaches a model is text this server produced. import json +import httpx import pytest from fastmcp import Client from pydantic import BaseModel, ValidationError -from prowler_mcp_server.lib.errors import InvalidArgument, _describe_failure +from prowler_mcp_server.lib.errors import ( + CredentialError, + InvalidArgument, + UpstreamInvalidResponse, + _describe_failure, + parse_json_response, +) from prowler_mcp_server.prowler_app.utils.api_client import ( ProwlerAPIError, ProwlerAPIInvalidResponse, @@ -22,6 +29,42 @@ from tests.helpers.jsonapi import jsonapi_error LATEST = "/api/v1/findings/latest" +# --------------------------------------------------------------- json bodies + + +def _answer( + body: str, *, url: str = "https://hub.prowler.com/api/check" +) -> httpx.Response: + """An answer as a client would hand it back, request attached.""" + return httpx.Response(200, text=body, request=httpx.Request("GET", url)) + + +def test_a_json_body_is_returned_as_it_is(): + """The helper only classifies the failure; the success path is untouched.""" + assert parse_json_response(_answer('{"id": "s3_bucket_public_access"}')) == { + "id": "s3_bucket_public_access" + } + + +def test_a_body_that_is_not_json_names_the_host_that_answered(): + """Which upstream is misbehaving is the one useful fact here, and the shared + helper is reached from every sub-server that reads an upstream directly.""" + with pytest.raises(UpstreamInvalidResponse) as raised: + parse_json_response(_answer("502 Bad Gateway")) + + assert raised.value.host == "hub.prowler.com" + assert "Bad Gateway" not in str(raised.value) + + +def test_a_body_that_is_not_json_is_not_a_valueerror(): + """`JSONDecodeError` is a ValueError, and callers tell an upstream fault from + a bad argument by type alone.""" + with pytest.raises(UpstreamInvalidResponse) as raised: + parse_json_response(_answer("not json")) + + assert not isinstance(raised.value, ValueError) + + # ------------------------------------------------------------ classification @@ -90,6 +133,29 @@ def test_an_unreadable_api_answer_is_never_called_safe_to_repeat(): assert "check the current state" in message +def test_an_unreadable_upstream_answer_is_not_blamed_on_the_arguments(): + """A `JSONDecodeError` from an upstream and one from an argument are the same + exception and opposite instructions.""" + message = _describe_failure( + UpstreamInvalidResponse("200 body is not JSON", host="hub.prowler.com") + ) + + assert "hub.prowler.com" in message + assert "could not read as JSON" in message + assert "changing them will not help" in message + + +def test_an_unreadable_upstream_answer_never_quotes_the_body(): + """The body is someone else's text, so only the host and the status leave here.""" + message = _describe_failure( + UpstreamInvalidResponse( + "502 body is not JSON", host="raw.githubusercontent.com" + ) + ) + + assert "body is not JSON" not in message + + def test_an_argument_this_server_rejected_is_repeated_verbatim(): """`InvalidArgument` exists to mark a message as one we wrote.""" message = _describe_failure( @@ -99,6 +165,19 @@ def test_an_argument_this_server_rejected_is_repeated_verbatim(): assert message == "page_size must be between 1 and 1000." +def test_a_credential_caught_here_is_answered_like_the_401_it_would_have_got(): + """It is not an argument problem, and saying so stops a pointless retry.""" + message = _describe_failure(CredentialError("the token has expired")) + + assert "the token has expired" in message + assert "changing the arguments will not help" in message + + +def test_a_transport_this_server_cannot_serve_is_left_masked(): + """No call caused a bad PROWLER_MCP_TRANSPORT_MODE and no call can fix it.""" + assert _describe_failure(RuntimeError("Invalid mode: websocket")) is None + + def test_a_pydantic_rejection_names_the_field_without_echoing_the_value(): """Pydantic quotes `input_value` back, and these tools take credentials.""" @@ -195,3 +274,34 @@ async def test_an_unreadable_api_answer_does_not_reach_the_agent_as_a_bad_argume assert result.isError is True assert "gateway timeout" not in result.content[0].text assert "argument" not in result.content[0].text + + +async def test_a_tool_specific_message_survives_masking( + mcp_root_server, mock_api_client, mock_router +): + """A `ToolError` raised without a `from` clause is the final word.""" + mock_router.add("GET", "/api/v1/integrations/i1", json={"data": None}) + + async with Client(mcp_root_server) as client: + result = await client.call_tool_mcp( + "prowler_get_integration", {"integration_id": "i1"} + ) + + assert result.isError is True + assert "prowler_list_integrations" in result.content[0].text + + +async def test_a_hub_tool_failure_says_which_host_refused_it( + mcp_root_server, hub_router +): + """Hub failures arrive as raw httpx errors: host and status relayed, body not.""" + hub_router.add( + "GET", "/api/check", status=503, text="upstream nginx 10.1.2.3" + ) + + async with Client(mcp_root_server) as client: + result = await client.call_tool_mcp("prowler_hub_list_checks", {}) + + assert result.isError is True + assert "hub.prowler.com" in result.content[0].text + assert "10.1.2.3" not in result.content[0].text diff --git a/mcp_server/tests/lib/test_types.py b/mcp_server/tests/lib/test_types.py new file mode 100644 index 0000000000..8af25a7aae --- /dev/null +++ b/mcp_server/tests/lib/test_types.py @@ -0,0 +1,125 @@ +"""Tests for the argument types every tool shares. + +The bug these pin: "required" alone does not stop a blank identifier. A model +that has no scan or query id to hand sends ``""`` rather than omitting the +argument, and an unguarded empty string travels into a URL path or a request +body -- where it comes back as a 404, or as an API rejection ("This field may +not be blank") that names no argument and leaves the model with nothing to fix. +""" + +import pytest +from fastmcp import Client + +from tests.helpers.jsonapi import jsonapi_collection, jsonapi_resource + +SCAN_ID = "019ac0d6-90d5-73e9-9acf-c22e256f1bac" +QUERIES = f"/api/v1/attack-paths-scans/{SCAN_ID}/queries" + + +@pytest.mark.parametrize("query_id", ["", " "], ids=["empty", "whitespace-only"]) +async def test_a_blank_identifier_is_rejected_before_any_request_goes_out( + mcp_root_server, mock_api_client, mock_router, query_id +): + """The reported failure: a blank `query_id` reached Prowler as a 400. + + The message has to name the argument. Prowler's own answer to the blank value + ("This field may not be blank") does not say which field, so the model had no + way to tell `scan_id` from `query_id` from the reply. + """ + async with Client(mcp_root_server) as client: + result = await client.call_tool_mcp( + "prowler_run_attack_paths_query", + {"scan_id": SCAN_ID, "query_id": query_id}, + ) + + assert result.isError is True + assert "query_id" in result.content[0].text + assert mock_router.requests == [] + + +async def test_an_identifier_keeps_its_surrounding_whitespace_out_of_the_url( + mcp_root_server, mock_api_client, mock_router +): + """A padded id is the same id, and a raw one would build a URL-escaped path.""" + mock_router.add("GET", QUERIES, json=jsonapi_collection([])) + + async with Client(mcp_root_server) as client: + result = await client.call_tool_mcp( + "prowler_list_attack_paths_queries", {"scan_id": f" {SCAN_ID} "} + ) + + assert result.isError is False + assert mock_router.paths() == [f"GET {QUERIES}"] + + +async def test_a_blank_optional_value_is_rejected_rather_than_written( + mcp_root_server, mock_api_client, mock_router +): + """An omitted optional means "leave it alone"; a blank one would blank the field. + + The API refuses it, so the only difference an unguarded blank makes is a + round trip and an error that names nothing. + """ + async with Client(mcp_root_server) as client: + result = await client.call_tool_mcp( + "prowler_update_mute_rule", {"rule_id": SCAN_ID, "name": ""} + ) + + assert result.isError is True + assert "name" in result.content[0].text + assert mock_router.requests == [] + + +async def test_an_omitted_optional_string_is_still_omitted( + mcp_root_server, mock_api_client, mock_router +): + """`NonBlankStr | None` must not turn "not provided" into a rejection.""" + mock_router.add( + "GET", + f"/api/v1/mute-rules/{SCAN_ID}", + json={ + "data": jsonapi_resource( + "mute-rules", + SCAN_ID, + { + "name": "unchanged", + "reason": "already reviewed", + "enabled": True, + "finding_uids": [], + }, + ) + }, + ) + + async with Client(mcp_root_server) as client: + result = await client.call_tool_mcp( + "prowler_update_mute_rule", {"rule_id": SCAN_ID} + ) + + assert result.isError is False + + +async def test_every_required_string_argument_is_guarded_against_a_blank( + mcp_root_server, +): + """A guard only one tool carries is one the next tool will be written without. + + Declared as `minLength` rather than checked inside each tool, so a client sees + the constraint in the schema before it calls. + """ + async with Client(mcp_root_server) as client: + tools = await client.list_tools() + + unguarded = [ + f"{tool.name}.{name}" + for tool in tools + for name, schema in tool.inputSchema.get("properties", {}).items() + # Plain required strings only. A union such as `dict | str` takes a JSON + # string, where a blank is a parse failure the classifier already + # explains, and a blank filter is a filter that matches everything. + if schema.get("type") == "string" + and name in tool.inputSchema.get("required", []) + and schema.get("minLength") != 1 + ] + + assert unguarded == [] diff --git a/mcp_server/tests/lib/test_urls.py b/mcp_server/tests/lib/test_urls.py new file mode 100644 index 0000000000..64798d5445 --- /dev/null +++ b/mcp_server/tests/lib/test_urls.py @@ -0,0 +1,59 @@ +"""Tests for the shared URL path builder. + +The bug these pin: an identifier interpolated into a path was resolved away by +httpx per RFC 3986, so "../" reached an endpoint no tool meant to call. +""" + +import pytest + +from prowler_mcp_server.lib.urls import path_segment, url_path + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("s3_bucket_public_access", "s3_bucket_public_access"), + ("cis_4.0_aws", "cis_4.0_aws"), + ("../../evil", "..%2F..%2Fevil"), + ("....//evil", "....%2F%2Fevil"), + ("%2e%2e%2f", "%252e%252e%252f"), + ("..;/", "..%3B%2F"), + ("s3/../evil", "s3%2F..%2Fevil"), + ("evil?fields=all", "evil%3Ffields%3Dall"), + ("evil#frag", "evil%23frag"), + ("evil\\wrong", "evil%5Cwrong"), + ("two words", "two%20words"), + ], + ids=[ + "plain", + "dots-in-a-name", + "traversal", + "stripped-filter-bypass", + "already-encoded", + "path-parameter", + "mid-path", + "query", + "fragment", + "backslash", + "space", + ], +) +def test_a_segment_survives_as_a_name_and_never_as_syntax(value, expected): + """A real ID passes through untouched; URL syntax comes back as characters.""" + assert path_segment(value) == expected + + +@pytest.mark.parametrize("value", [".", ".."], ids=["here", "up-one"]) +def test_a_segment_of_nothing_but_dots_is_escaped_rather_than_left_to_resolve(value): + """`quote` keeps a dot, so a segment of only dots would still resolve away.""" + assert path_segment(value) == value.replace(".", "%2E") + + +def test_a_path_is_the_segments_it_was_given_and_no_others(): + """One argument per segment, so no call site has to encode anything.""" + assert url_path("users", "../../evil", "roles") == "/users/..%2F..%2Fevil/roles" + + +def test_a_single_segment_path_keeps_its_leading_slash(): + """Every caller joins this onto a base URL that ends without a slash.""" + assert url_path("providers") == "/providers" diff --git a/mcp_server/tests/prowler_app/tools/test_attack_paths.py b/mcp_server/tests/prowler_app/tools/test_attack_paths.py new file mode 100644 index 0000000000..3f20549796 --- /dev/null +++ b/mcp_server/tests/prowler_app/tools/test_attack_paths.py @@ -0,0 +1,225 @@ +"""Tests for the Attack Paths tools. + +An Attack Paths scan is a separate resource from a regular scan, with IDs of its +own, and Prowler only creates one for an AWS provider. So the 404 these tools get +is almost always a regular scan ID passed where an Attack Paths one belongs -- +and Prowler's own reason for it, a bare "Not found.", names neither the resource +it looked in nor the tool that returns the right ID. +""" + +import pytest +from fastmcp import Client + +from tests.helpers.jsonapi import jsonapi_collection, jsonapi_resource + +QUERIES = "/api/v1/attack-paths-scans/s1/queries" + + +async def test_an_id_that_is_not_an_attack_paths_scan_says_which_tool_returns_one( + mcp_root_server, mock_api_client, mock_router +): + """Relaying "Not found." sends an agent to re-check an ID it cannot fix. + + The reply has to name the confusion it stands for: regular scan IDs do not + resolve here, and only AWS providers have an Attack Paths scan at all. + """ + mock_router.add( + "GET", + QUERIES, + status=404, + json={"errors": [{"status": "404", "detail": "Not found."}]}, + ) + + async with Client(mcp_root_server) as client: + with pytest.raises(Exception, match="different resource from regular scans"): + await client.call_tool( + "prowler_list_attack_paths_queries", {"scan_id": "s1"} + ) + + +async def test_the_answer_names_the_tool_that_returns_a_usable_id( + mcp_root_server, mock_api_client, mock_router +): + """An explanation with no next step leaves the agent guessing IDs.""" + mock_router.add( + "GET", + QUERIES, + status=404, + json={"errors": [{"status": "404", "detail": "Not found."}]}, + ) + + async with Client(mcp_root_server) as client: + with pytest.raises(Exception, match="prowler_list_attack_paths_scans"): + await client.call_tool( + "prowler_list_attack_paths_queries", {"scan_id": "s1"} + ) + + +async def test_a_failure_that_is_not_a_404_keeps_the_shared_message( + mcp_root_server, mock_api_client, mock_router +): + """Only the 404 means a bad ID. A 403 is a permission the ID cannot fix.""" + mock_router.add( + "GET", + QUERIES, + status=403, + json={"errors": [{"status": "403", "detail": "Denied."}]}, + ) + + async with Client(mcp_root_server) as client: + with pytest.raises(Exception, match="prowler_get_current_user"): + await client.call_tool( + "prowler_list_attack_paths_queries", {"scan_id": "s1"} + ) + + +async def test_queries_come_back_as_a_list( + mcp_root_server, mock_api_client, mock_router +): + """The success path is unchanged.""" + mock_router.add( + "GET", + QUERIES, + json=jsonapi_collection( + [ + jsonapi_resource( + "attack-paths-queries", + "aws-ec2-instances-internet-exposed", + { + "name": "Internet exposed EC2", + "description": "Find internet-exposed EC2 instances", + "provider": "aws", + "parameters": [], + }, + ) + ] + ), + ) + + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_list_attack_paths_queries", {"scan_id": "s1"} + ) + + assert result.data[0]["id"] == "aws-ec2-instances-internet-exposed" + + +# ------------------------------------------------------------- running a query + +RUN = "/api/v1/attack-paths-scans/s1/queries/run" +SCHEMA = "/api/v1/attack-paths-scans/s1/schema" + +EMPTY_RESULT = { + "data": { + "type": "attack-paths-query-results", + "id": "s1", + "attributes": {"nodes": [], "relationships": []}, + } +} + + +def _run_args(query_id: str = "aws-ec2-instances-internet-exposed") -> dict[str, str]: + """Arguments for a query run against the mocked scan.""" + return {"scan_id": "s1", "query_id": query_id} + + +async def test_a_query_that_matched_nothing_is_an_answer_not_a_failure( + mcp_root_server, mock_api_client, mock_router +): + """Prowler answers a query that matched nothing with 404 and the result body. + + Raising on the status called a clean account a failed call and sent the agent + off to re-check arguments that were right. + """ + mock_router.add("POST", RUN, status=404, json=EMPTY_RESULT) + + async with Client(mcp_root_server) as client: + result = await client.call_tool_mcp( + "prowler_run_attack_paths_query", _run_args() + ) + + assert result.isError is False + assert "matched nothing" in result.structuredContent["message"] + + +async def test_an_empty_result_does_not_come_back_as_an_empty_object( + mcp_root_server, mock_api_client, mock_router +): + """The serializer drops empty lists, so `{}` is all that would be left.""" + mock_router.add("POST", RUN, status=404, json=EMPTY_RESULT) + + async with Client(mcp_root_server) as client: + result = await client.call_tool("prowler_run_attack_paths_query", _run_args()) + + assert result.data != {} + + +async def test_a_null_graph_is_read_as_an_empty_one( + mcp_root_server, mock_api_client, mock_router +): + """Prowler can spell the empty graph as `null` rather than as empty lists. + + Reading `null` as if it were a graph crashed the parse, turning the same + "nothing matched" answer into an error the agent could not act on. + """ + mock_router.add("POST", RUN, status=404, json={"data": {"attributes": None}}) + + async with Client(mcp_root_server) as client: + result = await client.call_tool_mcp( + "prowler_run_attack_paths_query", _run_args() + ) + + assert result.isError is False + assert "matched nothing" in result.structuredContent["message"] + + +async def test_a_run_against_an_unknown_scan_still_names_the_confusion( + mcp_root_server, mock_api_client, mock_router +): + """A 404 with no result body is the ID being wrong, not an empty answer.""" + mock_router.add( + "POST", + RUN, + status=404, + json={"errors": [{"status": "404", "detail": "Not found."}]}, + ) + + async with Client(mcp_root_server) as client: + with pytest.raises(Exception, match="different resource from regular scans"): + await client.call_tool("prowler_run_attack_paths_query", _run_args()) + + +async def test_a_scan_whose_graph_records_no_schema_says_so( + mcp_root_server, mock_api_client, mock_router +): + """This 404 is about the graph, not the ID, so it must not blame the ID.""" + mock_router.add( + "GET", + SCHEMA, + status=404, + json={"detail": "No cartography schema metadata found for this provider"}, + ) + + async with Client(mcp_root_server) as client: + with pytest.raises(Exception, match="no Cartography schema recorded"): + await client.call_tool( + "prowler_get_attack_paths_cartography_schema", {"scan_id": "s1"} + ) + + +async def test_a_schema_request_for_an_unknown_scan_names_the_confusion( + mcp_root_server, mock_api_client, mock_router +): + """The other 404 here is the ID, and Prowler writes a JSON:API error for it.""" + mock_router.add( + "GET", + SCHEMA, + status=404, + json={"errors": [{"status": "404", "detail": "Not found."}]}, + ) + + async with Client(mcp_root_server) as client: + with pytest.raises(Exception, match="different resource from regular scans"): + await client.call_tool( + "prowler_get_attack_paths_cartography_schema", {"scan_id": "s1"} + ) diff --git a/mcp_server/tests/prowler_app/tools/test_compliance.py b/mcp_server/tests/prowler_app/tools/test_compliance.py new file mode 100644 index 0000000000..373e1d7f6c --- /dev/null +++ b/mcp_server/tests/prowler_app/tools/test_compliance.py @@ -0,0 +1,102 @@ +"""Tests for the compliance tools. + +Both compliance tools answer for exactly one scan. ``scan_id`` names it +directly; ``provider_id`` names it indirectly, as "the latest completed scan of +this provider". Passing both is not a refinement of either -- the scan the +caller named may belong to a different provider entirely -- so it is rejected +rather than resolved by preferring one, which would answer confidently for a +provider nobody asked about. + +Tools are driven through an in-memory MCP client so FastMCP resolves the +pydantic ``Field`` defaults and a raised failure arrives the way a client sees +it. +""" + +import pytest +from fastmcp import Client + +from tests.helpers.jsonapi import jsonapi_collection, jsonapi_resource + +SCANS = "/api/v1/scans" +OVERVIEWS = "/api/v1/compliance-overviews" +REQUIREMENTS = f"{OVERVIEWS}/requirements" + +TOOLS = [ + "prowler_get_compliance_overview", + "prowler_get_compliance_framework_state_details", +] + + +def arguments(tool: str, **overrides) -> dict: + """Build the arguments for either tool, which differ only in compliance_id.""" + payload = dict(overrides) + if tool.endswith("framework_state_details"): + payload["compliance_id"] = "cis_1.5_aws" + return payload + + +@pytest.mark.parametrize("tool", TOOLS) +async def test_neither_a_scan_nor_a_provider_is_refused_before_any_request( + mcp_root_server, mock_api_client, mock_router, tool +): + """There is no scan to answer for, and no way to guess one.""" + async with Client(mcp_root_server) as client: + with pytest.raises(Exception, match="must be provided"): + await client.call_tool(tool, arguments(tool)) + + assert mock_router.paths() == [] + + +@pytest.mark.parametrize("tool", TOOLS) +async def test_a_scan_and_a_provider_together_are_refused_rather_than_reconciled( + mcp_root_server, mock_api_client, mock_router, tool +): + """Silently keeping the scan would answer for whichever provider owns it. + + That report names a scan the caller did ask for, so nothing about it looks + wrong -- while the provider they also named went unread. + """ + async with Client(mcp_root_server) as client: + with pytest.raises(Exception, match="not both"): + await client.call_tool( + tool, arguments(tool, scan_id="s1", provider_id="p1") + ) + + assert mock_router.paths() == [] + + +@pytest.mark.parametrize("tool", TOOLS) +async def test_a_provider_on_its_own_resolves_to_its_latest_completed_scan( + mcp_root_server, mock_api_client, mock_router, tool +): + """The indirection is the point of accepting a provider at all.""" + mock_router.add( + "GET", + SCANS, + json=jsonapi_collection( + [jsonapi_resource("scans", "s9", {"state": "completed"})] + ), + ) + mock_router.add("GET", OVERVIEWS, json=jsonapi_collection([])) + mock_router.add("GET", REQUIREMENTS, json=jsonapi_collection([])) + # Each tool reads the compliance state from its own endpoint; both filter it + # by the scan that had to be resolved first. + read = OVERVIEWS if tool.endswith("overview") else REQUIREMENTS + + async with Client(mcp_root_server) as client: + await client.call_tool(tool, arguments(tool, provider_id="p1")) + + assert mock_router.query_params("GET", SCANS)["filter[provider]"] == "p1" + assert mock_router.query_params("GET", read)["filter[scan_id]"] == "s9" + + +@pytest.mark.parametrize("tool", TOOLS) +async def test_a_provider_with_no_completed_scan_is_named_as_the_bad_argument( + mcp_root_server, mock_api_client, mock_router, tool +): + """Nothing has been scanned yet, so there is no compliance state to report.""" + mock_router.add("GET", SCANS, json=jsonapi_collection([])) + + async with Client(mcp_root_server) as client: + with pytest.raises(Exception, match="No completed scans found for provider p1"): + await client.call_tool(tool, arguments(tool, provider_id="p1")) diff --git a/mcp_server/tests/prowler_app/tools/test_integrations.py b/mcp_server/tests/prowler_app/tools/test_integrations.py index 9d165a60c6..d94dd8a21d 100644 --- a/mcp_server/tests/prowler_app/tools/test_integrations.py +++ b/mcp_server/tests/prowler_app/tools/test_integrations.py @@ -312,17 +312,16 @@ async def test_creating_a_jira_integration_rejects_an_empty_domain( ): """A domain that normalizes to nothing is caught before the round trip.""" async with Client(mcp_root_server) as client: - result = await client.call_tool( - "prowler_create_jira_integration", - { - "domain": "https://", - "user_mail": "security@acme.com", - "api_token": "fake-atlassian-token-for-testing", - }, - ) + with pytest.raises(Exception, match="Invalid Jira domain"): + await client.call_tool( + "prowler_create_jira_integration", + { + "domain": "https://", + "user_mail": "security@acme.com", + "api_token": "fake-atlassian-token-for-testing", + }, + ) - assert result.data["status"] == "failed" - assert "Invalid Jira domain" in result.data["error"] assert mock_router.requests == [] @@ -334,14 +333,10 @@ async def test_creating_a_jira_integration_rejects_an_empty_domain( ], ids=["security-hub", "amazon-s3"], ) -async def test_a_rejected_creation_is_reported_rather_than_raised( +async def test_a_rejected_creation_fails_with_the_api_reason( mcp_root_server, mock_api_client, mock_router, tool, arguments ): - """Write tools answer with an error object so the agent can act on it. - - A raised exception reaches the model as a tool failure with no detail, and - the API's message is exactly what tells it what to do next. - """ + """A refused creation is a tool error, and it still carries the API's reason.""" mock_router.add( "POST", INTEGRATIONS, @@ -350,10 +345,8 @@ async def test_a_rejected_creation_is_reported_rather_than_raised( ) async with Client(mcp_root_server) as client: - result = await client.call_tool(tool, arguments) - - assert result.data["status"] == "failed" - assert "already has this integration" in result.data["error"] + with pytest.raises(Exception, match="already has this integration"): + await client.call_tool(tool, arguments) async def test_a_creation_with_no_id_back_warns_before_a_blind_retry( @@ -367,12 +360,11 @@ async def test_a_creation_with_no_id_back_warns_before_a_blind_retry( mock_router.add("POST", INTEGRATIONS, json={"data": {}}) async with Client(mcp_root_server) as client: - result = await client.call_tool( - "prowler_create_amazon_s3_integration", {"bucket_name": "my-reports"} - ) + with pytest.raises(Exception, match="did not return its ID"): + await client.call_tool( + "prowler_create_amazon_s3_integration", {"bucket_name": "my-reports"} + ) - assert result.data["status"] == "failed" - assert "did not return its ID" in result.data["error"] assert mock_router.paths() == [f"POST {INTEGRATIONS}"] @@ -395,12 +387,10 @@ async def test_a_creation_whose_read_back_fails_still_hands_over_the_id( ) async with Client(mcp_root_server) as client: - result = await client.call_tool( - "prowler_create_amazon_s3_integration", {"bucket_name": "my-reports"} - ) - - assert result.data["status"] == "failed" - assert "Integration i1 was created" in result.data["error"] + with pytest.raises(Exception, match="Integration i1 was created"): + await client.call_tool( + "prowler_create_amazon_s3_integration", {"bucket_name": "my-reports"} + ) async def test_a_connection_check_that_cannot_run_is_not_reported_as_a_failure( @@ -542,13 +532,12 @@ async def test_a_configuration_that_is_not_an_object_is_rejected_before_the_writ stub_integration(mock_router, S3_ATTRIBUTES) async with Client(mcp_root_server) as client: - result = await client.call_tool( - "prowler_update_integration", - {"integration_id": "i1", "configuration": configuration}, - ) + with pytest.raises(Exception, match=message): + await client.call_tool( + "prowler_update_integration", + {"integration_id": "i1", "configuration": configuration}, + ) - assert result.data["status"] == "failed" - assert message in result.data["error"] assert f"PATCH {INTEGRATION}" not in mock_router.paths() @@ -643,13 +632,12 @@ async def test_updating_a_jira_configuration_is_refused( stub_integration(mock_router, JIRA_ATTRIBUTES) async with Client(mcp_root_server) as client: - result = await client.call_tool( - "prowler_update_integration", - {"integration_id": "i1", "configuration": {"domain": "other"}}, - ) + with pytest.raises(Exception, match="do not accept a configuration"): + await client.call_tool( + "prowler_update_integration", + {"integration_id": "i1", "configuration": {"domain": "other"}}, + ) - assert result.data["status"] == "failed" - assert "do not accept a configuration" in result.data["error"] assert f"PATCH {INTEGRATION}" not in mock_router.paths() @@ -660,12 +648,12 @@ async def test_attaching_a_jira_integration_to_a_provider_is_refused( stub_integration(mock_router, JIRA_ATTRIBUTES) async with Client(mcp_root_server) as client: - result = await client.call_tool( - "prowler_update_integration", - {"integration_id": "i1", "provider_ids": ["p1"]}, - ) + with pytest.raises(Exception, match="tenant-wide"): + await client.call_tool( + "prowler_update_integration", + {"integration_id": "i1", "provider_ids": ["p1"]}, + ) - assert "tenant-wide" in result.data["error"] assert f"PATCH {INTEGRATION}" not in mock_router.paths() @@ -683,12 +671,12 @@ async def test_security_hub_must_keep_exactly_one_provider( stub_integration(mock_router, SECURITY_HUB_ATTRIBUTES, provider_ids=("p1",)) async with Client(mcp_root_server) as client: - result = await client.call_tool( - "prowler_update_integration", - {"integration_id": "i1", "provider_ids": provider_ids}, - ) + with pytest.raises(Exception, match="exactly one AWS provider"): + await client.call_tool( + "prowler_update_integration", + {"integration_id": "i1", "provider_ids": provider_ids}, + ) - assert "exactly one AWS provider" in result.data["error"] assert f"PATCH {INTEGRATION}" not in mock_router.paths() @@ -708,12 +696,12 @@ async def test_partial_jira_credentials_are_refused_to_protect_the_stored_ones( stub_integration(mock_router, JIRA_ATTRIBUTES) async with Client(mcp_root_server) as client: - result = await client.call_tool( - "prowler_update_integration", - {"integration_id": "i1", "credentials": credentials}, - ) + with pytest.raises(Exception, match="replaced as a whole"): + await client.call_tool( + "prowler_update_integration", + {"integration_id": "i1", "credentials": credentials}, + ) - assert "replaced as a whole" in result.data["error"] assert f"PATCH {INTEGRATION}" not in mock_router.paths() @@ -751,13 +739,14 @@ async def test_replacing_jira_credentials_normalizes_the_domain( # ------------------------------------------------- delete and connection tools -async def test_deleting_an_integration_reports_the_outcome_either_way( +async def test_deleting_an_integration_confirms_it_happened( mcp_root_server, mock_api_client, mock_router ): - """Deletion is irreversible, so both outcomes are stated explicitly. + """Deletion is irreversible, so a success says so rather than staying silent. - A bare exception would leave the agent unsure whether the credentials are - gone, and a retry of a delete that actually succeeded reads as a new failure. + It says so in the message and nowhere else: a `deleted: true` flag could only + ever be true, because an integration that was not deleted leaves the tool as + an error. """ mock_router.add("DELETE", INTEGRATION, status=204) @@ -766,24 +755,23 @@ async def test_deleting_an_integration_reports_the_outcome_either_way( "prowler_delete_integration", {"integration_id": "i1"} ) - assert result.data["deleted"] is True + assert "i1 deleted successfully" in result.data["message"] + assert "deleted" not in result.data -async def test_a_failed_deletion_says_it_did_not_happen( +async def test_a_refused_deletion_fails_and_says_the_role_is_the_problem( mcp_root_server, mock_api_client, mock_router ): - """`deleted: false` is the part the agent must not have to infer.""" + """A 403 is the same answer for every tool, so `lib.errors` writes it.""" mock_router.add( "DELETE", INTEGRATION, status=403, json=jsonapi_error(403, "Permission denied.") ) async with Client(mcp_root_server) as client: - result = await client.call_tool( - "prowler_delete_integration", {"integration_id": "i1"} - ) - - assert result.data["deleted"] is False - assert "Permission denied." in result.data["message"] + with pytest.raises(Exception, match="prowler_get_current_user"): + await client.call_tool( + "prowler_delete_integration", {"integration_id": "i1"} + ) async def test_checking_a_connection_surfaces_why_it_failed( @@ -1026,6 +1014,32 @@ async def test_an_accepted_dispatch_with_no_task_id_is_not_safe_to_retry( assert "task_id" not in result.data +async def test_a_dispatch_with_no_usable_credential_is_raised_not_called_unknown( + mcp_root_server, mock_api_client, mock_router, monkeypatch +): + """Authentication runs before the request, so nothing was ever queued. + + Reported as `unknown` it reads as "work items may exist in Jira, go and + look" -- for a call that never reached Prowler. It is not a dispatch outcome + at all: the credential has to be fixed, and no retry of this call does that. + """ + monkeypatch.setattr(mock_api_client.auth_manager, "mode", "http") + + async with Client(mcp_root_server) as client: + with pytest.raises(Exception, match="no usable credential"): + await client.call_tool( + "prowler_send_findings_to_jira", + { + "integration_id": "i1", + "project_key": "PROJ", + "issue_type": "Task", + "finding_ids": ["f1"], + }, + ) + + assert mock_router.paths() == [] + + async def test_a_dispatch_task_that_died_halfway_is_never_safe_to_retry( mcp_root_server, mock_api_client, mock_router ): diff --git a/mcp_server/tests/prowler_app/tools/test_muting.py b/mcp_server/tests/prowler_app/tools/test_muting.py new file mode 100644 index 0000000000..7c980600e3 --- /dev/null +++ b/mcp_server/tests/prowler_app/tools/test_muting.py @@ -0,0 +1,64 @@ +"""Tests for the muting tools. + +Muting is permanent and deleting a rule does not undo it, so the one thing +these assertions protect is that an agent is never told a deletion failed when +it did not: the old answer keyed off the *shape* of the API's reply rather than +off anything having gone wrong, and said nothing a caller could act on. +""" + +import pytest +from fastmcp import Client + +from tests.helpers.jsonapi import jsonapi_document, jsonapi_error, jsonapi_resource + +MUTE_RULE = "/api/v1/mute-rules/m1" + + +async def test_a_deleted_rule_is_reported_deleted_whatever_the_body( + mcp_root_server, mock_api_client, mock_router +): + """Prowler answers 204 with no body, but 200 with one is just as much a yes. + + The old check read ``success`` out of the parsed body, which only exists for + the empty-body case, so a deletion that worked could be reported as + "Failed to delete mute rule". + """ + mock_router.add( + "DELETE", + MUTE_RULE, + json=jsonapi_document(jsonapi_resource("mute-rules", "m1", {})), + ) + + async with Client(mcp_root_server) as client: + result = await client.call_tool("prowler_delete_mute_rule", {"rule_id": "m1"}) + + assert "m1 deleted successfully" in result.data["message"] + assert "stay muted" in result.data["message"] + # A flag with one reachable value is not a fact, it is an invitation to + # branch on a shape that does not exist. + assert "success" not in result.data + + +async def test_an_empty_body_deletion_is_reported_the_same_way( + mcp_root_server, mock_api_client, mock_router +): + """The 204 path, which is what Prowler actually sends today.""" + mock_router.add("DELETE", MUTE_RULE, status=204) + + async with Client(mcp_root_server) as client: + result = await client.call_tool("prowler_delete_mute_rule", {"rule_id": "m1"}) + + assert "m1 deleted successfully" in result.data["message"] + + +async def test_a_refused_deletion_is_an_error( + mcp_root_server, mock_api_client, mock_router +): + """A rule that was not deleted has to leave the tool as an error.""" + mock_router.add( + "DELETE", MUTE_RULE, status=404, json=jsonapi_error(404, "Not found.") + ) + + async with Client(mcp_root_server) as client: + with pytest.raises(Exception, match="Not found"): + await client.call_tool("prowler_delete_mute_rule", {"rule_id": "m1"}) diff --git a/mcp_server/tests/prowler_app/tools/test_providers.py b/mcp_server/tests/prowler_app/tools/test_providers.py new file mode 100644 index 0000000000..b3438a79a7 --- /dev/null +++ b/mcp_server/tests/prowler_app/tools/test_providers.py @@ -0,0 +1,316 @@ +"""Tests for the provider tools. + +Two behaviours drive most of the assertions here, and both are about telling a +failure apart from an outcome that is merely not final yet: + +* Deleting a provider removes it together with its scans, findings and + resources, in a background task that routinely outlives the polling window. + A deletion still running is not a failure, and reporting it as one invites a + retry of a destructive call that is already in flight. +* ``connect_provider`` runs a connection check, and a check that could not be + run says nothing about the provider's credentials. Reporting it as ``failed`` + blames an AWS role for an expired Prowler credential and sends the user off to + fix something that works. + +Tools are driven through an in-memory MCP client so FastMCP resolves the +pydantic ``Field`` defaults and a raised failure arrives the way a client sees +it. +""" + +import pytest +from fastmcp import Client + +from tests.helpers.http import MockRouter +from tests.helpers.jsonapi import ( + jsonapi_collection, + jsonapi_document, + jsonapi_error, + jsonapi_resource, + task_document, +) + +PROVIDERS = "/api/v1/providers" +PROVIDER = f"{PROVIDERS}/p1" +CONNECTION = f"{PROVIDER}/connection" +SECRETS = f"{PROVIDERS}/secrets" +TASK = "/api/v1/tasks/t1" + +PROVIDER_ATTRIBUTES = { + "uid": "123456789012", + "provider": "aws", + "alias": "production", + "connection": {"connected": True}, +} + + +@pytest.fixture +def mock_fast_polling(monkeypatch, api_client): + """Run the real polling loop, with a timeout a test can afford to wait out. + + The timeout path is the one worth testing here -- it is what used to be + reported as a failed deletion -- so the loop, its exception and the fallback + that reads the task afterwards all stay real. Only the 60 seconds go. + """ + original = type(api_client).poll_task_until_complete + + async def _fast(self, task_id, **_overridden): + return await original(self, task_id, timeout=0.3, poll_interval=0.05) + + monkeypatch.setattr(type(api_client), "poll_task_until_complete", _fast) + + +@pytest.fixture +def mock_polling_timeout(monkeypatch, api_client): + """Make the polling window run out on the first call, without the wait. + + The clock is what ends the polling loop here, so a test about *what happens + afterwards* has no reason to spend it. The read the fallback then makes is + the real one. + """ + + async def _timeout(self, task_id, **_overridden): + raise TimeoutError(f"Task {task_id} polling timed out after 60 seconds.") + + monkeypatch.setattr(type(api_client), "poll_task_until_complete", _timeout) + + +def stub_deletion_start(mock_router: MockRouter) -> MockRouter: + """Serve the DELETE as Prowler does: a task to poll, not a finished deletion.""" + return mock_router.add( + "DELETE", PROVIDER, json=jsonapi_document(jsonapi_resource("tasks", "t1", {})) + ) + + +# --------------------------------------------------------------- deletion + + +async def test_a_refused_deletion_is_an_error_not_a_result( + mcp_root_server, mock_api_client, mock_router +): + """Nothing started, so the classifier owns the message. + + Returned as ``{"deleted": false}`` it arrives with ``isError: false`` and a + model has no reason to treat it as anything but a completed call. + """ + mock_router.add( + "DELETE", PROVIDER, status=403, json=jsonapi_error(403, "Not allowed.") + ) + + async with Client(mcp_root_server) as client: + with pytest.raises(Exception, match="not allowed to do this"): + await client.call_tool("prowler_delete_provider", {"provider_id": "p1"}) + + +async def test_a_deletion_with_no_task_back_warns_before_a_blind_retry( + mcp_root_server, mock_api_client, mock_router +): + """Prowler accepted it, so the deletion is probably running. + + Without the task ID there is nothing to watch it with, which makes "check + whether it is gone" the only safe instruction. + """ + mock_router.add("DELETE", PROVIDER, json={"data": {}}) + + async with Client(mcp_root_server) as client: + with pytest.raises(Exception, match="did not return the ID"): + await client.call_tool("prowler_delete_provider", {"provider_id": "p1"}) + + assert mock_router.paths() == [f"DELETE {PROVIDER}"] + + +async def test_a_finished_deletion_reports_it_plainly( + mcp_root_server, mock_api_client, mock_router +): + """The success path is unchanged: the provider is gone.""" + stub_deletion_start(mock_router) + mock_router.add("GET", TASK, json=task_document("t1", "completed")) + + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_delete_provider", {"provider_id": "p1"} + ) + + assert result.data["status"] == "deleted" + + +async def test_a_deletion_still_running_is_not_reported_as_a_failure( + mcp_root_server, mock_api_client, mock_router, mock_fast_polling +): + """Outliving the polling window is normal for a provider with many findings. + + The task ID goes back so the deletion can be followed, and the message says + not to send it again -- which is the whole point of not calling this failed. + """ + stub_deletion_start(mock_router) + mock_router.add("GET", TASK, json=task_document("t1", "executing")) + + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_delete_provider", {"provider_id": "p1"} + ) + + assert result.data["status"] == "in_progress" + assert result.data["task_id"] == "t1" + assert "Do not send the deletion again" in result.data["message"] + + +async def test_a_deletion_that_finished_just_after_the_wait_is_reported_as_deleted( + mcp_root_server, mock_api_client, mock_router, mock_polling_timeout +): + """Polling gives up on the clock, not on the task. + + A deletion that completed a moment after the last poll is a finished + deletion, and the read the fallback makes is what says so. Reporting it as + still running would send the caller off to watch a provider that is gone. + """ + stub_deletion_start(mock_router) + mock_router.add("GET", TASK, json=task_document("t1", "completed")) + + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_delete_provider", {"provider_id": "p1"} + ) + + assert result.data["status"] == "deleted" + assert "task_id" not in result.data + + +async def test_a_deletion_task_that_stopped_is_an_error_naming_what_is_left( + mcp_root_server, mock_api_client, mock_router +): + """Here the provider really is still there, so this one is a failure. + + A provider is removed together with everything attached to it, so a task + that stopped halfway can leave part of that gone -- which is why the message + sends the caller to look rather than asserting the state. + """ + stub_deletion_start(mock_router) + mock_router.add("GET", TASK, json=task_document("t1", "failed", error="boom")) + + async with Client(mcp_root_server) as client: + with pytest.raises(Exception, match="ended as 'failed'"): + await client.call_tool("prowler_delete_provider", {"provider_id": "p1"}) + + +async def test_a_deletion_task_failure_does_not_relay_the_upstream_text( + mcp_root_server, mock_api_client, mock_router +): + """The task's own error is a celery traceback; it stays in the log.""" + stub_deletion_start(mock_router) + mock_router.add( + "GET", + TASK, + json=task_document("t1", "failed", error="Traceback: secret-internal-detail"), + ) + + async with Client(mcp_root_server) as client: + with pytest.raises(Exception) as raised: + await client.call_tool("prowler_delete_provider", {"provider_id": "p1"}) + + assert "secret-internal-detail" not in str(raised.value) + + +async def test_a_deletion_whose_progress_cannot_be_read_still_says_do_not_retry( + mcp_root_server, mock_api_client, mock_router, mock_fast_polling +): + """The outcome is unknown, which for a destructive call means: do not repeat. + + Reading the task is what tells "still running" from "stopped", so when that + read fails too the message drops the claim rather than guessing at one. + """ + stub_deletion_start(mock_router) + mock_router.add("GET", TASK, status=503, json=jsonapi_error(503, "Unavailable.")) + + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_delete_provider", {"provider_id": "p1"} + ) + + assert result.data["status"] == "in_progress" + assert "could not be read" in result.data["message"] + assert "Do not send the deletion again" in result.data["message"] + # The failure that got us here is the classifier's to phrase, so its raw + # text stays in the log rather than riding along in the message. + assert "API request failed" not in result.data["message"] + + +# ------------------------------------------------------- connection check + + +async def test_a_connection_check_that_cannot_run_is_not_reported_as_failed( + mcp_root_server, mock_api_client, mock_router +): + """`not_tested` says nothing about the credentials, and that is the point. + + A 401 here is this server's own credential, not the provider's. Calling it + `failed` tells the user their AWS role is broken when it is fine. + """ + # Registered in order and consumed in order: the lookup before the create + # finds nothing, the one after it finds the provider that was just made. + mock_router.add("GET", PROVIDERS, json=jsonapi_collection([])) + mock_router.add( + "GET", + PROVIDERS, + json=jsonapi_collection( + [jsonapi_resource("providers", "p1", PROVIDER_ATTRIBUTES)] + ), + ) + mock_router.add( + "POST", + PROVIDERS, + json=jsonapi_document(jsonapi_resource("providers", "p1", PROVIDER_ATTRIBUTES)), + ) + mock_router.add( + "POST", CONNECTION, status=401, json=jsonapi_error(401, "Token expired.") + ) + mock_router.add( + "GET", + PROVIDER, + json=jsonapi_document(jsonapi_resource("providers", "p1", PROVIDER_ATTRIBUTES)), + ) + + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_connect_provider", + {"provider_uid": "123456789012", "provider_type": "aws"}, + ) + + assert result.data["connected"] == "not_tested" + assert "never tested" in result.data["error"] + + +async def test_a_secret_lookup_failure_does_not_pass_as_having_no_secret( + mcp_root_server, mock_api_client, mock_router +): + """Returning None here would send the write down the create branch. + + A provider holds at most one secret, so creating a second one is refused and + the caller would be told its credentials were rejected when all that + actually failed was this read. + """ + mock_router.add( + "GET", + PROVIDERS, + json=jsonapi_collection( + [jsonapi_resource("providers", "p1", PROVIDER_ATTRIBUTES)] + ), + ) + mock_router.add( + "GET", SECRETS, status=429, json=jsonapi_error(429, "Too many requests.") + ) + + async with Client(mcp_root_server) as client: + with pytest.raises(Exception, match="rate limiting"): + await client.call_tool( + "prowler_connect_provider", + { + "provider_uid": "123456789012", + "provider_type": "aws", + "credentials": { + "aws_access_key_id": "AKIA", + "aws_secret_access_key": "s", + }, + }, + ) + + assert f"POST {SECRETS}" not in mock_router.paths() diff --git a/mcp_server/tests/prowler_app/tools/test_roles.py b/mcp_server/tests/prowler_app/tools/test_roles.py new file mode 100644 index 0000000000..f792cf0a04 --- /dev/null +++ b/mcp_server/tests/prowler_app/tools/test_roles.py @@ -0,0 +1,147 @@ +"""Tests for the role (RBAC) tools. + +``prowler_set_user_role`` replaces the single role a user holds, and the API +silently drops a role ID that does not exist in the tenant -- which would leave +the user with no role at all. So the tool reads the role first, and what that +read says has to be told apart carefully: only a not-found is about the role ID +the caller passed. A permission error, a rate limit or a server error is about +the request, and reporting either as "find a valid role ID" sends the user to +fix an ID that was fine. + +Tools are driven through an in-memory MCP client so FastMCP resolves the +pydantic ``Field`` defaults and a raised failure arrives the way a client sees +it. +""" + +import pytest +from fastmcp import Client + +from tests.helpers.http import MockRouter +from tests.helpers.jsonapi import ( + jsonapi_document, + jsonapi_error, + jsonapi_resource, +) + +USER = "/api/v1/users/u1" +USER_ROLES = f"{USER}/relationships/roles" +ROLE = "/api/v1/roles/r2" + +ROLE_ATTRIBUTES = {"name": "admin", "manage_account": True} + + +def stub_user_holding(mock_router: MockRouter, role_id: str) -> MockRouter: + """Serve ``GET /users/u1?include=roles`` with the user holding one role.""" + return mock_router.add( + "GET", + USER, + json=jsonapi_document( + jsonapi_resource("users", "u1", {"name": "Ada"}), + included=[jsonapi_resource("roles", role_id, ROLE_ATTRIBUTES)], + ), + ) + + +async def test_setting_a_role_the_user_already_holds_changes_nothing( + mcp_root_server, mock_api_client, mock_router +): + """Idempotent by design: no PATCH goes out, so nothing can be replaced.""" + stub_user_holding(mock_router, "r2") + + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_set_user_role", {"user_id": "u1", "role_id": "r2"} + ) + + assert result.data["changed"] is False + assert mock_router.paths() == [f"GET {USER}"] + + +async def test_a_role_that_does_not_exist_is_named_as_the_bad_argument( + mcp_root_server, mock_api_client, mock_router +): + """404 is the one answer that really is about the role ID. + + The PATCH would accept the ID and drop it, leaving the user with no role, so + the read has to stop the call -- and say which ID to replace. + """ + stub_user_holding(mock_router, "r1") + mock_router.add("GET", ROLE, status=404, json=jsonapi_error(404, "Not found.")) + + async with Client(mcp_root_server) as client: + with pytest.raises(Exception, match="does not exist in this tenant") as raised: + await client.call_tool( + "prowler_set_user_role", {"user_id": "u1", "role_id": "r2"} + ) + + assert "left unchanged" in str(raised.value) + assert f"PATCH {USER_ROLES}" not in mock_router.paths() + + +@pytest.mark.parametrize( + ("status", "detail", "expected"), + [ + (403, "Not allowed.", "not allowed to do this"), + (429, "Slow down.", "rate limiting"), + (500, "Boom.", "failed on Prowler's side"), + ], + ids=["forbidden", "rate-limited", "server-error"], +) +async def test_a_role_read_that_failed_for_another_reason_is_not_a_bad_role_id( + mcp_root_server, mock_api_client, mock_router, status, detail, expected +): + """These say nothing about the ID, so the classifier owns the message. + + Told "use prowler_list_roles to find a valid role ID", an agent goes looking + for a role that was never the problem -- and finds the same wall. + """ + stub_user_holding(mock_router, "r1") + mock_router.add("GET", ROLE, status=status, json=jsonapi_error(status, detail)) + + async with Client(mcp_root_server) as client: + with pytest.raises(Exception, match=expected) as raised: + await client.call_tool( + "prowler_set_user_role", {"user_id": "u1", "role_id": "r2"} + ) + + assert "valid role ID" not in str(raised.value) + assert f"PATCH {USER_ROLES}" not in mock_router.paths() + + +async def test_a_set_role_reports_the_role_the_user_holds_afterwards( + mcp_root_server, mock_api_client, mock_router +): + """The authoritative state is read back rather than assumed from the PATCH.""" + mock_router.add( + "GET", + USER, + json=jsonapi_document( + jsonapi_resource("users", "u1", {"name": "Ada"}), + included=[jsonapi_resource("roles", "r1", ROLE_ATTRIBUTES)], + ), + ) + mock_router.add( + "GET", + USER, + json=jsonapi_document( + jsonapi_resource("users", "u1", {"name": "Ada"}), + included=[jsonapi_resource("roles", "r2", ROLE_ATTRIBUTES)], + ), + ) + mock_router.add( + "GET", + ROLE, + json=jsonapi_document(jsonapi_resource("roles", "r2", ROLE_ATTRIBUTES)), + ) + mock_router.add("PATCH", USER_ROLES, status=204) + + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_set_user_role", {"user_id": "u1", "role_id": "r2"} + ) + + assert result.data["changed"] is True + assert [role["id"] for role in result.data["roles"]] == ["r2"] + assert mock_router.json_body("PATCH", USER_ROLES) == { + "data": [{"type": "roles", "id": "r2"}] + } diff --git a/mcp_server/tests/prowler_app/tools/test_scans.py b/mcp_server/tests/prowler_app/tools/test_scans.py new file mode 100644 index 0000000000..93e18c9a69 --- /dev/null +++ b/mcp_server/tests/prowler_app/tools/test_scans.py @@ -0,0 +1,201 @@ +"""Tests for the scans tools. + +Both write tools here used to answer a failure with a result object that the +protocol, the client and the model all read as a success, and both are calls +whose outcome an agent acts on: + +* ``prowler_trigger_scan`` starts work. Anything that reads as "nothing + happened" invites a second scan of the same provider. +* ``prowler_schedule_daily_scan`` was deciding whether the schedule existed by + reading the state of a different thing entirely -- the first scan Prowler + starts alongside it -- so a schedule that had just been created could be + reported as a failure. Retrying that can only hit the 409 the API raises for a + provider that already has one. + +Tools are driven through an in-memory MCP client so FastMCP resolves the +pydantic ``Field`` defaults and a raised failure arrives the way a client sees +it. +""" + +import pytest +from fastmcp import Client + +from tests.helpers.http import MockRouter +from tests.helpers.jsonapi import ( + jsonapi_document, + jsonapi_error, + jsonapi_resource, +) + +SCANS = "/api/v1/scans" +SCAN = f"{SCANS}/s1" +DAILY = "/api/v1/schedules/daily" + +SCAN_ATTRIBUTES = { + "name": "Nightly", + "trigger": "manual", + "state": "executing", + "progress": 40, +} + + +def stub_scan_creation(mock_router: MockRouter, scan_id: str = "s1") -> MockRouter: + """Serve the creation as Prowler does: a task carrying the new scan's ID.""" + return mock_router.add( + "POST", + SCANS, + json=jsonapi_document( + jsonapi_resource("tasks", "t1", {"task_args": {"scan_id": scan_id}}) + ), + ) + + +# ------------------------------------------------------------- trigger_scan + + +async def test_a_refused_scan_is_an_error_not_a_failed_looking_result( + mcp_root_server, mock_api_client, mock_router +): + """A rejection has to leave the tool as an error. + + Returned as a result it arrives with ``isError: false``, and a model reading + a successful tool call has no reason to doubt that a scan is now running. + """ + mock_router.add( + "POST", SCANS, status=403, json=jsonapi_error(403, "Insufficient permissions.") + ) + + async with Client(mcp_root_server) as client: + with pytest.raises(Exception, match="not allowed to do this"): + await client.call_tool("prowler_trigger_scan", {"provider_id": "p1"}) + + +async def test_a_scan_with_no_id_back_warns_before_a_blind_retry( + mcp_root_server, mock_api_client, mock_router +): + """Prowler accepted it, so a second call could start a duplicate scan. + + The message names the provider because that is what makes the suggested + check actionable without another lookup. + """ + mock_router.add("POST", SCANS, json={"data": {"attributes": {"task_args": {}}}}) + + async with Client(mcp_root_server) as client: + with pytest.raises(Exception, match="did not return its ID"): + await client.call_tool("prowler_trigger_scan", {"provider_id": "p1"}) + + assert mock_router.paths() == [f"POST {SCANS}"] + + +async def test_a_scan_whose_read_back_fails_still_hands_over_the_id( + mcp_root_server, mock_api_client, mock_router +): + """The scan is running; only reading it back went wrong. + + Reporting the read failure alone would read as "the scan was not created" + and invite a duplicate, so the error carries the ID to monitor instead. + """ + stub_scan_creation(mock_router) + mock_router.add("GET", SCAN, status=400, json=jsonapi_error(400, "Server error.")) + + async with Client(mcp_root_server) as client: + with pytest.raises(Exception, match="Scan s1 was created") as raised: + await client.call_tool("prowler_trigger_scan", {"provider_id": "p1"}) + + # Naming the scan is the whole reason this message exists, so it is written + # here rather than assembled from the failure. Splicing the failure text in + # would put whatever it happens to say in front of the model unclassified, + # which is the one thing the shared classifier exists to decide. + assert "API request failed" not in str(raised.value) + + +async def test_a_created_scan_comes_back_with_its_details( + mcp_root_server, mock_api_client, mock_router +): + """The success path still reports the scan, which is what gets monitored.""" + stub_scan_creation(mock_router) + mock_router.add( + "GET", + SCAN, + json=jsonapi_document(jsonapi_resource("scans", "s1", SCAN_ATTRIBUTES)), + ) + + async with Client(mcp_root_server) as client: + result = await client.call_tool("prowler_trigger_scan", {"provider_id": "p1"}) + + assert result.data["scan"]["id"] == "s1" + # No status flag: a scan that was not created is raised, so "success" could + # only ever be the one value and says nothing a reader can act on. + assert "status" not in result.data + + +# ------------------------------------------------------ schedule_daily_scan + + +@pytest.mark.parametrize("first_run_state", ["available", "scheduled", "executing"]) +async def test_a_schedule_is_reported_created_whatever_the_first_run_does( + mcp_root_server, mock_api_client, mock_router, first_run_state +): + """The task in the answer is the first scan run, not the schedule. + + Prowler commits the recurring schedule inside the request that serves this + call, so an answer at all means it exists. Reading that task's state as the + outcome of the scheduling reported a schedule that had just been created as + a failure. + """ + mock_router.add( + "POST", + DAILY, + json=jsonapi_document( + jsonapi_resource("tasks", "t1", {"state": first_run_state}) + ), + ) + + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_schedule_daily_scan", {"provider_id": "p1"} + ) + + assert result.data["first_run_state"] == first_run_state + assert "every 24 hours" in result.data["message"] + assert "scheduled" not in result.data + + +async def test_a_failed_first_run_leaves_the_schedule_standing( + mcp_root_server, mock_api_client, mock_router +): + """Worth saying, but it is not the schedule that failed. + + The gap it leaves is real -- no findings until tomorrow -- so the message + points at the manual scan that fills it rather than at the scheduling. + """ + mock_router.add( + "POST", + DAILY, + json=jsonapi_document(jsonapi_resource("tasks", "t1", {"state": "failed"})), + ) + + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_schedule_daily_scan", {"provider_id": "p1"} + ) + + assert result.data["first_run_state"] == "failed" + assert "schedule is unaffected" in result.data["message"] + assert "prowler_trigger_scan" in result.data["message"] + + +async def test_an_existing_schedule_is_relayed_as_the_api_explains_it( + mcp_root_server, mock_api_client, mock_router +): + """The 409 already says the useful thing, so the classifier relays it.""" + mock_router.add( + "POST", + DAILY, + status=409, + json=jsonapi_error(409, "There is already a scheduled scan for this provider."), + ) + + async with Client(mcp_root_server) as client: + with pytest.raises(Exception, match="already a scheduled scan"): + await client.call_tool("prowler_schedule_daily_scan", {"provider_id": "p1"}) diff --git a/mcp_server/tests/prowler_app/utils/test_auth.py b/mcp_server/tests/prowler_app/utils/test_auth.py index d39e5826d7..3c822cf2dc 100644 --- a/mcp_server/tests/prowler_app/utils/test_auth.py +++ b/mcp_server/tests/prowler_app/utils/test_auth.py @@ -6,8 +6,12 @@ Reference for later branches: ``ProwlerAppAuth`` resolves its ``mode`` and and ``base_url=`` explicitly, as these tests do. """ +import base64 +import json + import pytest +from prowler_mcp_server.lib.errors import CredentialError from prowler_mcp_server.prowler_app.utils.auth import ProwlerAppAuth from tests.helpers.tokens import FAKE_API_KEY, MALFORMED_API_KEY, fake_jwt @@ -42,13 +46,92 @@ async def test_http_mode_accepts_a_bearer_api_key(http_request_headers): assert await auth.get_valid_token() == FAKE_API_KEY +def _jwt_with_payload(payload: object) -> str: + """Mint an unsigned JWT carrying an arbitrary payload. + + ``fake_jwt`` always writes a well-formed object, so the malformed payloads + below are built here instead. + """ + encoded = ( + base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=") + ) + return f"header.{encoded}.fake-signature-not-verified" + + +async def test_http_mode_accepts_a_lowercase_bearer_scheme(http_request_headers): + """Authentication scheme names are case-insensitive (RFC 7235).""" + http_request_headers(authorization=f"bearer {FAKE_API_KEY}") + + auth = ProwlerAppAuth(mode="http") + + assert await auth.get_valid_token() == FAKE_API_KEY + + +async def test_http_mode_strips_only_the_scheme_prefix(http_request_headers): + """A token that repeats the scheme keeps it: only the prefix is removed.""" + token = f"{FAKE_API_KEY}_Bearer_suffix" + http_request_headers(authorization=f"Bearer {token}") + + auth = ProwlerAppAuth(mode="http") + + assert await auth.get_valid_token() == token + + +async def test_http_mode_rejects_an_authorization_header_without_a_token( + http_request_headers, +): + """A bare scheme carries no credential to authenticate with.""" + http_request_headers(authorization="Bearer ") + + auth = ProwlerAppAuth(mode="http") + + with pytest.raises(CredentialError, match="'Bearer ' form"): + await auth.get_valid_token() + + +async def test_http_mode_rejects_a_jwt_whose_payload_is_not_an_object( + http_request_headers, +): + """A payload that decodes to a list has no claims, so it is a bad credential. + + Without the type check it would reach `payload.get` and fail as an + unclassified `AttributeError`, which the client only sees masked. + """ + http_request_headers(authorization=f"Bearer {_jwt_with_payload(['exp'])}") + + auth = ProwlerAppAuth(mode="http") + + with pytest.raises(CredentialError, match="not a readable JWT"): + await auth.get_valid_token() + + +@pytest.mark.parametrize( + ("payload", "case"), + [ + ({"sub": "user"}, "missing"), + ({"exp": "1700000000"}, "string"), + ({"exp": None}, "null"), + ], +) +async def test_http_mode_rejects_a_jwt_without_a_numeric_expiration( + http_request_headers, payload: dict, case: str +): + """`exp` is a numeric date: comparing anything else raises a `TypeError`.""" + http_request_headers(authorization=f"Bearer {_jwt_with_payload(payload)}") + + auth = ProwlerAppAuth(mode="http") + + with pytest.raises(CredentialError, match="no readable 'exp' expiration claim"): + await auth.get_valid_token() + + async def test_http_mode_rejects_an_expired_jwt(http_request_headers): """An expired JWT is refused locally instead of being forwarded to the API.""" http_request_headers(authorization=f"Bearer {fake_jwt(expires_in=-60)}") auth = ProwlerAppAuth(mode="http") - with pytest.raises(ValueError, match="Token has expired"): + with pytest.raises(CredentialError, match="The token has expired"): await auth.get_valid_token() diff --git a/mcp_server/tests/prowler_documentation/test_server.py b/mcp_server/tests/prowler_documentation/test_server.py index da5e9f17f4..67ee57decf 100644 --- a/mcp_server/tests/prowler_documentation/test_server.py +++ b/mcp_server/tests/prowler_documentation/test_server.py @@ -1,7 +1,9 @@ -"""Tests for the Prowler documentation search tool. +"""Tests for the Prowler documentation tools. Mintlify moved the docs search to a new endpoint that answers with page -sections, so a result is a part of a page and has to read as one. +sections, so a result is a part of a page and has to read as one. And a failed +request must not reach an agent as "the documentation has nothing on this", +which is an answer it would act on, confidently and wrongly. """ import json @@ -9,6 +11,7 @@ import json from fastmcp import Client SEARCH = "/api/search/prowler" +DOC = "/getting-started/installation.md" def search_match( @@ -107,3 +110,65 @@ async def test_page_size_caps_a_response_the_api_did_not_size( ) assert len(result.data) == 2 + + +async def test_a_search_that_failed_is_not_reported_as_no_matches( + mcp_root_server, docs_router +): + """An empty list is an answer. A failed request is not, and must not look like one.""" + docs_router.add("POST", SEARCH, status=500, text="upstream error") + + async with Client(mcp_root_server) as client: + result = await client.call_tool_mcp("prowler_docs_search", {"term": "install"}) + + assert result.isError is True + assert result.structuredContent is None + + +async def test_a_missing_page_fails_and_names_the_tool_that_finds_a_valid_path( + mcp_root_server, docs_router +): + """A 404 answers the question, and still reaches the agent as an error.""" + docs_router.add("GET", DOC, status=404, text="Not Found") + + async with Client(mcp_root_server) as client: + result = await client.call_tool_mcp( + "prowler_docs_get_document", {"doc_path": "getting-started/installation"} + ) + + assert result.isError is True + assert "prowler_docs_search" in result.content[0].text + + +async def test_a_fetch_that_failed_is_not_reported_as_a_missing_page( + mcp_root_server, docs_router +): + """Only a 404 answers the question; every other status left it unanswered.""" + docs_router.add("GET", DOC, status=503, text="upstream error") + + async with Client(mcp_root_server) as client: + result = await client.call_tool_mcp( + "prowler_docs_get_document", {"doc_path": "getting-started/installation"} + ) + + assert result.isError is True + assert "no page at" not in result.content[0].text + + +async def test_an_unreadable_body_is_not_reported_as_a_bad_search_term( + mcp_root_server, docs_router +): + """An edge serving HTML is the site's fault; the caller has no term to fix.""" + docs_router.add("POST", SEARCH, status=200, text="edge error page") + + async with Client(mcp_root_server) as client: + result = await client.call_tool_mcp("prowler_docs_search", {"term": "install"}) + + assert result.isError is True + message = result.content[0].text + # Named as the upstream at fault, and explicitly not the caller's arguments, + # which is the story the shared ValueError branch would otherwise tell. + assert "leaves.mintlify.com" in message + assert "changing them will not help" in message + # The body it choked on is upstream text, which this server never relays. + assert "edge error page" not in message diff --git a/mcp_server/tests/prowler_hub/test_server.py b/mcp_server/tests/prowler_hub/test_server.py new file mode 100644 index 0000000000..50711aa1fa --- /dev/null +++ b/mcp_server/tests/prowler_hub/test_server.py @@ -0,0 +1,335 @@ +"""Tests for the Prowler Hub tools. + +The Hub sub-server uses its own httpx clients, so its failures never pass through +the Prowler API client. They still have to arrive as tool errors rather than as a +result object, which the protocol, the client and the model all read as a success. +""" + +import pytest +from fastmcp import Client + +CHECKS = "/api/check" +PROVIDERS = "/api/providers" +COMPLIANCE = "/api/compliance" +CHECK_ID = "s3_bucket_public_access" +HUB_CHECK = f"{CHECKS}/{CHECK_ID}" + + +def github_check(provider: str, suffix: str = ".py") -> str: + """The raw.githubusercontent path a check artifact is fetched from.""" + return ( + f"/prowler-cloud/prowler/refs/heads/master/prowler/providers/{provider}" + f"/services/s3/{CHECK_ID}/{CHECK_ID}{suffix}" + ) + + +GITHUB_CHECK = github_check("aws") +GITHUB_FIXER = github_check("aws", "_fixer.py") + + +async def test_listing_checks_returns_the_lightweight_shape( + mcp_root_server, hub_router +): + """The happy path, so the failure tests below are not the only coverage.""" + hub_router.add( + "GET", + CHECKS, + json=[ + { + "id": "s3_bucket_public_access", + "provider": "aws", + "title": "S3 buckets should block public access", + "severity": "high", + } + ], + ) + + async with Client(mcp_root_server) as client: + result = await client.call_tool("prowler_hub_list_checks", {}) + + assert result.data["count"] == 1 + assert result.data["checks"][0]["id"] == "s3_bucket_public_access" + + +async def test_an_unknown_check_fails_and_names_the_tool_that_finds_one( + mcp_root_server, hub_router +): + """A 404 is the caller's mistake, and the fix is a different tool.""" + hub_router.add("GET", f"{CHECKS}/nope", status=404) + + async with Client(mcp_root_server) as client: + result = await client.call_tool_mcp( + "prowler_hub_get_check_details", {"check_id": "nope"} + ) + + assert result.isError is True + assert "prowler_hub_semantic_search_checks" in result.content[0].text + + +async def test_an_unknown_provider_fails_and_lists_the_real_ones( + mcp_root_server, hub_router +): + """The valid values are already in hand, so withholding them wastes a call.""" + hub_router.add( + "GET", + PROVIDERS, + json=[{"id": "aws", "name": "Amazon Web Services", "services": ["s3"]}], + ) + + async with Client(mcp_root_server) as client: + result = await client.call_tool_mcp( + "prowler_hub_get_provider_services", {"provider_id": "alicloud"} + ) + + assert result.isError is True + assert "aws" in result.content[0].text + + +async def test_a_check_without_a_fixer_says_that_is_normal(mcp_root_server, hub_router): + """Most checks have no auto-remediation, so this must not read as a defect.""" + hub_router.add("GET", GITHUB_FIXER, status=404) + hub_router.add("GET", HUB_CHECK, json={"id": CHECK_ID, "provider": "aws"}) + + async with Client(mcp_root_server) as client: + result = await client.call_tool_mcp( + "prowler_hub_get_check_fixer", + {"provider_id": "aws", "check_id": CHECK_ID}, + ) + + assert result.isError is True + message = result.content[0].text + assert "normal" in message + # The Hub confirmed the check is an aws check, so nothing is left to verify. + assert "prowler_hub_get_check_details" not in message + + +async def test_a_check_from_another_provider_names_the_provider_that_has_it( + mcp_root_server, hub_router +): + """The ID exists; only the provider is wrong. Saying otherwise sends the + caller off to search for an ID they already hold.""" + hub_router.add("GET", github_check("azure"), status=404) + hub_router.add("GET", HUB_CHECK, json={"id": CHECK_ID, "provider": "aws"}) + + async with Client(mcp_root_server) as client: + result = await client.call_tool_mcp( + "prowler_hub_get_check_code", + {"provider_id": "azure", "check_id": CHECK_ID}, + ) + + assert result.isError is True + message = result.content[0].text + assert "provider_id='aws'" in message + assert "No check with the ID" not in message + + +async def test_a_fixer_from_another_provider_is_not_reported_as_a_missing_fixer( + mcp_root_server, hub_router +): + """'That check has no fixer' about a check the provider never had is a lie + the caller cannot act on.""" + hub_router.add("GET", github_check("azure", "_fixer.py"), status=404) + hub_router.add("GET", HUB_CHECK, json={"id": CHECK_ID, "provider": "aws"}) + + async with Client(mcp_root_server) as client: + result = await client.call_tool_mcp( + "prowler_hub_get_check_fixer", + {"provider_id": "azure", "check_id": CHECK_ID}, + ) + + assert result.isError is True + message = result.content[0].text + assert "provider_id='aws'" in message + assert "auto-remediation" not in message + + +async def test_a_check_id_that_exists_nowhere_is_still_reported_as_unknown( + mcp_root_server, hub_router +): + """The Hub not having the ID either is the one case that does justify the + original message.""" + hub_router.add("GET", github_check("azure"), status=404) + hub_router.add("GET", HUB_CHECK, status=404) + + async with Client(mcp_root_server) as client: + result = await client.call_tool_mcp( + "prowler_hub_get_check_code", + {"provider_id": "azure", "check_id": CHECK_ID}, + ) + + assert result.isError is True + assert "No check with the ID" in result.content[0].text + + +async def test_an_unreachable_hub_leaves_the_cause_open_rather_than_guessing( + mcp_root_server, hub_router +): + """With nothing to distinguish the causes, naming one of them is a guess.""" + hub_router.add("GET", github_check("azure"), status=404) + hub_router.add("GET", HUB_CHECK, status=503) + + async with Client(mcp_root_server) as client: + result = await client.call_tool_mcp( + "prowler_hub_get_check_code", + {"provider_id": "azure", "check_id": CHECK_ID}, + ) + + assert result.isError is True + message = result.content[0].text + assert "No check with the ID" not in message + assert "prowler_hub_get_check_details" in message + + +async def test_a_check_code_hit_never_asks_the_hub(mcp_root_server, hub_router): + """The Hub lookup exists to explain a 404. On the happy path it is dead + weight -- a second round trip for every call that already succeeded.""" + hub_router.add("GET", GITHUB_CHECK, text="class s3_bucket_public_access: ...") + + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_hub_get_check_code", + {"provider_id": "aws", "check_id": CHECK_ID}, + ) + + assert "class s3_bucket_public_access" in result.data["content"] + assert hub_router.paths() == [f"GET {GITHUB_CHECK}"] + + +async def test_a_hub_outage_is_reported_rather_than_returned_as_an_empty_list( + mcp_root_server, hub_router +): + """An empty result set and a failed request are different answers.""" + hub_router.add("GET", CHECKS, status=500, json={"detail": "boom"}) + + async with Client(mcp_root_server) as client: + result = await client.call_tool_mcp("prowler_hub_list_checks", {}) + + assert result.isError is True + assert result.structuredContent is None + + +@pytest.mark.parametrize( + ("check_id", "routed_as", "sent_as"), + [ + ("../../evil", f"{CHECKS}/../../evil", b"/api/check/..%2F..%2Fevil"), + ("s3/../evil", f"{CHECKS}/s3/../evil", b"/api/check/s3%2F..%2Fevil"), + ("..", f"{CHECKS}/..", b"/api/check/%2E%2E"), + ( + "s3_x?fields=all", + f"{CHECKS}/s3_x?fields=all", + b"/api/check/s3_x%3Ffields%3Dall", + ), + ("s3_x#frag", f"{CHECKS}/s3_x#frag", b"/api/check/s3_x%23frag"), + ], + ids=["traversal", "mid-path", "dot-segment", "query", "fragment"], +) +async def test_an_id_names_a_check_and_cannot_name_an_endpoint( + mcp_root_server, hub_router, check_id, routed_as, sent_as +): + """The bug this pins: httpx resolved "../.." away and the request left + /api/check for another endpoint of the Hub.""" + hub_router.add("GET", routed_as, status=404) + + async with Client(mcp_root_server) as client: + result = await client.call_tool_mcp( + "prowler_hub_get_check_details", {"check_id": check_id} + ) + + assert hub_router.requests[0].url.raw_path == sent_as + assert result.isError is True + assert "No check with the ID" in result.content[0].text + + +async def test_a_compliance_id_cannot_name_an_endpoint_either( + mcp_root_server, hub_router +): + """Every Hub path is built by the same helper, so this holds without its own + guard.""" + hub_router.add("GET", f"{COMPLIANCE}/../../evil", status=404) + + async with Client(mcp_root_server) as client: + result = await client.call_tool_mcp( + "prowler_hub_get_compliance_details", {"compliance_id": "../../evil"} + ) + + assert hub_router.requests[0].url.raw_path == b"/api/compliance/..%2F..%2Fevil" + assert result.isError is True + assert "No compliance framework with the ID" in result.content[0].text + + +async def test_a_check_source_url_confines_the_provider_and_the_check_alike( + mcp_root_server, hub_router +): + """Both halves of the GitHub raw URL come from the caller, so both are + confined.""" + hub_router.add("GET", github_check("../../../../evil"), status=404) + hub_router.add("GET", HUB_CHECK, json={"id": CHECK_ID, "provider": "aws"}) + + async with Client(mcp_root_server) as client: + result = await client.call_tool_mcp( + "prowler_hub_get_check_code", + {"provider_id": "../../../../evil", "check_id": CHECK_ID}, + ) + + assert ( + hub_router.requests[0].url.raw_path + == github_check("..%2F..%2F..%2F..%2Fevil").encode() + ) + assert result.isError is True + + +async def test_a_hub_body_that_is_not_json_is_not_blamed_on_the_arguments( + mcp_root_server, hub_router +): + """An edge answering 200 with an HTML page decodes to the same + `JSONDecodeError` a malformed argument does, and the two mean opposite + things: nothing in this call can be corrected.""" + hub_router.add("GET", CHECKS, text="502 Bad Gateway") + + async with Client(mcp_root_server) as client: + result = await client.call_tool_mcp("prowler_hub_list_checks", {}) + + assert result.isError is True + message = result.content[0].text + assert "hub.prowler.com" in message + assert "could not read as JSON" in message + assert "Bad Gateway" not in message + assert "Send it as a real object" not in message + + +async def test_an_unreadable_hub_answer_does_not_become_an_unknown_check( + mcp_root_server, hub_router +): + """The 404 branch is the only one that may claim the ID does not exist. A + body that could not be read says nothing about the ID.""" + hub_router.add("GET", HUB_CHECK, text="not json") + + async with Client(mcp_root_server) as client: + result = await client.call_tool_mcp( + "prowler_hub_get_check_details", {"check_id": CHECK_ID} + ) + + assert result.isError is True + message = result.content[0].text + assert "could not read as JSON" in message + assert "No check with the ID" not in message + + +async def test_an_unreadable_hub_answer_leaves_a_missing_check_file_unexplained( + mcp_root_server, hub_router +): + """The Hub is asked which provider owns the check. A body it could not read + is no more of an answer than an outage, so it hedges the same way.""" + hub_router.add("GET", github_check("azure"), status=404) + hub_router.add("GET", HUB_CHECK, text="not json") + + async with Client(mcp_root_server) as client: + result = await client.call_tool_mcp( + "prowler_hub_get_check_code", + {"provider_id": "azure", "check_id": CHECK_ID}, + ) + + assert result.isError is True + message = result.content[0].text + assert "No check with the ID" not in message + assert "prowler_hub_get_check_details" in message diff --git a/permissions/prowler-additions-policy.json b/permissions/prowler-additions-policy.json index 25ea46b09d..be17979d2e 100644 --- a/permissions/prowler-additions-policy.json +++ b/permissions/prowler-additions-policy.json @@ -38,6 +38,7 @@ "glue:GetSecurityConfiguration*", "glue:SearchTables", "glue:GetMLTransforms", + "inspector2:BatchGetFindingDetails", "lambda:GetFunction*", "lambda:GetLayerVersion", "logs:FilterLogEvents", diff --git a/permissions/templates/cloudformation/prowler-scan-role.yml b/permissions/templates/cloudformation/prowler-scan-role.yml index d04c8f25d6..d31dea9846 100644 --- a/permissions/templates/cloudformation/prowler-scan-role.yml +++ b/permissions/templates/cloudformation/prowler-scan-role.yml @@ -210,6 +210,7 @@ Resources: - "glue:GetSecurityConfiguration*" - "glue:SearchTables" - "glue:GetMLTransforms" + - "inspector2:BatchGetFindingDetails" - "lambda:GetFunction*" - "logs:FilterLogEvents" - "lightsail:GetRelationalDatabases" @@ -479,6 +480,7 @@ Resources: - "glue:GetSecurityConfiguration*" - "glue:SearchTables" - "glue:GetMLTransforms" + - "inspector2:BatchGetFindingDetails" - "lambda:GetFunction*" - "logs:FilterLogEvents" - "lightsail:GetRelationalDatabases" diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 401d8a7a1a..7232bd41a0 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -4,6 +4,76 @@ All notable changes to the **Prowler SDK** are documented in this file. +## [5.42.0] (Prowler v5.42.0) + +### 🚀 Added + +- AWS ISO partitions (`aws-iso`, `aws-iso-b`, `aws-iso-e` and `aws-iso-f`) to the AWS service region matrix, generated from the endpoints data bundled with botocore [(#12759)](https://github.com/prowler-cloud/prowler/pull/12759) +- `--aws-connect-timeout` and `--aws-read-timeout` CLI flags, plus `PROWLER_AWS_BOTO3_CONNECT_TIMEOUT` and `PROWLER_AWS_BOTO3_READ_TIMEOUT` environment variables, to bound how long each AWS API call waits for an endpoint [(#12774)](https://github.com/prowler-cloud/prowler/pull/12774) + +### 🔄 Changed + +- AWS provider default Boto3 connect timeout lowered from 60 to 10 seconds, so scans in restricted-egress networks (VPC endpoints for a subset of services, GovCloud, private deployments) no longer spend 4 minutes per region on every service whose endpoint is unreachable [(#12774)](https://github.com/prowler-cloud/prowler/pull/12774) + +### 🐞 Fixed + +- Duplicate requirement ids, checks listed twice in a requirement and references to non-existent checks across compliance frameworks, now guarded by a catalog integrity test [(#12717)](https://github.com/prowler-cloud/prowler/pull/12717) +- Duplicate requirement `3.2.1` in ProwlerThreatScore for Azure (SQL auditing retention is now `3.2.4`) and doubled check id in requirement `1.2.1` of ProwlerThreatScore for GCP [(#12717)](https://github.com/prowler-cloud/prowler/pull/12717) +- Jira connection checks no longer log an error when a single project has no issue types visible to the integration user (typically a missing "create issue" permission on that project), a case the caller already treats as non-fatal [(#12742)](https://github.com/prowler-cloud/prowler/pull/12742) +- `Jira.test_connection()` now fetches each project's issue types concurrently instead of one request at a time, so accounts with many Jira projects no longer take tens of seconds (unbounded, scaling with the project count) to verify the connection [(#12742)](https://github.com/prowler-cloud/prowler/pull/12742) +- `AwsProvider.get_available_aws_service_regions()` now returns an empty set for an unknown service or partition instead of raising `KeyError`, so a service unavailable in the audited partition is skipped [(#12759)](https://github.com/prowler-cloud/prowler/pull/12759) +- `AwsProvider.generate_regional_clients()` now returns an empty dict instead of `None` when the regional clients cannot be built, a failure that surfaced later as `AttributeError: 'NoneType' object has no attribute 'values'` [(#12759)](https://github.com/prowler-cloud/prowler/pull/12759) +- `AwsProvider.get_global_region()` now returns a real region for each ISO partition instead of the `aws-iso-global` pseudo endpoint, which collapsed the four partitions into one answer [(#12759)](https://github.com/prowler-cloud/prowler/pull/12759) +- Bootstrap STS calls now use the session region when `PROWLER_AWS_PARTITION` is set and the region belongs to that partition, instead of always going to the partition's global STS region, which a deployment reached only through its own region's VPC endpoints cannot route to [(#12764)](https://github.com/prowler-cloud/prowler/pull/12764) +- The Image provider now uses the directory named by `TRIVY_CACHE_DIR` when one is set, instead of a fresh temporary directory it deletes afterwards, so a deployment can supply a vulnerability database it already holds and one with network access stops re-downloading the database for every image it scans [(#12773)](https://github.com/prowler-cloud/prowler/pull/12773) +- `--aws-retries-max-attempts 0` now disables Boto3 retries instead of being silently ignored in favour of the default of 3 [(#12774)](https://github.com/prowler-cloud/prowler/pull/12774) +- `rolesanywhere_profile_restricts_session_permissions`, `iam_role_service_trust_restricts_source_to_account` and `codebuild_project_uses_allowed_github_organizations` crashing with `TypeError` when `iam:ListRoles` is denied [(#12785)](https://github.com/prowler-cloud/prowler/pull/12785) + +--- + +## [5.41.0] (Prowler v5.41.0) + +### 🚀 Added + +- `memorydb_cluster_in_transit_encryption_enabled` check for AWS provider, verifying MemoryDB clusters have in-transit encryption (TLS) enabled [(#12246)](https://github.com/prowler-cloud/prowler/pull/12246) +- `elasticbeanstalk_environment_no_secrets_in_configuration` check for AWS provider, scanning the option settings of every Elastic Beanstalk environment for hardcoded secrets [(#12378)](https://github.com/prowler-cloud/prowler/pull/12378) +- CIS Google Workspace Foundations Benchmark v1.4.0 compliance framework [(#12513)](https://github.com/prowler-cloud/prowler/pull/12513) +- `Jira.send_finding()` returns typed creation outcomes with the issue key, immutable ID and browse URL, and supports length-safe finding and delivery-attempt labels plus marker lookup for uncertain deliveries [(#12539)](https://github.com/prowler-cloud/prowler/pull/12539) +- `Jira.get_issues_status()` resolves issue references by immutable ID in batches and returns explicit open, done, moved, missing, forbidden or unknown outcomes without treating missing issues as deleted [(#12539)](https://github.com/prowler-cloud/prowler/pull/12539) +- `guardduty_ai_protection_enabled` check for AWS provider, flagging GuardDuty detectors without AI Protection, which analyzes AWS CloudTrail data events from Amazon Bedrock, Amazon Bedrock AgentCore and Amazon SageMaker AI; a detector that does not report the feature is `MANUAL` rather than `FAIL` [(#12564)](https://github.com/prowler-cloud/prowler/pull/12564) +- `guardduty_runtime_monitoring_enabled` check for AWS provider, flagging GuardDuty detectors without unified Runtime Monitoring, the only feature that covers Amazon EC2 instances and Amazon ECS on AWS Fargate tasks in addition to Amazon EKS [(#12564)](https://github.com/prowler-cloud/prowler/pull/12564) +- `ecr_registry_enhanced_scanning_enabled` check for AWS provider, verifying that the ECR registry scan type is enhanced (Amazon Inspector, covering programming language packages and continuous rescanning) instead of basic, and reporting MANUAL when the registry scanning configuration cannot be read [(#12660)](https://github.com/prowler-cloud/prowler/pull/12660) +- `eks_cluster_vpc_cni_network_policy_enforced` check for AWS provider, flagging EKS clusters whose Amazon VPC CNI managed add-on does not enable Kubernetes network policy enforcement, and reporting MANUAL where the EKS API cannot show the setting [(#12661)](https://github.com/prowler-cloud/prowler/pull/12661) +- `cloudwatch_log_group_agentcore_data_protection_policy_enabled` check for AWS provider, verifying that Bedrock AgentCore log groups mask sensitive data with a CloudWatch Logs data protection policy [(#12662)](https://github.com/prowler-cloud/prowler/pull/12662) +- `iam_policy_no_agentcore_workload_access_token_wildcard` check for AWS provider, flagging customer-managed IAM policies that allow `bedrock-agentcore:GetWorkloadAccessToken`, `GetWorkloadAccessTokenForJWT` or `GetWorkloadAccessTokenForUserId` on resources reaching workload identities other than the caller's own, which AWS documents as the only binding on the unverified user ID the token is issued for [(#12664)](https://github.com/prowler-cloud/prowler/pull/12664) +- `iam_policy_passrole_to_bedrock_agentcore_restricted` check for AWS provider, flagging customer-managed IAM policies that allow `iam:PassRole` over every role where the passed role can reach Bedrock AgentCore, so any principal holding the policy can run agent code under any role in the account [(#12664)](https://github.com/prowler-cloud/prowler/pull/12664) +- `iam_role_service_trust_restricts_source_to_account` check for AWS provider, flagging IAM roles whose trust policy lets an AWS service principal assume the role without confining the request to a specific source account, including trust policies that `iam_role_cross_service_confused_deputy_prevention` does not evaluate [(#12664)](https://github.com/prowler-cloud/prowler/pull/12664) +- `PROWLER_IMAGE_PROVIDER_ALLOWED_PRIVATE_NETWORKS` environment variable so the image provider can reach container registries on allowlisted private networks, keeping every other non-public address blocked [(#12678)](https://github.com/prowler-cloud/prowler/pull/12678) +- `PROWLER_AWS_PARTITION` environment variable to select the AWS partition used for STS credential validation and scan bootstrap, with a clear error when the account belongs to a different partition [(#12680)](https://github.com/prowler-cloud/prowler/pull/12680) + +### 🔄 Changed + +- Google Workspace checks mapped to CIS evaluate the full audit procedure instead of a single condition, so Gmail spoofing actions, 2-Step Verification, password expiration and alert severity left on Google's defaults no longer pass [(#12513)](https://github.com/prowler-cloud/prowler/pull/12513) +- `security_login_challenges_configured` and `security_2sv_enforced` unmapped from CIS Google Workspace 4.1.4.1 and CISA SCuBA 0.6 `GWS.COMMONCONTROLS.1.1`, whose Post-SSO verification and phishing-resistant MFA requirements neither check can prove [(#12513)](https://github.com/prowler-cloud/prowler/pull/12513) + +### 🐞 Fixed + +- GitHub repository discovery for unscoped scans now paginates beyond the first 100 accessible repositories instead of silently scanning only the first page [(#12460)](https://github.com/prowler-cloud/prowler/pull/12460) +- `rules_*_alert_configured` checks no longer pass a rule whose delivery to the alert center is disabled, the setting behind the benchmark's "Ensure that Alerts is set to On" [(#12513)](https://github.com/prowler-cloud/prowler/pull/12513) +- `security_password_policy_strong` no longer fails a domain that never touched the password strength setting: Google enforces strong passwords by default, so an unset value is the secure default and not a missing configuration [(#12513)](https://github.com/prowler-cloud/prowler/pull/12513) +- `security_2sv_enforced` and `security_2sv_hardware_keys_admins` report MANUAL instead of judging domain-wide values that a group or a sub-organizational unit overrides, or that were dropped because the root organizational unit could not be resolved; a domain-wide failure is still reported as such, with the override noted [(#12513)](https://github.com/prowler-cloud/prowler/pull/12513) +- `ecr_registry_scan_images_on_push_enabled` no longer passes a registry whose scanning rules are all `MANUAL`, nor describes a `CONTINUOUS_SCAN` registry as scanning on push; each rule's `scanFrequency` is now read instead of inferred from a rule's presence [(#12560)](https://github.com/prowler-cloud/prowler/pull/12560) +- CloudWatch log metric filter checks no longer crash with `AttributeError` when the account has a metric filter whose log group was not retrieved [(#12561)](https://github.com/prowler-cloud/prowler/pull/12561) +- `guardduty_eks_runtime_monitoring_enabled` no longer reports `FAIL` for detectors that use unified Runtime Monitoring; the GuardDuty service now reads the `RUNTIME_MONITORING` feature, which is mutually exclusive with `EKS_RUNTIME_MONITORING` and already covers Amazon EKS [(#12564)](https://github.com/prowler-cloud/prowler/pull/12564) +- Checks no longer report `FAIL` when the scanning identity lacks permissions, an API is not enabled or a feature is not licensed; they now emit a single `MANUAL` finding naming what is required, across 28 M365, Azure, AWS and GCP checks [(#12645)](https://github.com/prowler-cloud/prowler/pull/12645) +- `sagemaker_notebook_instance_without_direct_internet_access_configured` check logic to read the `DirectInternetAccess` setting instead of `RootAccess`, failing a notebook instance with direct internet access enabled even when root access is disabled [(#12659)](https://github.com/prowler-cloud/prowler/pull/12659) +- Basic authentication fallback in the image provider when a registry rejects the negotiated bearer token, so registries like Harbor that guard catalog listing behind Basic can be enumerated [(#12678)](https://github.com/prowler-cloud/prowler/pull/12678) +- Registry catalog listing when the server answers with a Bearer challenge after negotiating Basic (or anonymous) authentication, switching to a bearer token obtained from the challenge instead of failing [(#12678)](https://github.com/prowler-cloud/prowler/pull/12678) +- `--registry-insecure` now propagates to Trivy via `TRIVY_INSECURE`, so images in registries with self-signed certificates can be pulled and scanned, not just enumerated [(#12678)](https://github.com/prowler-cloud/prowler/pull/12678) +- Registry scans in the Image provider now skip non-image OCI artifacts (Helm charts, cosign signatures, SBOM attestations) and no longer abort the whole scan when Trivy fails on a single discovered image; registry enumeration also runs in parallel instead of one request at a time [(#12695)](https://github.com/prowler-cloud/prowler/pull/12695) + +--- + ## [5.40.0] (Prowler v5.40.0) ### 🚀 Added diff --git a/prowler/changelog.d/aws-inspector2-fips-checks.added.md b/prowler/changelog.d/aws-inspector2-fips-checks.added.md new file mode 100644 index 0000000000..40c36f8062 --- /dev/null +++ b/prowler/changelog.d/aws-inspector2-fips-checks.added.md @@ -0,0 +1 @@ +`inspector2_coverage_scan_status_active`, `inspector2_coverage_recently_scanned`, `inspector2_active_findings_no_known_exploited_vulnerabilities`, `inspector2_active_findings_kev_within_due_date`, `inspector2_active_findings_within_max_age`, `elbv2_listener_fips_tls_enabled` and `transfer_server_fips_security_policy_enabled` checks for AWS provider, covering FedRAMP 20x Class C vulnerability detection, CISA KEV remediation and FIPS cryptography rules; the KEV checks require `inspector2:BatchGetFindingDetails`, now in the Prowler additions policy diff --git a/prowler/changelog.d/aws-partition-bootstrap-falls-back-to-the-next-region.fixed.md b/prowler/changelog.d/aws-partition-bootstrap-falls-back-to-the-next-region.fixed.md new file mode 100644 index 0000000000..4bd9c855d1 --- /dev/null +++ b/prowler/changelog.d/aws-partition-bootstrap-falls-back-to-the-next-region.fixed.md @@ -0,0 +1 @@ +Bootstrap STS calls now try up to two more regions of the partition declared in `PROWLER_AWS_PARTITION` when the first one cannot be reached, so a deployment that routes to only one region of its partition no longer fails on an endpoint it has no path to. This covers validating credentials, assuming a role and getting an MFA session token diff --git a/prowler/changelog.d/azure-sovereign-cloud-defender-keyvault-hosts.fixed.md b/prowler/changelog.d/azure-sovereign-cloud-defender-keyvault-hosts.fixed.md new file mode 100644 index 0000000000..99ef3354e0 --- /dev/null +++ b/prowler/changelog.d/azure-sovereign-cloud-defender-keyvault-hosts.fixed.md @@ -0,0 +1 @@ +Azure Defender security contacts and Key Vault key rotation policies now use the endpoints of the selected cloud (`--azure-region`) instead of the hardcoded `management.azure.com` and `vault.azure.net` hosts, so both work on `AzureUSGovernment` and `AzureChinaCloud` diff --git a/prowler/changelog.d/elasticbeanstalk-environment-no-secrets.added.md b/prowler/changelog.d/elasticbeanstalk-environment-no-secrets.added.md deleted file mode 100644 index 9f85bf8550..0000000000 --- a/prowler/changelog.d/elasticbeanstalk-environment-no-secrets.added.md +++ /dev/null @@ -1 +0,0 @@ -`elasticbeanstalk_environment_no_secrets_in_configuration` check for AWS provider, scanning the option settings of every Elastic Beanstalk environment for hardcoded secrets diff --git a/prowler/changelog.d/fedramp-20x-frr-class-c-2026.added.md b/prowler/changelog.d/fedramp-20x-frr-class-c-2026.added.md new file mode 100644 index 0000000000..147ad052f3 --- /dev/null +++ b/prowler/changelog.d/fedramp-20x-frr-class-c-2026.added.md @@ -0,0 +1 @@ +`FedRAMP-20x-FRR-Class-C` universal compliance framework (`fedramp_20x_frr_class_c_2026`) with the 158 provider rules of the FedRAMP 20x Class C ruleset from the FedRAMP Consolidated Rules 2026 for AWS, Azure, GCP, Kubernetes and M365 diff --git a/prowler/changelog.d/fedramp-20x-ksi-2026.added.md b/prowler/changelog.d/fedramp-20x-ksi-2026.added.md new file mode 100644 index 0000000000..5fae5bb4a2 --- /dev/null +++ b/prowler/changelog.d/fedramp-20x-ksi-2026.added.md @@ -0,0 +1 @@ +`FedRAMP-20x-KSI` universal compliance framework (`fedramp_20x_ksi_2026`) with the 46 Key Security Indicators from the FedRAMP Consolidated Rules 2026 mapped for AWS, Azure, GCP, Kubernetes and M365 diff --git a/prowler/changelog.d/fedramp-20x-ksi-low-pilot.removed.md b/prowler/changelog.d/fedramp-20x-ksi-low-pilot.removed.md new file mode 100644 index 0000000000..fdca7d54e5 --- /dev/null +++ b/prowler/changelog.d/fedramp-20x-ksi-low-pilot.removed.md @@ -0,0 +1 @@ +`fedramp_20x_ksi_low_aws`, `fedramp_20x_ksi_low_azure` and `fedramp_20x_ksi_low_gcp` FedRAMP 20x Phase One pilot frameworks, superseded by `fedramp_20x_ksi_2026` diff --git a/prowler/changelog.d/googleworkspace-2sv-all-users-overrides.fixed.md b/prowler/changelog.d/googleworkspace-2sv-all-users-overrides.fixed.md new file mode 100644 index 0000000000..1283ac91ad --- /dev/null +++ b/prowler/changelog.d/googleworkspace-2sv-all-users-overrides.fixed.md @@ -0,0 +1 @@ +`security_2sv_enforced` reports domain-wide 2-Step Verification failures as FAIL even when every failing setting is overridden for a group or organizational unit diff --git a/prowler/changelog.d/jira-finding-labels-url.added.md b/prowler/changelog.d/jira-finding-labels-url.added.md deleted file mode 100644 index 16266aba99..0000000000 --- a/prowler/changelog.d/jira-finding-labels-url.added.md +++ /dev/null @@ -1 +0,0 @@ -`Jira.send_finding()` returns typed creation outcomes with the issue key, immutable ID and browse URL, and supports length-safe finding and delivery-attempt labels plus marker lookup for uncertain deliveries diff --git a/prowler/changelog.d/jira-get-issues-status.added.md b/prowler/changelog.d/jira-get-issues-status.added.md deleted file mode 100644 index 4926418e7a..0000000000 --- a/prowler/changelog.d/jira-get-issues-status.added.md +++ /dev/null @@ -1 +0,0 @@ -`Jira.get_issues_status()` resolves issue references by immutable ID in batches and returns explicit open, done, moved, missing, forbidden or unknown outcomes without treating missing issues as deleted diff --git a/prowler/changelog.d/m365-defender-preset-policy-keyerror.fixed.md b/prowler/changelog.d/m365-defender-preset-policy-keyerror.fixed.md new file mode 100644 index 0000000000..2a50037e84 --- /dev/null +++ b/prowler/changelog.d/m365-defender-preset-policy-keyerror.fixed.md @@ -0,0 +1 @@ +`KeyError` in M365 Defender malware, anti-phishing and inbound anti-spam checks when the tenant has Standard or Strict preset security policies diff --git a/prowler/changelog.d/memorydb-cluster-in-transit-encryption-enabled.added.md b/prowler/changelog.d/memorydb-cluster-in-transit-encryption-enabled.added.md deleted file mode 100644 index 90ce26d446..0000000000 --- a/prowler/changelog.d/memorydb-cluster-in-transit-encryption-enabled.added.md +++ /dev/null @@ -1 +0,0 @@ -`memorydb_cluster_in_transit_encryption_enabled` check for AWS provider, verifying MemoryDB clusters have in-transit encryption (TLS) enabled diff --git a/prowler/changelog.d/sdk-image-debian-cves.security.md b/prowler/changelog.d/sdk-image-debian-cves.security.md new file mode 100644 index 0000000000..50bd4186f3 --- /dev/null +++ b/prowler/changelog.d/sdk-image-debian-cves.security.md @@ -0,0 +1 @@ +`libsqlite3-0`, `gzip`, `perl-base`, `libssh2-1t64` and `libpcre2-8-0` upgraded in the SDK container image, patching nine high Debian CVEs diff --git a/prowler/changelog.d/sdk-image-powershell-dotnet-cve.security.md b/prowler/changelog.d/sdk-image-powershell-dotnet-cve.security.md new file mode 100644 index 0000000000..370aa37289 --- /dev/null +++ b/prowler/changelog.d/sdk-image-powershell-dotnet-cve.security.md @@ -0,0 +1 @@ +PowerShell from 7.5.9 to 7.5.11 in the SDK container image, bundling .NET runtime 9.0.20 and patching CVE-2026-62901 diff --git a/prowler/changelog.d/smn-topic-subscriptions.added.md b/prowler/changelog.d/smn-topic-subscriptions.added.md new file mode 100644 index 0000000000..87ca0c8c28 --- /dev/null +++ b/prowler/changelog.d/smn-topic-subscriptions.added.md @@ -0,0 +1 @@ +`smn_topic_subscriptions` check for Huawei Cloud provider: SMN topics have at least one subscription configured diff --git a/prowler/compliance/aws/aws_account_security_onboarding_aws.json b/prowler/compliance/aws/aws_account_security_onboarding_aws.json index 1910bac223..1b6568005e 100644 --- a/prowler/compliance/aws/aws_account_security_onboarding_aws.json +++ b/prowler/compliance/aws/aws_account_security_onboarding_aws.json @@ -277,7 +277,6 @@ } ], "Checks": [ - "guardduty_is_enabled", "guardduty_is_enabled" ], "ConfigRequirements": [ @@ -497,7 +496,7 @@ "Checks": [] }, { - "Id": "Create Cost Anomaly Detection monitors to alert spending anomalies", + "Id": "Create Cost Anomaly Detection monitors to alert spending anomalies (Prod)", "Description": "Develop monitoring systems for detecting cost anomalies and generating alerts for irregular spending patterns.", "Attributes": [ { @@ -510,7 +509,7 @@ "Checks": [] }, { - "Id": "Create Cost Anomaly Detection monitors to alert spending anomalies", + "Id": "Create Cost Anomaly Detection monitors to alert spending anomalies (QA)", "Description": "Establish monitoring systems for cost anomaly detection to promptly notify about unusual spending patterns.", "Attributes": [ { @@ -618,7 +617,7 @@ ] }, { - "Id": "Export scan results as metrics in centralized collector", + "Id": "Export scan results as metrics in centralized collector (EC2)", "Description": "Export scan results as metrics to a centralized collector.", "Attributes": [ { @@ -667,7 +666,7 @@ ] }, { - "Id": "Export scan results as metrics in centralized collector", + "Id": "Export scan results as metrics in centralized collector (ECR)", "Description": "Generate metric data from scan results and store it in a centralized collector.", "Attributes": [ { @@ -1189,7 +1188,7 @@ ] }, { - "Id": "Export metrics in centralized collector", + "Id": "Export metrics in centralized collector (Shield Advanced)", "Description": "Exporting metrics to a centralized collector for data aggregation and analysis.", "Attributes": [ { @@ -1367,7 +1366,7 @@ "Checks": [] }, { - "Id": "Export metrics in centralized collector", + "Id": "Export metrics in centralized collector (WAFv2)", "Description": "Exporting metrics to a centralized collector for comprehensive data aggregation.", "Attributes": [ { diff --git a/prowler/compliance/aws/aws_ai_security_framework_aws.json b/prowler/compliance/aws/aws_ai_security_framework_aws.json index fa8c9d83a7..ceac752ff8 100644 --- a/prowler/compliance/aws/aws_ai_security_framework_aws.json +++ b/prowler/compliance/aws/aws_ai_security_framework_aws.json @@ -241,6 +241,7 @@ "iam_inline_policy_no_administrative_privileges", "iam_policy_allows_privilege_escalation", "iam_inline_policy_allows_privilege_escalation", + "iam_policy_passrole_to_bedrock_agentcore_restricted", "iam_role_administratoraccess_policy", "iam_user_administrator_access_policy", "iam_group_administrator_access_policy", @@ -269,6 +270,7 @@ "iam_user_no_setup_initial_access_key", "iam_user_two_active_access_key", "iam_user_console_access_unused", + "iam_policy_no_agentcore_workload_access_token_wildcard", "bedrock_api_key_no_long_term_credentials" ] }, @@ -305,6 +307,7 @@ ], "Checks": [ "iam_role_cross_service_confused_deputy_prevention", + "iam_role_service_trust_restricts_source_to_account", "iam_role_access_not_stale_to_bedrock", "iam_user_access_not_stale_to_bedrock", "iam_role_cross_account_readonlyaccess_policy" @@ -484,7 +487,8 @@ "awslambda_function_no_secrets_in_variables", "ecs_task_definitions_no_environment_secrets", "ec2_instance_secrets_user_data", - "cloudwatch_log_group_no_secrets_in_logs" + "cloudwatch_log_group_no_secrets_in_logs", + "cloudwatch_log_group_agentcore_data_protection_policy_enabled" ] }, { @@ -878,9 +882,11 @@ "guardduty_s3_protection_enabled", "guardduty_eks_audit_log_enabled", "guardduty_eks_runtime_monitoring_enabled", + "guardduty_runtime_monitoring_enabled", "guardduty_lambda_protection_enabled", "guardduty_rds_protection_enabled", - "guardduty_ec2_malware_protection_enabled" + "guardduty_ec2_malware_protection_enabled", + "guardduty_ai_protection_enabled" ], "ConfigRequirements": [ { @@ -1128,10 +1134,12 @@ "eks_cluster_not_publicly_accessible", "eks_cluster_private_nodes_enabled", "eks_cluster_network_policy_enabled", + "eks_cluster_vpc_cni_network_policy_enforced", "eks_cluster_uses_a_supported_version", "eks_control_plane_logging_all_types_enabled", "eks_cluster_kms_cmk_encryption_in_secrets_enabled", - "eks_cluster_deletion_protection_enabled" + "eks_cluster_deletion_protection_enabled", + "ecr_registry_enhanced_scanning_enabled" ] }, { @@ -1154,7 +1162,8 @@ "ecs_task_definitions_logging_enabled", "ecs_task_definitions_no_environment_secrets", "ecs_task_definitions_host_namespace_not_shared", - "ecs_cluster_container_insights_enabled" + "ecs_cluster_container_insights_enabled", + "ecr_registry_enhanced_scanning_enabled" ] }, { diff --git a/prowler/compliance/aws/aws_foundational_technical_review_aws.json b/prowler/compliance/aws/aws_foundational_technical_review_aws.json index 9d8e3cfdc8..d910a5aec7 100644 --- a/prowler/compliance/aws/aws_foundational_technical_review_aws.json +++ b/prowler/compliance/aws/aws_foundational_technical_review_aws.json @@ -334,7 +334,6 @@ "iam_role_cross_service_confused_deputy_prevention", "iam_root_hardware_mfa_enabled", "iam_root_mfa_enabled", - "iam_root_hardware_mfa_enabled", "iam_user_hardware_mfa_enabled", "iam_user_mfa_enabled_console_access", "iam_administrator_access_with_mfa" diff --git a/prowler/compliance/aws/aws_well_architected_framework_security_pillar_aws.json b/prowler/compliance/aws/aws_well_architected_framework_security_pillar_aws.json index 41458eea5b..12a59a6b06 100644 --- a/prowler/compliance/aws/aws_well_architected_framework_security_pillar_aws.json +++ b/prowler/compliance/aws/aws_well_architected_framework_security_pillar_aws.json @@ -472,11 +472,9 @@ "emr_cluster_publicly_accesible", "glacier_vaults_policy_public_access", "awslambda_function_not_publicly_accessible", - "awslambda_function_not_publicly_accessible", "rds_instance_no_public_access", "rds_snapshots_public_access", "kms_key_not_publicly_accessible", - "opensearch_service_domains_not_publicly_accessible", "redshift_cluster_public_access", "s3_account_level_public_access_blocks", "s3_bucket_public_access", @@ -493,7 +491,6 @@ "eks_cluster_not_publicly_accessible", "elb_internet_facing", "elbv2_internet_facing", - "s3_account_level_public_access_blocks", "sns_topics_not_publicly_accessible", "sqs_queues_not_publicly_accessible", "ssm_documents_set_as_public", @@ -553,7 +550,6 @@ "awslambda_function_invoke_api_operations_cloudtrail_logging_enabled", "cloudfront_distributions_logging_enabled", "cloudtrail_bedrock_logging_enabled", - "cloudtrail_cloudwatch_logging_enabled", "cloudtrail_logs_s3_bucket_access_logging_enabled", "directoryservice_directory_log_forwarding_enabled", "eks_control_plane_logging_all_types_enabled", @@ -771,7 +767,6 @@ "ec2_securitygroup_not_used", "ec2_securitygroup_with_many_ingress_egress_rules", "elbv2_desync_mitigation_mode", - "elbv2_desync_mitigation_mode", "route53_domains_privacy_protection_enabled", "route53_domains_transferlock_enabled", "shield_advanced_protection_in_associated_elastic_ips", diff --git a/prowler/compliance/aws/c5_aws.json b/prowler/compliance/aws/c5_aws.json index ce26e241e3..46b4ee0ed1 100644 --- a/prowler/compliance/aws/c5_aws.json +++ b/prowler/compliance/aws/c5_aws.json @@ -268,7 +268,6 @@ "iam_role_administratoraccess_policy", "iam_aws_attached_policy_no_administrative_privileges", "iam_customer_unattached_policy_no_administrative_privileges", - "iam_role_administratoraccess_policy", "iam_user_administrator_access_policy", "organizations_delegated_administrators", "cloudwatch_changes_to_network_route_tables_alarm_configured", @@ -1936,9 +1935,7 @@ "cloudwatch_changes_to_network_acls_alarm_configured", "cloudwatch_changes_to_network_gateways_alarm_configured", "cloudwatch_changes_to_network_route_tables_alarm_configured", - "cloudwatch_changes_to_vpcs_alarm_configured", - "cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled", - "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled" + "cloudwatch_changes_to_vpcs_alarm_configured" ] }, { @@ -3866,7 +3863,6 @@ } ], "Checks": [ - "acm_certificates_transparency_logs_enabled", "acm_certificates_transparency_logs_enabled", "apigateway_restapi_logging_enabled", "apigatewayv2_api_access_logging_enabled", @@ -3945,7 +3941,6 @@ "iam_root_hardware_mfa_enabled", "iam_root_mfa_enabled", "iam_user_hardware_mfa_enabled", - "iam_user_mfa_enabled_console_access", "s3_bucket_no_mfa_delete" ] }, @@ -5219,8 +5214,6 @@ "iam_customer_unattached_policy_no_administrative_privileges", "iam_group_administrator_access_policy", "iam_inline_policy_no_administrative_privileges", - "iam_policy_cloudshell_admin_not_attached", - "iam_role_administratoraccess_policy", "iam_user_administrator_access_policy", "organizations_delegated_administrators", "rds_cluster_default_admin", @@ -5291,8 +5284,6 @@ "iam_customer_unattached_policy_no_administrative_privileges", "iam_group_administrator_access_policy", "iam_inline_policy_no_administrative_privileges", - "iam_policy_cloudshell_admin_not_attached", - "iam_role_administratoraccess_policy", "iam_user_administrator_access_policy", "organizations_delegated_administrators", "rds_cluster_default_admin", @@ -6357,8 +6348,7 @@ ], "Checks": [ "cognito_user_pool_blocks_compromised_credentials_sign_in_attempts", - "cognito_user_pool_blocks_potential_malicious_sign_in_attempts", - "cognito_user_pool_blocks_compromised_credentials_sign_in_attempts" + "cognito_user_pool_blocks_potential_malicious_sign_in_attempts" ] }, { @@ -6670,7 +6660,6 @@ "sagemaker_training_jobs_intercontainer_encryption_enabled", "sagemaker_training_jobs_volume_and_output_encryption_enabled", "sns_topics_kms_encryption_at_rest_enabled", - "sqs_queues_server_side_encryption_enabled", "storagegateway_fileshare_encryption_enabled", "transfer_server_in_transit_encryption_enabled", "workspaces_volume_encryption_enabled" @@ -7696,13 +7685,11 @@ "dynamodb_accelerator_cluster_in_transit_encryption_enabled", "transfer_server_in_transit_encryption_enabled", "dms_endpoint_redis_in_transit_encryption_enabled", - "dynamodb_accelerator_cluster_in_transit_encryption_enabled", "ec2_transitgateway_auto_accept_vpc_attachments", "elasticache_redis_cluster_in_transit_encryption_enabled", "kafka_cluster_in_transit_encryption_enabled", "kafka_connector_in_transit_encryption_enabled", - "redshift_cluster_in_transit_encryption_enabled", - "transfer_server_in_transit_encryption_enabled" + "redshift_cluster_in_transit_encryption_enabled" ] }, { @@ -10967,7 +10954,6 @@ "kms_cmk_not_multi_region", "cloudfront_distributions_geo_restrictions_enabled", "cloudtrail_multi_region_enabled_logging_management_events", - "kms_cmk_not_multi_region", "organizations_scp_check_deny_regions", "s3_multi_region_access_point_public_access_block" ] diff --git a/prowler/compliance/aws/cis_5.0_aws.json b/prowler/compliance/aws/cis_5.0_aws.json index 0c8d46e170..d0c7320fc5 100644 --- a/prowler/compliance/aws/cis_5.0_aws.json +++ b/prowler/compliance/aws/cis_5.0_aws.json @@ -204,7 +204,7 @@ ] }, { - "Id": "1.1", + "Id": "1.10", "Description": "Do not create access keys during initial setup for IAM users with a console password", "Checks": [ "iam_user_no_setup_initial_access_key" diff --git a/prowler/compliance/aws/cisa_aws.json b/prowler/compliance/aws/cisa_aws.json index 27b06a1ea4..c3e7b427f0 100644 --- a/prowler/compliance/aws/cisa_aws.json +++ b/prowler/compliance/aws/cisa_aws.json @@ -58,14 +58,12 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_kms_encryption_enabled", "cloudtrail_log_file_validation_enabled", "codebuild_project_user_controlled_buildspec", "dynamodb_accelerator_cluster_encryption_enabled", "dynamodb_tables_kms_cmk_encryption_enabled", "dynamodb_tables_pitr_enabled", - "dynamodb_tables_pitr_enabled", "ec2_ebs_volume_encryption", "ec2_ebs_public_snapshot", "ec2_ebs_default_encryption", @@ -85,7 +83,6 @@ "iam_password_policy_minimum_length_14", "iam_password_policy_lowercase", "iam_password_policy_number", - "iam_password_policy_number", "iam_password_policy_symbol", "iam_password_policy_uppercase", "iam_no_custom_policy_permissive_role_assumption", @@ -97,23 +94,18 @@ "iam_no_root_access_key", "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", - "iam_user_mfa_enabled_console_access", "iam_user_accesskey_unused", "iam_user_console_access_unused", "kms_cmk_rotation_enabled", "awslambda_function_not_publicly_accessible", - "awslambda_function_not_publicly_accessible", - "cloudwatch_log_group_kms_encryption_enabled", "cloudwatch_log_group_kms_encryption_enabled", "rds_instance_enhanced_monitoring_enabled", "rds_instance_backup_enabled", "rds_instance_deletion_protection", "rds_instance_storage_encrypted", - "rds_instance_backup_enabled", "rds_instance_integration_cloudwatch_logs", "rds_instance_multi_az", "rds_instance_no_public_access", - "rds_instance_storage_encrypted", "rds_snapshots_public_access", "redshift_cluster_automated_snapshot", "redshift_cluster_audit_logging", @@ -125,7 +117,6 @@ "s3_bucket_policy_public_write_access", "s3_bucket_object_versioning", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_training_jobs_volume_and_output_encryption_enabled", "sagemaker_notebook_instance_without_direct_internet_access_configured", "sagemaker_notebook_instance_encryption_enabled", @@ -222,7 +213,6 @@ "iam_password_policy_minimum_length_14", "iam_password_policy_lowercase", "iam_password_policy_number", - "iam_password_policy_number", "iam_password_policy_symbol", "iam_password_policy_uppercase" ] @@ -246,7 +236,6 @@ "ec2_ebs_default_encryption", "opensearch_service_domains_encryption_at_rest_enabled", "rds_instance_storage_encrypted", - "rds_instance_storage_encrypted", "redshift_cluster_audit_logging", "s3_bucket_default_encryption", "sagemaker_training_jobs_volume_and_output_encryption_enabled", @@ -273,7 +262,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", "cloudwatch_log_group_kms_encryption_enabled", "dynamodb_tables_kms_cmk_encryption_enabled", @@ -289,18 +277,14 @@ "opensearch_service_domains_cloudwatch_logging_enabled", "opensearch_service_domains_node_to_node_encryption_enabled", "awslambda_function_not_publicly_accessible", - "awslambda_function_not_publicly_accessible", - "cloudwatch_log_group_kms_encryption_enabled", "rds_instance_storage_encrypted", "rds_instance_integration_cloudwatch_logs", "rds_instance_no_public_access", "rds_snapshots_public_access", - "rds_snapshots_public_access", "redshift_cluster_audit_logging", "redshift_cluster_public_access", "s3_bucket_default_encryption", "s3_bucket_secure_transport_policy", - "redshift_cluster_public_access", "s3_bucket_server_access_logging_enabled", "s3_bucket_public_access", "s3_bucket_policy_public_write_access", @@ -349,7 +333,6 @@ "elbv2_deletion_protection", "rds_instance_backup_enabled", "rds_instance_deletion_protection", - "rds_instance_backup_enabled", "redshift_cluster_automated_snapshot", "s3_bucket_object_versioning" ] @@ -409,11 +392,9 @@ } ], "Checks": [ - "dynamodb_tables_pitr_enabled", "dynamodb_tables_pitr_enabled", "efs_have_backup_enabled", "rds_instance_backup_enabled", - "rds_instance_backup_enabled", "redshift_cluster_automated_snapshot", "s3_bucket_object_versioning" ] @@ -432,8 +413,7 @@ "Checks": [ "iam_user_hardware_mfa_enabled", "iam_root_mfa_enabled", - "iam_user_mfa_enabled_console_access", - "iam_user_hardware_mfa_enabled" + "iam_user_mfa_enabled_console_access" ] }, { diff --git a/prowler/compliance/aws/fedramp_20x_ksi_low_aws.json b/prowler/compliance/aws/fedramp_20x_ksi_low_aws.json deleted file mode 100644 index ebc7d696c9..0000000000 --- a/prowler/compliance/aws/fedramp_20x_ksi_low_aws.json +++ /dev/null @@ -1,397 +0,0 @@ -{ - "Framework": "FedRAMP-20x-KSI-Low", - "Name": "FedRAMP 20x Key Security Indicators (KSIs) - Low Impact Level v25.05C", - "Version": "25.05C", - "Provider": "AWS", - "Description": "FedRAMP 20x Key Security Indicators (KSIs) Low Impact Level represent core security indicators for cloud service providers, focusing on automation, continuous monitoring, and cloud-native security principles per FedRAMP 20x Phase One pilot requirements for Low impact systems.", - "Requirements": [ - { - "Id": "ksi-cmt", - "Name": "KSI-CMT: Change Management", - "Description": "A secure cloud service provider will ensure that all system changes are properly documented and configuration baselines are updated accordingly", - "Attributes": [ - { - "ItemId": "ksi-cmt", - "Section": "Change Management", - "Service": "aws" - } - ], - "Checks": [ - "cloudtrail_multi_region_enabled", - "cloudtrail_log_file_validation_enabled", - "cloudtrail_s3_dataevents_read_enabled", - "cloudtrail_s3_dataevents_write_enabled", - "cloudwatch_changes_to_network_acls_alarm_configured", - "cloudwatch_changes_to_network_gateways_alarm_configured", - "cloudwatch_changes_to_network_route_tables_alarm_configured", - "cloudwatch_changes_to_vpcs_alarm_configured", - "cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled", - "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", - "cloudwatch_log_metric_filter_aws_organizations_changes", - "cloudwatch_log_metric_filter_for_s3_bucket_policy_changes", - "cloudwatch_log_metric_filter_policy_changes", - "cloudwatch_log_metric_filter_security_group_changes", - "config_recorder_all_regions_enabled", - "ec2_instance_managed_by_ssm", - "ec2_instance_older_than_specific_days", - "ssm_managed_compliant_patching", - "ssm_managed_instance_compliance_association_compliant", - "ssm_managed_instance_compliance_patch_compliant" - ], - "ConfigRequirements": [ - { - "Check": "config_recorder_all_regions_enabled", - "ConfigKey": "mute_non_default_regions", - "Operator": "eq", - "Value": false - } - ] - }, - { - "Id": "ksi-cna", - "Name": "KSI-CNA: Cloud Native Architecture", - "Description": "A secure cloud service offering will use cloud native architecture and design principles to enforce and enhance the Confidentiality, Integrity and Availability of the system", - "Attributes": [ - { - "ItemId": "ksi-cna", - "Section": "Cloud Native Architecture", - "Service": "aws" - } - ], - "Checks": [ - "autoscaling_group_multiple_az", - "autoscaling_group_multiple_instance_types", - "autoscaling_group_capacity_rebalance_enabled", - "dynamodb_tables_pitr_enabled", - "dynamodb_tables_deletion_protection_enabled", - "ec2_instance_imdsv2_enabled", - "ec2_networkacl_allow_ingress_any_port", - "ec2_securitygroup_default_restrict_traffic", - "ec2_securitygroup_allow_ingress_from_internet_to_any_port", - "eks_cluster_network_policy_enabled", - "eks_cluster_not_publicly_accessible", - "eks_cluster_private_nodes_enabled", - "eks_cluster_uses_a_supported_version", - "elb_cross_zone_load_balancing_enabled", - "elbv2_alb_multi_az_scheme", - "elbv2_waf_acl_attached", - "rds_instance_multi_az", - "rds_cluster_multi_az", - "vpc_subnet_auto_assign_public_ip_disabled", - "vpc_default_security_group_restricts_traffic", - "vpc_peering_connection_routing_tables_with_least_privilege", - "ec2_confidential_workload_host_imdsv2_not_enforced" - ] - }, - { - "Id": "ksi-iam", - "Name": "KSI-IAM: Identity and Access Management", - "Description": "A secure cloud service offering will protect user data, control access, and apply zero trust principles", - "Attributes": [ - { - "ItemId": "ksi-iam", - "Section": "Identity and Access Management", - "Service": "aws" - } - ], - "Checks": [ - "iam_inline_policy_no_wildcard_marketplace_subscribe", - "iam_policy_no_wildcard_marketplace_subscribe", - "iam_administrator_access_with_mfa", - "iam_aws_attached_policy_no_administrative_privileges", - "iam_customer_attached_policy_no_administrative_privileges", - "iam_inline_policy_no_administrative_privileges", - "iam_no_custom_policy_permissive_role_assumption", - "iam_no_root_access_key", - "iam_password_policy_expires_passwords_within_90_days_or_less", - "iam_password_policy_lowercase", - "iam_password_policy_minimum_length_14", - "iam_password_policy_number", - "iam_password_policy_reuse_24", - "iam_password_policy_symbol", - "iam_password_policy_uppercase", - "iam_policy_attached_only_to_group_or_roles", - "iam_policy_no_full_access_to_cloudtrail", - "iam_policy_no_full_access_to_kms", - "iam_root_hardware_mfa_enabled", - "iam_root_mfa_enabled", - "iam_rotate_access_key_90_days", - "iam_role_access_not_stale_to_bedrock", - "iam_user_access_not_stale_to_bedrock", - "iam_user_access_not_stale_to_sagemaker", - "iam_user_accesskey_unused", - "iam_user_console_access_unused", - "iam_user_hardware_mfa_enabled", - "iam_user_mfa_enabled_console_access", - "iam_user_two_active_access_key", - "organizations_scp_check_deny_regions", - "organizations_opt_out_ai_services_policy" - ] - }, - { - "Id": "ksi-inr", - "Name": "KSI-INR: Incident Response", - "Description": "A secure cloud service offering will respond to incidents according to FedRAMP requirements and cloud service provider policies", - "Attributes": [ - { - "ItemId": "ksi-inr", - "Section": "Incident Response", - "Service": "aws" - } - ], - "Checks": [ - "guardduty_centrally_managed", - "guardduty_ec2_malware_protection_enabled", - "guardduty_eks_audit_log_enabled", - "guardduty_eks_protection_enabled", - "guardduty_eks_runtime_monitoring_enabled", - "guardduty_is_enabled", - "guardduty_lambda_protection_enabled", - "guardduty_malware_protection_enabled", - "guardduty_no_high_severity_findings", - "guardduty_rds_protection_enabled", - "guardduty_s3_protection_enabled", - "inspector2_is_enabled", - "inspector2_active_findings_exist", - "securityhub_enabled", - "sns_topics_kms_encryption_at_rest_enabled" - ], - "ConfigRequirements": [ - { - "Check": "guardduty_is_enabled", - "ConfigKey": "mute_non_default_regions", - "Operator": "eq", - "Value": false - }, - { - "Check": "securityhub_enabled", - "ConfigKey": "mute_non_default_regions", - "Operator": "eq", - "Value": false - } - ] - }, - { - "Id": "ksi-mla", - "Name": "KSI-MLA: Monitoring, Logging, and Auditing", - "Description": "A secure cloud service offering will monitor, log, and audit all important events, activity, and changes", - "Attributes": [ - { - "ItemId": "ksi-mla", - "Section": "Monitoring, Logging, and Auditing", - "Service": "aws" - } - ], - "Checks": [ - "apigateway_restapi_logging_enabled", - "cloudtrail_cloudwatch_logging_enabled", - "cloudtrail_kms_encryption_enabled", - "cloudtrail_log_file_validation_enabled", - "cloudtrail_multi_region_enabled", - "cloudtrail_s3_dataevents_read_enabled", - "cloudtrail_s3_dataevents_write_enabled", - "cloudwatch_log_group_kms_encryption_enabled", - "cloudwatch_log_group_retention_policy_specific_days_enabled", - "ecs_cluster_container_insights_enabled", - "eks_cluster_control_plane_audit_logging_enabled", - "elb_logging_enabled", - "elbv2_logging_enabled", - "inspector2_is_enabled", - "opensearch_service_domains_cloudwatch_logging_enabled", - "rds_instance_enhanced_monitoring_enabled", - "rds_instance_integration_cloudwatch_logs", - "redshift_cluster_audit_logging", - "s3_bucket_server_access_logging_enabled", - "vpc_flow_logs_enabled", - "wafv2_webacl_logging_enabled", - "kms_key_enclave_debug_attestation_detected", - "kms_key_enclave_attestation_unknown_image" - ] - }, - { - "Id": "ksi-piy", - "Name": "KSI-PIY: Policy and Inventory", - "Description": "A secure cloud service offering will have intentional, organized, universal guidance for how every information resource, including personnel, is secured", - "Attributes": [ - { - "ItemId": "ksi-piy", - "Section": "Policy and Inventory", - "Service": "aws" - } - ], - "Checks": [ - "config_recorder_all_regions_enabled", - "config_recorder_using_aws_service_role", - "ec2_instance_managed_by_ssm", - "organizations_account_part_of_organizations", - "organizations_delegated_administrators", - "organizations_scp_check_deny_regions", - "organizations_tags_policies_enabled_and_attached", - "resourceexplorer_indexes_found", - "ssm_managed_instance_compliance_association_compliant", - "trustedadvisor_premium_support_plan_subscribed" - ], - "ConfigRequirements": [ - { - "Check": "config_recorder_all_regions_enabled", - "ConfigKey": "mute_non_default_regions", - "Operator": "eq", - "Value": false - } - ] - }, - { - "Id": "ksi-rpl", - "Name": "KSI-RPL: Recovery Planning", - "Description": "A secure cloud service offering will define, maintain, and test incident response plan(s) and recovery capabilities to ensure minimal service disruption and data loss", - "Attributes": [ - { - "ItemId": "ksi-rpl", - "Section": "Recovery Planning", - "Service": "aws" - } - ], - "Checks": [ - "backup_plans_exist", - "backup_reportplans_exist", - "backup_vaults_exist", - "backup_vaults_encrypted", - "backup_recovery_point_encrypted", - "backup_recovery_point_manual_deletion_disabled", - "backup_recovery_point_minimum_retention_days", - "dlm_ebs_snapshot_lifecycle_policy_exists", - "dynamodb_tables_pitr_enabled", - "dynamodb_tables_deletion_protection_enabled", - "efs_have_backup_enabled", - "fsx_file_system_copy_tags_to_backups", - "rds_instance_backup_enabled", - "rds_instance_backup_retention_policy", - "rds_instance_deletion_protection", - "rds_cluster_deletion_protection", - "rds_snapshots_encrypted", - "redshift_cluster_automated_snapshot" - ] - }, - { - "Id": "ksi-svc", - "Name": "KSI-SVC: Service Configuration", - "Description": "A secure cloud service offering will follow FedRAMP encryption policies, continuously verify information resource integrity, and restrict access to third-party information resources", - "Attributes": [ - { - "ItemId": "ksi-svc", - "Section": "Service Configuration", - "Service": "aws" - } - ], - "Checks": [ - "acm_certificates_expiration_check", - "apigateway_restapi_cache_encrypted", - "cloudtrail_kms_encryption_enabled", - "dax_cluster_encryption_enabled", - "dynamodb_table_encryption_enabled", - "dynamodb_table_encryption_uses_cmks", - "ebs_volume_encryption_enabled", - "ec2_ebs_default_encryption", - "ec2_instance_ebs_optimized", - "efs_encryption_at_rest_enabled", - "eks_cluster_envelope_encryption_enabled", - "elasticache_redis_cluster_encryption_at_rest_enabled", - "elasticache_redis_cluster_encryption_at_transit_enabled", - "elbv2_ssl_listeners", - "fsx_file_system_encryption_at_rest_enabled", - "kinesis_stream_encrypted_at_rest", - "kms_cmk_rotation_enabled", - "kms_cmk_not_scheduled_for_deletion", - "kms_key_not_publicly_accessible", - "rds_instance_storage_encrypted", - "rds_instance_storage_encrypted_with_cmk", - "rds_cluster_storage_encrypted", - "redshift_cluster_encryption_at_rest", - "redshift_cluster_encryption_in_transit", - "s3_bucket_server_side_encryption_enabled", - "s3_bucket_default_encryption", - "s3_bucket_secure_transport_policy", - "sagemaker_notebook_instance_encryption_enabled", - "sns_topics_kms_encryption_at_rest_enabled", - "sqs_queue_server_side_encryption_enabled", - "kms_key_enclave_attestation_not_enforced" - ] - }, - { - "Id": "ksi-tpr", - "Name": "KSI-TPR: Third-Party Information Resources", - "Description": "A secure cloud service offering will understand, monitor, and manage supply chain risks from third-party information resources", - "Attributes": [ - { - "ItemId": "ksi-tpr", - "Section": "Third-Party Information Resources", - "Service": "aws" - } - ], - "Checks": [ - "ecr_registry_scan_images_on_push_enabled", - "ecr_repositories_lifecycle_policy_enabled", - "ecr_repositories_not_publicly_accessible", - "ecr_repositories_scan_on_push_enabled", - "ecr_repositories_scan_vulnerabilities_in_latest_image", - "ecr_repositories_tag_immutability", - "inspector2_active_findings_exist", - "inspector2_is_enabled", - "awslambda_function_using_supported_runtimes", - "ssm_managed_compliant_patching", - "trustedadvisor_premium_support_plan_subscribed", - "guardduty_no_high_severity_findings" - ] - }, - { - "Id": "ksi-iam-07", - "Name": "KSI-IAM-07: Account Lifecycle Management", - "Description": "Securely manage the lifecycle and privileges of all accounts, roles, and groups", - "Attributes": [ - { - "ItemId": "ksi-iam-07", - "Section": "Identity and Access Management", - "Service": "aws" - } - ], - "Checks": [ - "iam_no_root_access_key", - "iam_policy_attached_only_to_group_or_roles", - "iam_rotate_access_key_90_days", - "iam_role_access_not_stale_to_bedrock", - "iam_user_access_not_stale_to_bedrock", - "iam_user_access_not_stale_to_sagemaker", - "iam_user_accesskey_unused", - "iam_user_console_access_unused", - "organizations_delegated_administrators" - ] - }, - { - "Id": "ksi-mla-07", - "Name": "KSI-MLA-07: Monitoring and Logging Inventory", - "Description": "Maintain a list of information resources and event types that will be monitored, logged, and audited", - "Attributes": [ - { - "ItemId": "ksi-mla-07", - "Section": "Monitoring, Logging, and Auditing", - "Service": "aws" - } - ], - "Checks": [ - "cloudtrail_multi_region_enabled", - "cloudwatch_log_group_retention_policy_specific_days_enabled", - "config_recorder_all_regions_enabled", - "inspector2_is_enabled", - "resourceexplorer_indexes_found" - ], - "ConfigRequirements": [ - { - "Check": "config_recorder_all_regions_enabled", - "ConfigKey": "mute_non_default_regions", - "Operator": "eq", - "Value": false - } - ] - } - ] -} diff --git a/prowler/compliance/aws/fedramp_low_revision_4_aws.json b/prowler/compliance/aws/fedramp_low_revision_4_aws.json index 059de69675..63382aa174 100644 --- a/prowler/compliance/aws/fedramp_low_revision_4_aws.json +++ b/prowler/compliance/aws/fedramp_low_revision_4_aws.json @@ -21,7 +21,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_log_file_validation_enabled", "cloudwatch_changes_to_network_acls_alarm_configured", "opensearch_service_domains_cloudwatch_logging_enabled", @@ -127,8 +126,7 @@ "securityhub_enabled", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" ], "ConfigRequirements": [ { @@ -161,7 +159,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_log_file_validation_enabled", "elbv2_logging_enabled", "rds_instance_integration_cloudwatch_logs", @@ -226,7 +223,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudwatch_changes_to_network_acls_alarm_configured", "ec2_instance_imdsv2_enabled", "elbv2_waf_acl_attached", @@ -276,13 +272,11 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured", "ssm_managed_compliant_patching", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" ] }, { @@ -299,7 +293,6 @@ "Checks": [ "ec2_instance_managed_by_ssm", "guardduty_is_enabled", - "ssm_managed_compliant_patching", "ssm_managed_compliant_patching" ], "ConfigRequirements": [ @@ -323,11 +316,9 @@ } ], "Checks": [ - "dynamodb_tables_pitr_enabled", "dynamodb_tables_pitr_enabled", "efs_have_backup_enabled", "rds_instance_backup_enabled", - "rds_instance_backup_enabled", "redshift_cluster_automated_snapshot", "s3_bucket_object_versioning" ] @@ -344,13 +335,11 @@ } ], "Checks": [ - "dynamodb_tables_pitr_enabled", "dynamodb_tables_pitr_enabled", "efs_have_backup_enabled", "elbv2_deletion_protection", "rds_instance_backup_enabled", "rds_instance_multi_az", - "rds_instance_backup_enabled", "redshift_cluster_automated_snapshot", "s3_bucket_object_versioning" ] @@ -371,7 +360,6 @@ "iam_root_hardware_mfa_enabled", "iam_root_mfa_enabled", "iam_no_root_access_key", - "iam_user_mfa_enabled_console_access", "iam_user_mfa_enabled_console_access" ] }, @@ -482,12 +470,10 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" ] }, { diff --git a/prowler/compliance/aws/fedramp_moderate_revision_4_aws.json b/prowler/compliance/aws/fedramp_moderate_revision_4_aws.json index 06f81ea08d..3474986c1a 100644 --- a/prowler/compliance/aws/fedramp_moderate_revision_4_aws.json +++ b/prowler/compliance/aws/fedramp_moderate_revision_4_aws.json @@ -29,7 +29,6 @@ "iam_no_root_access_key", "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", - "iam_user_mfa_enabled_console_access", "iam_role_access_not_stale_to_bedrock", "iam_user_access_not_stale_to_bedrock", "iam_user_access_not_stale_to_sagemaker", @@ -68,7 +67,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "cloudwatch_changes_to_network_acls_alarm_configured", "cloudwatch_changes_to_network_gateways_alarm_configured", @@ -149,7 +147,6 @@ "iam_no_root_access_key", "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", - "iam_user_mfa_enabled_console_access", "iam_role_access_not_stale_to_bedrock", "iam_user_access_not_stale_to_bedrock", "iam_user_access_not_stale_to_sagemaker", @@ -174,7 +171,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "opensearch_service_domains_cloudwatch_logging_enabled", "guardduty_is_enabled", @@ -220,7 +216,6 @@ "iam_no_root_access_key", "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", - "iam_user_mfa_enabled_console_access", "iam_role_access_not_stale_to_bedrock", "iam_user_access_not_stale_to_bedrock", "iam_user_access_not_stale_to_sagemaker", @@ -312,7 +307,6 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_confidential_workload_host_imdsv2_not_enforced", "kms_key_enclave_attestation_not_enforced" @@ -345,7 +339,6 @@ "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port", "ec2_confidential_workload_host_public_ip" ] }, @@ -426,7 +419,6 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_confidential_workload_host_imdsv2_not_enforced", "kms_key_enclave_attestation_bypassable_path" @@ -457,13 +449,11 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured", "securityhub_enabled", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" ], "ConfigRequirements": [ { @@ -521,10 +511,8 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_securitygroup_default_restrict_traffic", - "ec2_networkacl_allow_ingress_any_port", "ec2_networkacl_allow_ingress_any_port" ] }, @@ -545,7 +533,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_bedrock_logging_enabled", "cloudtrail_cloudwatch_logging_enabled", "elbv2_logging_enabled", @@ -572,7 +559,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "elbv2_logging_enabled", "elb_logging_enabled", @@ -599,7 +585,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "cloudwatch_changes_to_network_acls_alarm_configured", "cloudwatch_changes_to_network_gateways_alarm_configured", @@ -723,7 +708,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_bedrock_logging_enabled", "cloudtrail_cloudwatch_logging_enabled", "elbv2_logging_enabled", @@ -750,7 +734,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudwatch_changes_to_network_acls_alarm_configured", "cloudwatch_changes_to_network_gateways_alarm_configured", "cloudwatch_changes_to_network_route_tables_alarm_configured", @@ -804,13 +787,11 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured", "ssm_managed_compliant_patching", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" ] }, { @@ -863,7 +844,6 @@ "Checks": [ "ec2_instance_managed_by_ssm", "guardduty_is_enabled", - "ssm_managed_compliant_patching", "ssm_managed_compliant_patching" ], "ConfigRequirements": [ @@ -888,11 +868,9 @@ } ], "Checks": [ - "dynamodb_tables_pitr_enabled", "dynamodb_tables_pitr_enabled", "efs_have_backup_enabled", "rds_instance_backup_enabled", - "rds_instance_backup_enabled", "redshift_cluster_automated_snapshot", "s3_bucket_object_versioning" ] @@ -909,13 +887,11 @@ } ], "Checks": [ - "dynamodb_tables_pitr_enabled", "dynamodb_tables_pitr_enabled", "efs_have_backup_enabled", "elbv2_deletion_protection", "rds_instance_backup_enabled", "rds_instance_multi_az", - "rds_instance_backup_enabled", "redshift_cluster_automated_snapshot", "s3_bucket_object_versioning" ] @@ -935,7 +911,6 @@ "Checks": [ "iam_root_mfa_enabled", "iam_user_mfa_enabled_console_access", - "iam_user_mfa_enabled_console_access", "iam_root_hardware_mfa_enabled" ] }, @@ -954,7 +929,6 @@ "Checks": [ "iam_root_hardware_mfa_enabled", "iam_root_mfa_enabled", - "iam_user_mfa_enabled_console_access", "iam_user_mfa_enabled_console_access" ] }, @@ -1056,42 +1030,6 @@ } ] }, - { - "Id": "ir-4-1", - "Name": "IR-4(1) Automated Incident Handling Processes", - "Description": "The organization employs automated mechanisms to support the incident handling process.", - "Attributes": [ - { - "ItemId": "ir-4-1", - "Section": "Incident Response (IR)", - "SubSection": "Incident Handling (IR-4)", - "Service": "aws" - } - ], - "Checks": [ - "cloudwatch_changes_to_network_acls_alarm_configured", - "cloudwatch_changes_to_network_gateways_alarm_configured", - "cloudwatch_changes_to_network_route_tables_alarm_configured", - "cloudwatch_changes_to_vpcs_alarm_configured", - "guardduty_is_enabled", - "guardduty_no_high_severity_findings", - "securityhub_enabled" - ], - "ConfigRequirements": [ - { - "Check": "guardduty_is_enabled", - "ConfigKey": "mute_non_default_regions", - "Operator": "eq", - "Value": false - }, - { - "Check": "securityhub_enabled", - "ConfigKey": "mute_non_default_regions", - "Operator": "eq", - "Value": false - } - ] - }, { "Id": "ir-6-1", "Name": "IR-6(1) Automated Reporting", @@ -1266,12 +1204,10 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" ] }, { @@ -1326,12 +1262,10 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port", "ec2_confidential_workload_host_public_ip" ] }, @@ -1362,12 +1296,10 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port", "ec2_confidential_workload_host_public_ip" ] }, @@ -1485,15 +1417,12 @@ "Checks": [ "cloudtrail_kms_encryption_enabled", "ec2_ebs_volume_encryption", - "ec2_ebs_volume_encryption", "efs_encryption_at_rest_enabled", "opensearch_service_domains_encryption_at_rest_enabled", "cloudwatch_log_group_kms_encryption_enabled", "rds_instance_storage_encrypted", - "rds_instance_storage_encrypted", "redshift_cluster_audit_logging", "s3_bucket_default_encryption", - "s3_bucket_default_encryption", "sagemaker_notebook_instance_encryption_enabled", "sns_topics_kms_encryption_at_rest_enabled", "kms_key_enclave_debug_attestation_detected" @@ -1513,7 +1442,6 @@ ], "Checks": [ "ec2_instance_managed_by_ssm", - "ssm_managed_compliant_patching", "ssm_managed_compliant_patching" ] }, @@ -1557,7 +1485,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "guardduty_is_enabled", "redshift_cluster_audit_logging", "securityhub_enabled" @@ -1593,7 +1520,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudwatch_changes_to_network_acls_alarm_configured", "cloudwatch_changes_to_network_gateways_alarm_configured", "cloudwatch_changes_to_network_route_tables_alarm_configured", @@ -1636,7 +1562,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudwatch_changes_to_network_acls_alarm_configured", "cloudwatch_changes_to_network_gateways_alarm_configured", "cloudwatch_changes_to_network_route_tables_alarm_configured", @@ -1677,7 +1602,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudwatch_changes_to_network_acls_alarm_configured", "cloudwatch_changes_to_network_gateways_alarm_configured", "cloudwatch_changes_to_network_route_tables_alarm_configured", @@ -1790,10 +1714,8 @@ "Checks": [ "cloudwatch_log_group_retention_policy_specific_days_enabled", "dynamodb_tables_pitr_enabled", - "dynamodb_tables_pitr_enabled", "efs_have_backup_enabled", "rds_instance_backup_enabled", - "rds_instance_backup_enabled", "redshift_cluster_automated_snapshot", "s3_bucket_object_versioning" ] diff --git a/prowler/compliance/aws/ffiec_aws.json b/prowler/compliance/aws/ffiec_aws.json index f9e72adf0e..156b26fe80 100644 --- a/prowler/compliance/aws/ffiec_aws.json +++ b/prowler/compliance/aws/ffiec_aws.json @@ -60,7 +60,6 @@ } ], "Checks": [ - "rds_instance_backup_enabled", "rds_instance_backup_enabled", "rds_instance_multi_az", "redshift_cluster_automated_snapshot" @@ -115,7 +114,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "cloudwatch_log_group_retention_policy_specific_days_enabled", "elbv2_logging_enabled", @@ -145,7 +143,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "elbv2_logging_enabled", "elb_logging_enabled", @@ -365,7 +362,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "elbv2_logging_enabled", "elb_logging_enabled", @@ -392,7 +388,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "elbv2_logging_enabled", "elb_logging_enabled", @@ -437,7 +432,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "elbv2_logging_enabled", "elb_logging_enabled", @@ -571,8 +565,7 @@ ], "Checks": [ "ec2_networkacl_allow_ingress_any_port", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" ] }, { @@ -716,7 +709,6 @@ "iam_password_policy_minimum_length_14", "iam_password_policy_lowercase", "iam_password_policy_number", - "iam_password_policy_number", "iam_password_policy_symbol", "iam_password_policy_uppercase", "iam_aws_attached_policy_no_administrative_privileges", @@ -726,7 +718,6 @@ "iam_root_mfa_enabled", "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", - "iam_user_mfa_enabled_console_access", "iam_user_accesskey_unused", "iam_user_console_access_unused" ] @@ -747,7 +738,6 @@ "iam_password_policy_minimum_length_14", "iam_password_policy_lowercase", "iam_password_policy_number", - "iam_password_policy_number", "iam_password_policy_symbol", "iam_password_policy_uppercase" ] @@ -795,12 +785,10 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" ] }, { @@ -820,8 +808,7 @@ "elbv2_waf_acl_attached", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" ] }, { @@ -859,7 +846,6 @@ ], "Checks": [ "ec2_instance_managed_by_ssm", - "ssm_managed_compliant_patching", "ssm_managed_compliant_patching" ] }, @@ -879,7 +865,6 @@ "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port", "ec2_confidential_workload_host_vsock_proxy_exposed" ] }, @@ -933,8 +918,7 @@ "Checks": [ "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" ] }, { @@ -1002,7 +986,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "cloudwatch_changes_to_network_acls_alarm_configured", "cloudwatch_changes_to_network_gateways_alarm_configured", @@ -1066,7 +1049,6 @@ "rds_instance_backup_enabled", "rds_instance_deletion_protection", "rds_instance_multi_az", - "rds_instance_backup_enabled", "s3_bucket_object_versioning" ] } diff --git a/prowler/compliance/aws/gdpr_aws.json b/prowler/compliance/aws/gdpr_aws.json index 1eae6ec040..3a6a1ae668 100644 --- a/prowler/compliance/aws/gdpr_aws.json +++ b/prowler/compliance/aws/gdpr_aws.json @@ -30,12 +30,6 @@ "iam_password_policy_symbol", "iam_password_policy_uppercase", "iam_password_policy_reuse_24", - "iam_password_policy_minimum_length_14", - "iam_password_policy_lowercase", - "iam_password_policy_number", - "iam_password_policy_number", - "iam_password_policy_symbol", - "iam_password_policy_uppercase", "iam_aws_attached_policy_no_administrative_privileges", "iam_customer_attached_policy_no_administrative_privileges", "iam_inline_policy_no_administrative_privileges", @@ -85,7 +79,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "cloudtrail_kms_encryption_enabled", "config_recorder_all_regions_enabled", @@ -122,8 +115,6 @@ "cloudtrail_log_file_validation_enabled", "dynamodb_accelerator_cluster_encryption_enabled", "dynamodb_tables_kms_cmk_encryption_enabled", - "dynamodb_tables_kms_cmk_encryption_enabled", - "ec2_ebs_volume_encryption", "ec2_ebs_volume_encryption", "efs_encryption_at_rest_enabled", "elb_ssl_listeners", @@ -133,11 +124,9 @@ "rds_instance_storage_encrypted", "rds_instance_backup_enabled", "rds_instance_integration_cloudwatch_logs", - "rds_instance_storage_encrypted", "redshift_cluster_automated_snapshot", "redshift_cluster_audit_logging", "s3_bucket_default_encryption", - "s3_bucket_default_encryption", "s3_bucket_secure_transport_policy", "sagemaker_notebook_instance_encryption_enabled", "sns_topics_kms_encryption_at_rest_enabled", diff --git a/prowler/compliance/aws/gxp_21_cfr_part_11_aws.json b/prowler/compliance/aws/gxp_21_cfr_part_11_aws.json index f4bae0b9d6..f89678fb27 100644 --- a/prowler/compliance/aws/gxp_21_cfr_part_11_aws.json +++ b/prowler/compliance/aws/gxp_21_cfr_part_11_aws.json @@ -24,11 +24,9 @@ "elbv2_deletion_protection", "rds_instance_backup_enabled", "rds_instance_deletion_protection", - "rds_instance_backup_enabled", "rds_instance_multi_az", "redshift_cluster_automated_snapshot", "s3_bucket_object_versioning", - "ssm_managed_compliant_patching", "ssm_managed_compliant_patching" ] }, @@ -47,7 +45,6 @@ "cloudtrail_kms_encryption_enabled", "cloudwatch_log_group_retention_policy_specific_days_enabled", "rds_instance_storage_encrypted", - "rds_instance_storage_encrypted", "rds_snapshots_public_access", "redshift_cluster_audit_logging", "redshift_cluster_public_access", @@ -80,7 +77,6 @@ "iam_password_policy_minimum_length_14", "iam_password_policy_lowercase", "iam_password_policy_number", - "iam_password_policy_number", "iam_password_policy_symbol", "iam_password_policy_uppercase", "iam_policy_attached_only_to_group_or_roles", @@ -92,7 +88,6 @@ "iam_no_root_access_key", "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", - "iam_user_mfa_enabled_console_access", "iam_user_accesskey_unused", "iam_user_console_access_unused", "awslambda_function_not_publicly_accessible", @@ -103,13 +98,11 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured", "secretsmanager_automatic_rotation_enabled", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port", "ec2_confidential_workload_host_imdsv2_not_enforced", "ec2_confidential_workload_host_public_ip" ] @@ -130,7 +123,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "cloudwatch_log_group_retention_policy_specific_days_enabled", "elbv2_logging_enabled", @@ -168,7 +160,6 @@ "iam_password_policy_minimum_length_14", "iam_password_policy_lowercase", "iam_password_policy_number", - "iam_password_policy_number", "iam_password_policy_symbol", "iam_password_policy_uppercase", "iam_policy_attached_only_to_group_or_roles", @@ -180,7 +171,6 @@ "iam_no_root_access_key", "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", - "iam_user_mfa_enabled_console_access", "iam_user_accesskey_unused", "iam_user_console_access_unused", "awslambda_function_not_publicly_accessible", @@ -191,13 +181,11 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured", "secretsmanager_automatic_rotation_enabled", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port", "ec2_confidential_workload_host_imdsv2_not_enforced", "ec2_confidential_workload_host_public_ip" ] @@ -215,7 +203,6 @@ ], "Checks": [ "ec2_instance_managed_by_ssm", - "ssm_managed_compliant_patching", "ssm_managed_compliant_patching" ] }, @@ -246,7 +233,6 @@ "s3_bucket_policy_public_write_access", "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_securitygroup_default_restrict_traffic", - "ec2_networkacl_allow_ingress_any_port", "ec2_networkacl_allow_ingress_any_port" ] }, @@ -280,10 +266,8 @@ "kms_cmk_rotation_enabled", "cloudwatch_log_group_kms_encryption_enabled", "rds_instance_storage_encrypted", - "rds_instance_storage_encrypted", "redshift_cluster_audit_logging", "s3_bucket_default_encryption", - "s3_bucket_default_encryption", "s3_bucket_secure_transport_policy", "sagemaker_notebook_instance_encryption_enabled", "sns_topics_kms_encryption_at_rest_enabled" @@ -304,14 +288,12 @@ "iam_password_policy_minimum_length_14", "iam_password_policy_lowercase", "iam_password_policy_number", - "iam_password_policy_number", "iam_password_policy_symbol", "iam_password_policy_uppercase", "iam_root_hardware_mfa_enabled", "iam_root_mfa_enabled", "iam_no_root_access_key", "iam_rotate_access_key_90_days", - "iam_user_mfa_enabled_console_access", "iam_user_mfa_enabled_console_access" ] }, @@ -330,7 +312,6 @@ "iam_password_policy_minimum_length_14", "iam_password_policy_lowercase", "iam_password_policy_number", - "iam_password_policy_number", "iam_password_policy_symbol", "iam_password_policy_uppercase", "iam_rotate_access_key_90_days", diff --git a/prowler/compliance/aws/gxp_eu_annex_11_aws.json b/prowler/compliance/aws/gxp_eu_annex_11_aws.json index fdca6d1747..2b4f17632b 100644 --- a/prowler/compliance/aws/gxp_eu_annex_11_aws.json +++ b/prowler/compliance/aws/gxp_eu_annex_11_aws.json @@ -41,12 +41,9 @@ } ], "Checks": [ - "dynamodb_tables_pitr_enabled", "dynamodb_tables_pitr_enabled", "efs_have_backup_enabled", "rds_instance_backup_enabled", - "rds_instance_backup_enabled", - "rds_instance_backup_enabled", "redshift_cluster_automated_snapshot", "s3_bucket_object_versioning" ] @@ -66,7 +63,6 @@ "cloudtrail_kms_encryption_enabled", "dynamodb_accelerator_cluster_encryption_enabled", "dynamodb_tables_kms_cmk_encryption_enabled", - "dynamodb_tables_kms_cmk_encryption_enabled", "dynamodb_tables_pitr_enabled", "ec2_ebs_volume_encryption", "ec2_ebs_default_encryption", @@ -76,12 +72,9 @@ "cloudwatch_log_group_kms_encryption_enabled", "rds_instance_backup_enabled", "rds_instance_storage_encrypted", - "rds_instance_backup_enabled", - "rds_instance_storage_encrypted", "redshift_cluster_automated_snapshot", "redshift_cluster_audit_logging", "s3_bucket_default_encryption", - "s3_bucket_default_encryption", "s3_bucket_object_versioning", "sagemaker_notebook_instance_encryption_enabled", "sns_topics_kms_encryption_at_rest_enabled" @@ -101,10 +94,7 @@ "Checks": [ "rds_instance_backup_enabled", "dynamodb_tables_pitr_enabled", - "dynamodb_tables_pitr_enabled", "efs_have_backup_enabled", - "rds_instance_backup_enabled", - "rds_instance_backup_enabled", "redshift_cluster_automated_snapshot", "s3_bucket_object_versioning" ] @@ -192,12 +182,8 @@ } ], "Checks": [ - "dynamodb_tables_pitr_enabled", "dynamodb_tables_pitr_enabled", "efs_have_backup_enabled", - "efs_have_backup_enabled", - "rds_instance_backup_enabled", - "rds_instance_backup_enabled", "rds_instance_backup_enabled", "redshift_cluster_automated_snapshot", "s3_bucket_object_versioning" @@ -215,12 +201,8 @@ } ], "Checks": [ - "dynamodb_tables_pitr_enabled", "dynamodb_tables_pitr_enabled", "efs_have_backup_enabled", - "efs_have_backup_enabled", - "rds_instance_backup_enabled", - "rds_instance_backup_enabled", "rds_instance_backup_enabled", "redshift_cluster_automated_snapshot", "s3_bucket_object_versioning" @@ -299,12 +281,8 @@ } ], "Checks": [ - "dynamodb_tables_pitr_enabled", "dynamodb_tables_pitr_enabled", "efs_have_backup_enabled", - "efs_have_backup_enabled", - "rds_instance_backup_enabled", - "rds_instance_backup_enabled", "rds_instance_backup_enabled", "redshift_cluster_automated_snapshot", "s3_bucket_object_versioning" diff --git a/prowler/compliance/aws/hipaa_aws.json b/prowler/compliance/aws/hipaa_aws.json index f2cf10c666..01de916ec6 100644 --- a/prowler/compliance/aws/hipaa_aws.json +++ b/prowler/compliance/aws/hipaa_aws.json @@ -70,7 +70,6 @@ "rds_instance_backup_enabled", "rds_instance_storage_encrypted", "rds_instance_multi_az", - "rds_instance_storage_encrypted", "rds_snapshots_public_access", "redshift_cluster_audit_logging", "redshift_cluster_public_access", @@ -85,7 +84,6 @@ "sns_topics_kms_encryption_at_rest_enabled", "ec2_networkacl_allow_ingress_any_port", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port", "ec2_confidential_workload_host_public_ip" ] }, @@ -106,7 +104,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "cloudtrail_kms_encryption_enabled", "cloudtrail_log_file_validation_enabled", @@ -179,14 +176,12 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "elbv2_logging_enabled", "elb_logging_enabled", "guardduty_is_enabled", "iam_root_hardware_mfa_enabled", "iam_root_mfa_enabled", "iam_user_mfa_enabled_console_access", - "iam_user_mfa_enabled_console_access", "redshift_cluster_audit_logging", "s3_bucket_server_access_logging_enabled", "securityhub_enabled", @@ -276,8 +271,6 @@ "cloudtrail_kms_encryption_enabled", "dynamodb_accelerator_cluster_encryption_enabled", "dynamodb_tables_kms_cmk_encryption_enabled", - "dynamodb_tables_kms_cmk_encryption_enabled", - "ec2_ebs_volume_encryption", "ec2_ebs_volume_encryption", "ec2_ebs_default_encryption", "efs_encryption_at_rest_enabled", @@ -289,11 +282,9 @@ "rds_instance_storage_encrypted", "rds_instance_backup_enabled", "rds_instance_integration_cloudwatch_logs", - "rds_instance_storage_encrypted", "redshift_cluster_automated_snapshot", "redshift_cluster_audit_logging", "s3_bucket_default_encryption", - "s3_bucket_default_encryption", "sagemaker_notebook_instance_encryption_enabled", "sns_topics_kms_encryption_at_rest_enabled" ] @@ -353,7 +344,6 @@ ], "Checks": [ "ec2_instance_managed_by_ssm", - "ssm_managed_compliant_patching", "ssm_managed_compliant_patching" ] }, @@ -463,7 +453,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "elbv2_logging_enabled", "elb_logging_enabled", @@ -502,14 +491,10 @@ } ], "Checks": [ - "dynamodb_tables_pitr_enabled", "dynamodb_tables_pitr_enabled", "efs_have_backup_enabled", - "efs_have_backup_enabled", - "rds_instance_backup_enabled", "rds_instance_backup_enabled", "rds_instance_multi_az", - "rds_instance_backup_enabled", "redshift_cluster_automated_snapshot", "s3_bucket_object_versioning" ] @@ -526,14 +511,10 @@ } ], "Checks": [ - "dynamodb_tables_pitr_enabled", "dynamodb_tables_pitr_enabled", "efs_have_backup_enabled", - "efs_have_backup_enabled", - "rds_instance_backup_enabled", "rds_instance_backup_enabled", "rds_instance_multi_az", - "rds_instance_backup_enabled", "redshift_cluster_automated_snapshot", "s3_bucket_object_versioning" ] @@ -550,14 +531,10 @@ } ], "Checks": [ - "dynamodb_tables_pitr_enabled", "dynamodb_tables_pitr_enabled", "efs_have_backup_enabled", - "efs_have_backup_enabled", - "rds_instance_backup_enabled", "rds_instance_backup_enabled", "rds_instance_multi_az", - "rds_instance_backup_enabled", "redshift_cluster_automated_snapshot", "s3_bucket_object_versioning" ] @@ -574,14 +551,10 @@ } ], "Checks": [ - "dynamodb_tables_pitr_enabled", "dynamodb_tables_pitr_enabled", "efs_have_backup_enabled", - "efs_have_backup_enabled", - "rds_instance_backup_enabled", "rds_instance_backup_enabled", "rds_instance_multi_az", - "rds_instance_backup_enabled", "redshift_cluster_automated_snapshot", "s3_bucket_object_versioning" ] @@ -642,7 +615,6 @@ "redshift_cluster_public_access", "s3_bucket_public_access", "s3_bucket_policy_public_write_access", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_confidential_workload_host_public_ip", "ec2_confidential_workload_host_imdsv2_not_enforced", @@ -679,12 +651,8 @@ } ], "Checks": [ - "dynamodb_tables_pitr_enabled", "dynamodb_tables_pitr_enabled", "efs_have_backup_enabled", - "efs_have_backup_enabled", - "rds_instance_backup_enabled", - "rds_instance_backup_enabled", "rds_instance_backup_enabled", "redshift_cluster_automated_snapshot", "s3_bucket_object_versioning" @@ -705,7 +673,6 @@ "cloudtrail_kms_encryption_enabled", "dynamodb_accelerator_cluster_encryption_enabled", "dynamodb_tables_kms_cmk_encryption_enabled", - "dynamodb_tables_kms_cmk_encryption_enabled", "ec2_ebs_volume_encryption", "ec2_ebs_default_encryption", "efs_encryption_at_rest_enabled", @@ -714,10 +681,8 @@ "kms_cmk_rotation_enabled", "cloudwatch_log_group_kms_encryption_enabled", "rds_instance_storage_encrypted", - "rds_instance_storage_encrypted", "redshift_cluster_audit_logging", "s3_bucket_default_encryption", - "s3_bucket_default_encryption", "s3_bucket_secure_transport_policy", "sagemaker_notebook_instance_encryption_enabled", "sns_topics_kms_encryption_at_rest_enabled", @@ -741,7 +706,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "cloudtrail_log_file_validation_enabled", "cloudwatch_log_group_retention_policy_specific_days_enabled", @@ -828,7 +792,6 @@ "iam_password_policy_reuse_24", "iam_root_hardware_mfa_enabled", "iam_root_mfa_enabled", - "iam_user_mfa_enabled_console_access", "iam_user_mfa_enabled_console_access" ] }, @@ -852,7 +815,6 @@ "s3_bucket_secure_transport_policy", "ec2_networkacl_allow_ingress_any_port", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port", "ec2_confidential_workload_host_unrestricted_ingress", "ec2_confidential_workload_host_vsock_proxy_exposed" ] @@ -872,7 +834,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "elb_ssl_listeners", "guardduty_is_enabled", @@ -910,7 +871,6 @@ "cloudtrail_kms_encryption_enabled", "dynamodb_accelerator_cluster_encryption_enabled", "dynamodb_tables_kms_cmk_encryption_enabled", - "dynamodb_tables_kms_cmk_encryption_enabled", "ec2_ebs_volume_encryption", "ec2_ebs_default_encryption", "efs_encryption_at_rest_enabled", @@ -919,10 +879,8 @@ "opensearch_service_domains_encryption_at_rest_enabled", "cloudwatch_log_group_kms_encryption_enabled", "rds_instance_storage_encrypted", - "rds_instance_storage_encrypted", "redshift_cluster_audit_logging", "s3_bucket_default_encryption", - "s3_bucket_default_encryption", "s3_bucket_secure_transport_policy", "sagemaker_notebook_instance_encryption_enabled", "sns_topics_kms_encryption_at_rest_enabled" diff --git a/prowler/compliance/aws/iso27001_2022_aws.json b/prowler/compliance/aws/iso27001_2022_aws.json index d245053375..8f41e65e83 100644 --- a/prowler/compliance/aws/iso27001_2022_aws.json +++ b/prowler/compliance/aws/iso27001_2022_aws.json @@ -259,16 +259,7 @@ "iam_rotate_access_key_90_days", "iam_user_accesskey_unused", "iam_user_console_access_unused", - "iam_no_root_access_key", - "iam_password_policy_expires_passwords_within_90_days_or_less", - "iam_password_policy_reuse_24", - "iam_password_policy_minimum_length_14", - "iam_password_policy_number", - "iam_password_policy_symbol", - "iam_password_policy_lowercase", - "iam_password_policy_uppercase", - "iam_user_mfa_enabled_console_access", - "iam_rotate_access_key_90_days" + "iam_no_root_access_key" ] }, { @@ -997,11 +988,9 @@ "emr_cluster_publicly_accesible", "glacier_vaults_policy_public_access", "awslambda_function_not_publicly_accessible", - "awslambda_function_not_publicly_accessible", "rds_instance_no_public_access", "rds_snapshots_public_access", "kms_key_not_publicly_accessible", - "opensearch_service_domains_not_publicly_accessible", "redshift_cluster_public_access", "s3_account_level_public_access_blocks", "s3_bucket_public_access", @@ -1018,7 +1007,6 @@ "eks_cluster_not_publicly_accessible", "elb_internet_facing", "elbv2_internet_facing", - "s3_account_level_public_access_blocks", "sns_topics_not_publicly_accessible", "sqs_queues_not_publicly_accessible", "ssm_documents_set_as_public", @@ -1091,11 +1079,10 @@ } ], "Checks": [ - "codebuild_project_artifact_encryption", - "codebuild_project_envvar_awscred_check", + "codebuild_project_no_secrets_in_variables", "codebuild_project_logging_enabled", "codebuild_project_older_90_days", - "codebuild_project_source_repo_url_check", + "codebuild_project_source_repo_url_no_sensitive_credentials", "codebuild_project_user_controlled_buildspec" ] }, @@ -1378,7 +1365,6 @@ "apigateway_restapi_logging_enabled", "apigatewayv2_api_access_logging_enabled", "appsync_field_level_logging_enabled", - "athena_workgroup_logging_enabled", "awslambda_function_invoke_api_operations_cloudtrail_logging_enabled", "bedrock_model_invocation_logging_enabled", "bedrock_model_invocation_logs_encryption_enabled", @@ -1631,7 +1617,6 @@ "ec2_securitygroup_from_launch_wizard", "ec2_securitygroup_not_used", "ec2_securitygroup_with_many_ingress_egress_rules", - "ec2_transitgateway_auto_accept_vpc_attachments", "ec2_confidential_workload_host_imdsv2_not_enforced", "ec2_confidential_workload_host_public_ip", "ec2_confidential_workload_host_unrestricted_ingress", @@ -1730,7 +1715,6 @@ "ec2_securitygroup_from_launch_wizard", "ec2_securitygroup_not_used", "ec2_securitygroup_with_many_ingress_egress_rules", - "ec2_transitgateway_auto_accept_vpc_attachments", "ec2_confidential_workload_host_imdsv2_not_enforced", "ec2_confidential_workload_host_public_ip", "ec2_confidential_workload_host_unrestricted_ingress" @@ -1828,7 +1812,6 @@ "ec2_securitygroup_from_launch_wizard", "ec2_securitygroup_not_used", "ec2_securitygroup_with_many_ingress_egress_rules", - "ec2_transitgateway_auto_accept_vpc_attachments", "ec2_confidential_workload_host_imdsv2_not_enforced", "ec2_confidential_workload_host_public_ip", "ec2_confidential_workload_host_unrestricted_ingress" @@ -1847,7 +1830,7 @@ } ], "Checks": [ - "vpc_default_security_group_closed", + "ec2_securitygroup_default_restrict_traffic", "vpc_flow_logs_enabled", "securityhub_enabled" ], @@ -1931,9 +1914,6 @@ "storagegateway_fileshare_encryption_enabled", "transfer_server_in_transit_encryption_enabled", "workspaces_volume_encryption_enabled", - "dynamodb_tables_kms_cmk_encryption_enabled", - "eks_cluster_kms_cmk_encryption_in_secrets_enabled", - "kafka_cluster_encryption_at_rest_uses_cmk", "kms_cmk_are_used", "kms_cmk_not_deleted_unintentionally", "kms_cmk_not_multi_region", diff --git a/prowler/compliance/aws/kisa_isms_p_2023_aws.json b/prowler/compliance/aws/kisa_isms_p_2023_aws.json index 78b76a5b9b..b15d98c635 100644 --- a/prowler/compliance/aws/kisa_isms_p_2023_aws.json +++ b/prowler/compliance/aws/kisa_isms_p_2023_aws.json @@ -2603,7 +2603,6 @@ "cloudwatch_changes_to_network_gateways_alarm_configured", "cloudwatch_changes_to_network_route_tables_alarm_configured", "cloudwatch_changes_to_vpcs_alarm_configured", - "cloudwatch_log_group_no_critical_pii_in_logs", "cloudwatch_log_group_no_secrets_in_logs", "cloudwatch_log_group_retention_policy_specific_days_enabled", "codebuild_project_logging_enabled", @@ -2793,7 +2792,6 @@ "cloudwatch_changes_to_vpcs_alarm_configured", "cloudwatch_cross_account_sharing_disabled", "cloudwatch_log_group_kms_encryption_enabled", - "cloudwatch_log_group_no_critical_pii_in_logs", "cloudwatch_log_group_no_secrets_in_logs", "cloudwatch_log_group_not_publicly_accessible", "cloudwatch_log_group_retention_policy_specific_days_enabled", @@ -2991,7 +2989,6 @@ "cloudwatch_changes_to_vpcs_alarm_configured", "cloudwatch_cross_account_sharing_disabled", "cloudwatch_log_group_kms_encryption_enabled", - "cloudwatch_log_group_no_critical_pii_in_logs", "cloudwatch_log_group_no_secrets_in_logs", "cloudwatch_log_group_not_publicly_accessible", "cloudwatch_log_group_retention_policy_specific_days_enabled", diff --git a/prowler/compliance/aws/kisa_isms_p_2023_korean_aws.json b/prowler/compliance/aws/kisa_isms_p_2023_korean_aws.json index 668b6d0c21..e9a871b85c 100644 --- a/prowler/compliance/aws/kisa_isms_p_2023_korean_aws.json +++ b/prowler/compliance/aws/kisa_isms_p_2023_korean_aws.json @@ -2606,7 +2606,6 @@ "cloudwatch_changes_to_network_gateways_alarm_configured", "cloudwatch_changes_to_network_route_tables_alarm_configured", "cloudwatch_changes_to_vpcs_alarm_configured", - "cloudwatch_log_group_no_critical_pii_in_logs", "cloudwatch_log_group_no_secrets_in_logs", "cloudwatch_log_group_retention_policy_specific_days_enabled", "codebuild_project_logging_enabled", @@ -2796,7 +2795,6 @@ "cloudwatch_changes_to_vpcs_alarm_configured", "cloudwatch_cross_account_sharing_disabled", "cloudwatch_log_group_kms_encryption_enabled", - "cloudwatch_log_group_no_critical_pii_in_logs", "cloudwatch_log_group_no_secrets_in_logs", "cloudwatch_log_group_not_publicly_accessible", "cloudwatch_log_group_retention_policy_specific_days_enabled", @@ -2994,7 +2992,6 @@ "cloudwatch_changes_to_vpcs_alarm_configured", "cloudwatch_cross_account_sharing_disabled", "cloudwatch_log_group_kms_encryption_enabled", - "cloudwatch_log_group_no_critical_pii_in_logs", "cloudwatch_log_group_no_secrets_in_logs", "cloudwatch_log_group_not_publicly_accessible", "cloudwatch_log_group_retention_policy_specific_days_enabled", diff --git a/prowler/compliance/aws/nis2_aws.json b/prowler/compliance/aws/nis2_aws.json index 995c9a6dac..f1f8f28daa 100644 --- a/prowler/compliance/aws/nis2_aws.json +++ b/prowler/compliance/aws/nis2_aws.json @@ -326,7 +326,6 @@ "cloudwatch_changes_to_vpcs_alarm_configured", "cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled", "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", - "cloudwatch_log_metric_filter_aws_organizations_changes", "cloudwatch_log_metric_filter_for_s3_bucket_policy_changes", "cloudwatch_log_metric_filter_policy_changes", "cloudwatch_log_metric_filter_security_group_changes" diff --git a/prowler/compliance/aws/nist_800_171_revision_2_aws.json b/prowler/compliance/aws/nist_800_171_revision_2_aws.json index ae7b9998b3..80992b7b75 100644 --- a/prowler/compliance/aws/nist_800_171_revision_2_aws.json +++ b/prowler/compliance/aws/nist_800_171_revision_2_aws.json @@ -29,7 +29,6 @@ "iam_root_mfa_enabled", "iam_no_root_access_key", "iam_user_mfa_enabled_console_access", - "iam_user_mfa_enabled_console_access", "iam_role_access_not_stale_to_bedrock", "iam_user_access_not_stale_to_bedrock", "iam_user_access_not_stale_to_sagemaker", @@ -46,8 +45,7 @@ "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" ] }, { @@ -74,7 +72,6 @@ "iam_root_mfa_enabled", "iam_no_root_access_key", "iam_user_mfa_enabled_console_access", - "iam_user_mfa_enabled_console_access", "iam_role_access_not_stale_to_bedrock", "iam_user_access_not_stale_to_bedrock", "iam_user_access_not_stale_to_sagemaker", @@ -91,8 +88,7 @@ "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" ] }, { @@ -122,8 +118,7 @@ "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" ] }, { @@ -223,7 +218,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "elbv2_logging_enabled", "elb_logging_enabled", "guardduty_is_enabled", @@ -286,8 +280,7 @@ "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" ] }, { @@ -305,8 +298,7 @@ "s3_account_level_public_access_blocks", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" ] }, { @@ -325,7 +317,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "cloudwatch_log_group_retention_policy_specific_days_enabled", "elbv2_logging_enabled", @@ -367,7 +358,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "guardduty_is_enabled", "rds_instance_integration_cloudwatch_logs", @@ -398,7 +388,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "rds_instance_integration_cloudwatch_logs", "s3_bucket_server_access_logging_enabled", @@ -571,7 +560,6 @@ "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port", "ec2_confidential_workload_host_vsock_proxy_exposed" ] }, @@ -604,7 +592,6 @@ ], "Checks": [ "iam_root_mfa_enabled", - "iam_user_mfa_enabled_console_access", "iam_user_mfa_enabled_console_access" ] }, @@ -622,7 +609,6 @@ "Checks": [ "iam_root_hardware_mfa_enabled", "iam_root_mfa_enabled", - "iam_user_mfa_enabled_console_access", "iam_user_mfa_enabled_console_access" ] }, @@ -928,7 +914,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_log_file_validation_enabled", "elbv2_logging_enabled", "elb_logging_enabled", @@ -944,8 +929,7 @@ "securityhub_enabled", "vpc_flow_logs_enabled", "ec2_networkacl_allow_ingress_any_port", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" ], "ConfigRequirements": [ { @@ -988,7 +972,6 @@ "rds_instance_integration_cloudwatch_logs", "rds_instance_multi_az", "rds_instance_no_public_access", - "rds_instance_backup_enabled", "redshift_cluster_public_access", "s3_bucket_public_access", "s3_bucket_policy_public_write_access", @@ -1060,7 +1043,6 @@ "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port", "ec2_confidential_workload_host_public_ip" ] }, @@ -1077,8 +1059,7 @@ ], "Checks": [ "ec2_networkacl_allow_ingress_any_port", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" ] }, { @@ -1155,7 +1136,6 @@ "Checks": [ "cloudtrail_kms_encryption_enabled", "dynamodb_tables_kms_cmk_encryption_enabled", - "dynamodb_tables_kms_cmk_encryption_enabled", "ec2_ebs_volume_encryption", "efs_encryption_at_rest_enabled", "opensearch_service_domains_encryption_at_rest_enabled", @@ -1212,7 +1192,6 @@ "ec2_instance_managed_by_ssm", "guardduty_is_enabled", "securityhub_enabled", - "ssm_managed_compliant_patching", "ssm_managed_compliant_patching" ], "ConfigRequirements": [ @@ -1300,7 +1279,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "elbv2_logging_enabled", "elb_logging_enabled", "guardduty_is_enabled", @@ -1340,7 +1318,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "elbv2_logging_enabled", "elb_logging_enabled", "guardduty_is_enabled", diff --git a/prowler/compliance/aws/nist_800_53_revision_4_aws.json b/prowler/compliance/aws/nist_800_53_revision_4_aws.json index 8bd36a3910..c776806957 100644 --- a/prowler/compliance/aws/nist_800_53_revision_4_aws.json +++ b/prowler/compliance/aws/nist_800_53_revision_4_aws.json @@ -108,7 +108,6 @@ } ], "Checks": [ - "cloudtrail_multi_region_enabled", "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "cloudwatch_changes_to_network_acls_alarm_configured", @@ -239,8 +238,7 @@ "redshift_cluster_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured", "s3_bucket_public_access", - "s3_bucket_policy_public_write_access", - "s3_bucket_public_access" + "s3_bucket_policy_public_write_access" ] }, { @@ -266,12 +264,10 @@ "redshift_cluster_public_access", "s3_bucket_public_access", "s3_bucket_policy_public_write_access", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" ] }, { @@ -340,7 +336,6 @@ "redshift_cluster_public_access", "s3_bucket_public_access", "s3_bucket_policy_public_write_access", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured" ] }, @@ -425,7 +420,6 @@ "redshift_cluster_public_access", "s3_bucket_public_access", "s3_bucket_policy_public_write_access", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured" ] }, @@ -445,7 +439,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "elbv2_logging_enabled", "elb_logging_enabled", @@ -471,7 +464,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "elbv2_logging_enabled", "elb_logging_enabled", @@ -633,7 +625,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "elbv2_logging_enabled", "elb_logging_enabled", @@ -746,7 +737,6 @@ ], "Checks": [ "ec2_instance_managed_by_ssm", - "ssm_managed_compliant_patching", "ssm_managed_compliant_patching" ] }, @@ -764,7 +754,6 @@ "Checks": [ "dynamodb_tables_pitr_enabled", "efs_have_backup_enabled", - "rds_instance_backup_enabled", "rds_instance_backup_enabled" ] }, @@ -784,7 +773,6 @@ "efs_have_backup_enabled", "elbv2_deletion_protection", "rds_instance_backup_enabled", - "rds_instance_backup_enabled", "rds_instance_multi_az", "s3_bucket_object_versioning" ] @@ -804,7 +792,6 @@ "Checks": [ "iam_root_hardware_mfa_enabled", "iam_root_mfa_enabled", - "iam_user_mfa_enabled_console_access", "iam_user_mfa_enabled_console_access" ] }, @@ -821,7 +808,6 @@ } ], "Checks": [ - "iam_user_mfa_enabled_console_access", "iam_user_mfa_enabled_console_access" ] }, @@ -840,7 +826,6 @@ "Checks": [ "iam_root_hardware_mfa_enabled", "iam_root_mfa_enabled", - "iam_user_mfa_enabled_console_access", "iam_user_mfa_enabled_console_access" ] }, @@ -1101,8 +1086,7 @@ "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" ] }, { @@ -1135,8 +1119,7 @@ "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" ] }, { @@ -1237,7 +1220,6 @@ "opensearch_service_domains_encryption_at_rest_enabled", "cloudwatch_log_group_kms_encryption_enabled", "rds_instance_storage_encrypted", - "rds_instance_storage_encrypted", "redshift_cluster_audit_logging", "s3_bucket_default_encryption", "sagemaker_notebook_instance_encryption_enabled", @@ -1258,7 +1240,6 @@ ], "Checks": [ "ec2_instance_managed_by_ssm", - "ssm_managed_compliant_patching", "ssm_managed_compliant_patching" ] }, @@ -1513,7 +1494,6 @@ "dynamodb_tables_pitr_enabled", "efs_have_backup_enabled", "rds_instance_backup_enabled", - "rds_instance_backup_enabled", "s3_bucket_object_versioning" ] } diff --git a/prowler/compliance/aws/nist_800_53_revision_5_aws.json b/prowler/compliance/aws/nist_800_53_revision_5_aws.json index 13a0e0772c..8471d77f1c 100644 --- a/prowler/compliance/aws/nist_800_53_revision_5_aws.json +++ b/prowler/compliance/aws/nist_800_53_revision_5_aws.json @@ -28,7 +28,6 @@ "iam_no_root_access_key", "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", - "iam_user_mfa_enabled_console_access", "iam_role_access_not_stale_to_bedrock", "iam_user_access_not_stale_to_bedrock", "iam_user_access_not_stale_to_sagemaker", @@ -162,7 +161,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "rds_instance_integration_cloudwatch_logs", "redshift_cluster_audit_logging", @@ -201,7 +199,6 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured" ] }, @@ -336,7 +333,6 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_confidential_workload_host_imdsv2_not_enforced", "kms_key_enclave_attestation_not_enforced" @@ -357,7 +353,6 @@ "Checks": [ "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", "rds_instance_integration_cloudwatch_logs", @@ -380,7 +375,6 @@ "Checks": [ "iam_root_hardware_mfa_enabled", "iam_root_mfa_enabled", - "iam_user_mfa_enabled_console_access", "iam_user_mfa_enabled_console_access" ] }, @@ -408,7 +402,6 @@ "iam_no_root_access_key", "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", - "iam_user_mfa_enabled_console_access", "iam_user_accesskey_unused", "iam_user_console_access_unused", "secretsmanager_automatic_rotation_enabled" @@ -439,7 +432,6 @@ "iam_no_root_access_key", "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", - "iam_user_mfa_enabled_console_access", "iam_user_accesskey_unused", "iam_user_console_access_unused", "secretsmanager_automatic_rotation_enabled" @@ -470,7 +462,6 @@ "iam_no_root_access_key", "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", - "iam_user_mfa_enabled_console_access", "iam_user_accesskey_unused", "iam_user_console_access_unused", "secretsmanager_automatic_rotation_enabled" @@ -501,7 +492,6 @@ "iam_no_root_access_key", "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", - "iam_user_mfa_enabled_console_access", "iam_user_accesskey_unused", "iam_user_console_access_unused", "secretsmanager_automatic_rotation_enabled" @@ -532,7 +522,6 @@ "iam_no_root_access_key", "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", - "iam_user_mfa_enabled_console_access", "iam_user_accesskey_unused", "iam_user_console_access_unused", "secretsmanager_automatic_rotation_enabled" @@ -563,7 +552,6 @@ "iam_no_root_access_key", "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", - "iam_user_mfa_enabled_console_access", "iam_user_accesskey_unused", "iam_user_console_access_unused", "secretsmanager_automatic_rotation_enabled" @@ -594,7 +582,6 @@ "iam_no_root_access_key", "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", - "iam_user_mfa_enabled_console_access", "iam_user_accesskey_unused", "iam_user_console_access_unused", "secretsmanager_automatic_rotation_enabled" @@ -625,7 +612,6 @@ "iam_no_root_access_key", "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", - "iam_user_mfa_enabled_console_access", "iam_user_accesskey_unused", "iam_user_console_access_unused", "secretsmanager_automatic_rotation_enabled" @@ -655,7 +641,6 @@ "iam_no_root_access_key", "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", - "iam_user_mfa_enabled_console_access", "iam_user_accesskey_unused", "iam_user_console_access_unused", "secretsmanager_automatic_rotation_enabled" @@ -686,7 +671,6 @@ "iam_no_root_access_key", "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", - "iam_user_mfa_enabled_console_access", "iam_user_accesskey_unused", "iam_user_console_access_unused", "secretsmanager_automatic_rotation_enabled" @@ -710,7 +694,6 @@ "iam_user_accesskey_unused", "iam_user_console_access_unused", "iam_user_mfa_enabled_console_access", - "iam_user_mfa_enabled_console_access", "iam_rotate_access_key_90_days", "iam_no_root_access_key", "iam_root_mfa_enabled", @@ -748,7 +731,6 @@ "iam_no_root_access_key", "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", - "iam_user_mfa_enabled_console_access", "iam_user_accesskey_unused", "iam_user_console_access_unused", "secretsmanager_automatic_rotation_enabled" @@ -779,7 +761,6 @@ "iam_no_root_access_key", "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", - "iam_user_mfa_enabled_console_access", "iam_user_accesskey_unused", "iam_user_console_access_unused", "secretsmanager_automatic_rotation_enabled" @@ -810,7 +791,6 @@ "iam_no_root_access_key", "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", - "iam_user_mfa_enabled_console_access", "iam_user_accesskey_unused", "iam_user_console_access_unused", "secretsmanager_automatic_rotation_enabled" @@ -848,7 +828,6 @@ "redshift_cluster_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured" ] }, @@ -876,7 +855,6 @@ "iam_no_root_access_key", "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", - "iam_user_mfa_enabled_console_access", "iam_role_access_not_stale_to_bedrock", "iam_user_access_not_stale_to_bedrock", "iam_user_access_not_stale_to_sagemaker", @@ -901,7 +879,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "rds_instance_integration_cloudwatch_logs", "redshift_cluster_audit_logging", @@ -933,7 +910,6 @@ "iam_no_root_access_key", "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", - "iam_user_mfa_enabled_console_access", "iam_user_accesskey_unused", "iam_user_console_access_unused", "secretsmanager_automatic_rotation_enabled" @@ -988,7 +964,6 @@ "iam_no_root_access_key", "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", - "iam_user_mfa_enabled_console_access", "iam_user_accesskey_unused", "iam_user_console_access_unused", "secretsmanager_automatic_rotation_enabled" @@ -1019,7 +994,6 @@ "iam_no_root_access_key", "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", - "iam_user_mfa_enabled_console_access", "iam_user_accesskey_unused", "iam_user_console_access_unused", "secretsmanager_automatic_rotation_enabled" @@ -1050,7 +1024,6 @@ "iam_no_root_access_key", "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", - "iam_user_mfa_enabled_console_access", "iam_user_accesskey_unused", "iam_user_console_access_unused", "secretsmanager_automatic_rotation_enabled" @@ -1100,11 +1073,9 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", - "ec2_networkacl_allow_ingress_any_port", "ec2_confidential_workload_host_public_ip" ] }, @@ -1144,7 +1115,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "elbv2_logging_enabled", "elb_logging_enabled", @@ -1178,7 +1148,6 @@ "iam_no_root_access_key", "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", - "iam_user_mfa_enabled_console_access", "iam_user_accesskey_unused", "iam_user_console_access_unused", "secretsmanager_automatic_rotation_enabled" @@ -1238,7 +1207,6 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_confidential_workload_host_imdsv2_not_enforced", "kms_key_enclave_attestation_bypassable_path" @@ -1298,7 +1266,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "rds_instance_integration_cloudwatch_logs", "redshift_cluster_audit_logging", @@ -1340,7 +1307,6 @@ "iam_password_policy_minimum_length_14", "iam_root_hardware_mfa_enabled", "iam_root_mfa_enabled", - "iam_user_mfa_enabled_console_access", "iam_user_mfa_enabled_console_access" ] }, @@ -1361,7 +1327,6 @@ "iam_password_policy_minimum_length_14", "iam_root_hardware_mfa_enabled", "iam_root_mfa_enabled", - "iam_user_mfa_enabled_console_access", "iam_user_mfa_enabled_console_access" ] }, @@ -1405,12 +1370,10 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" ] }, { @@ -1437,12 +1400,10 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" ] }, { @@ -1488,10 +1449,8 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_securitygroup_default_restrict_traffic", - "ec2_networkacl_allow_ingress_any_port", "ec2_networkacl_allow_ingress_any_port" ] }, @@ -1519,12 +1478,10 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" ] }, { @@ -1551,12 +1508,10 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" ] }, { @@ -1582,7 +1537,6 @@ "iam_no_root_access_key", "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", - "iam_user_mfa_enabled_console_access", "iam_user_accesskey_unused", "iam_user_console_access_unused", "secretsmanager_automatic_rotation_enabled" @@ -1624,7 +1578,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_bedrock_logging_enabled", "cloudtrail_cloudwatch_logging_enabled", "elbv2_logging_enabled", @@ -1677,7 +1630,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "elbv2_logging_enabled", "elb_logging_enabled", @@ -1704,7 +1656,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "elbv2_logging_enabled", "elb_logging_enabled", @@ -1731,7 +1682,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "elbv2_logging_enabled", "elb_logging_enabled", @@ -1758,7 +1708,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "elbv2_logging_enabled", "elb_logging_enabled", @@ -1785,7 +1734,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "elbv2_logging_enabled", "elb_logging_enabled", @@ -1812,7 +1760,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "elbv2_logging_enabled", "elb_logging_enabled", @@ -1890,7 +1837,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "cloudwatch_log_group_retention_policy_specific_days_enabled", "elbv2_logging_enabled", @@ -1918,7 +1864,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "cloudwatch_log_group_retention_policy_specific_days_enabled", "elbv2_logging_enabled", @@ -1982,7 +1927,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "cloudwatch_log_group_retention_policy_specific_days_enabled", "elbv2_logging_enabled", @@ -2010,7 +1954,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "cloudwatch_log_group_retention_policy_specific_days_enabled", "elbv2_logging_enabled", @@ -2054,7 +1997,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "elbv2_logging_enabled", "elb_logging_enabled", @@ -2120,7 +2062,6 @@ "opensearch_service_domains_node_to_node_encryption_enabled", "cloudwatch_log_group_kms_encryption_enabled", "rds_instance_storage_encrypted", - "rds_instance_storage_encrypted", "redshift_cluster_audit_logging", "s3_bucket_default_encryption", "s3_bucket_secure_transport_policy", @@ -2160,7 +2101,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "cloudwatch_log_group_retention_policy_specific_days_enabled", "elbv2_logging_enabled", @@ -2219,7 +2159,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_bedrock_logging_enabled", "cloudtrail_cloudwatch_logging_enabled", "elbv2_logging_enabled", @@ -2247,7 +2186,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_bedrock_logging_enabled", "cloudtrail_cloudwatch_logging_enabled", "elbv2_logging_enabled", @@ -2275,7 +2213,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "cloudwatch_log_group_retention_policy_specific_days_enabled", "elbv2_logging_enabled", @@ -2303,7 +2240,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "cloudwatch_log_group_retention_policy_specific_days_enabled", "elbv2_logging_enabled", @@ -2331,7 +2267,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "cloudwatch_changes_to_network_acls_alarm_configured", "cloudwatch_changes_to_network_gateways_alarm_configured", @@ -2380,7 +2315,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "elbv2_logging_enabled", "elb_logging_enabled", @@ -2407,7 +2341,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "cloudwatch_changes_to_network_acls_alarm_configured", "cloudwatch_changes_to_network_gateways_alarm_configured", @@ -2456,7 +2389,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "cloudwatch_changes_to_network_acls_alarm_configured", "cloudwatch_changes_to_network_gateways_alarm_configured", @@ -2505,7 +2437,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "elbv2_logging_enabled", "elb_logging_enabled", @@ -2633,7 +2564,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "cloudwatch_changes_to_network_acls_alarm_configured", "cloudwatch_changes_to_network_gateways_alarm_configured", @@ -2882,7 +2812,6 @@ "iam_no_root_access_key", "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", - "iam_user_mfa_enabled_console_access", "iam_user_accesskey_unused", "iam_user_console_access_unused", "secretsmanager_automatic_rotation_enabled" @@ -2906,7 +2835,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "elbv2_logging_enabled", "elb_logging_enabled", @@ -3045,7 +2973,6 @@ "Checks": [ "ec2_instance_managed_by_ssm", "guardduty_is_enabled", - "ssm_managed_compliant_patching", "ssm_managed_compliant_patching" ], "ConfigRequirements": [ @@ -3317,7 +3244,6 @@ "efs_have_backup_enabled", "rds_instance_backup_enabled", "redshift_cluster_automated_snapshot", - "rds_instance_backup_enabled", "dynamodb_tables_pitr_enabled", "s3_bucket_object_versioning" ] @@ -3432,7 +3358,6 @@ "elbv2_deletion_protection", "rds_instance_backup_enabled", "rds_instance_deletion_protection", - "rds_instance_backup_enabled", "rds_instance_multi_az", "redshift_cluster_automated_snapshot", "s3_bucket_object_versioning", @@ -3470,7 +3395,6 @@ "Checks": [ "efs_have_backup_enabled", "rds_instance_backup_enabled", - "rds_instance_backup_enabled", "redshift_cluster_automated_snapshot", "s3_bucket_object_versioning" ] @@ -3490,7 +3414,6 @@ "Checks": [ "efs_have_backup_enabled", "rds_instance_backup_enabled", - "rds_instance_backup_enabled", "redshift_cluster_automated_snapshot", "s3_bucket_object_versioning" ] @@ -3511,7 +3434,6 @@ "dynamodb_tables_pitr_enabled", "efs_have_backup_enabled", "rds_instance_backup_enabled", - "rds_instance_backup_enabled", "rds_instance_multi_az", "redshift_cluster_automated_snapshot", "s3_bucket_object_versioning" @@ -3533,7 +3455,6 @@ "dynamodb_tables_pitr_enabled", "efs_have_backup_enabled", "rds_instance_backup_enabled", - "rds_instance_backup_enabled", "redshift_cluster_automated_snapshot", "redshift_cluster_automatic_upgrades", "s3_bucket_object_versioning" @@ -3555,7 +3476,6 @@ "dynamodb_tables_pitr_enabled", "efs_have_backup_enabled", "rds_instance_backup_enabled", - "rds_instance_backup_enabled", "redshift_cluster_automated_snapshot", "redshift_cluster_automatic_upgrades", "s3_bucket_object_versioning" @@ -3577,7 +3497,6 @@ "efs_have_backup_enabled", "rds_instance_backup_enabled", "redshift_cluster_automated_snapshot", - "rds_instance_backup_enabled", "dynamodb_tables_pitr_enabled", "redshift_cluster_automatic_upgrades", "s3_bucket_object_versioning" @@ -3603,10 +3522,8 @@ "opensearch_service_domains_encryption_at_rest_enabled", "cloudwatch_log_group_kms_encryption_enabled", "rds_instance_storage_encrypted", - "rds_instance_storage_encrypted", "redshift_cluster_audit_logging", "s3_bucket_default_encryption", - "s3_bucket_default_encryption", "sagemaker_notebook_instance_encryption_enabled", "sns_topics_kms_encryption_at_rest_enabled" ] @@ -3625,7 +3542,6 @@ ], "Checks": [ "rds_instance_storage_encrypted", - "s3_bucket_default_encryption", "s3_bucket_default_encryption" ] }, @@ -3644,7 +3560,6 @@ "dynamodb_tables_pitr_enabled", "efs_have_backup_enabled", "rds_instance_backup_enabled", - "rds_instance_backup_enabled", "rds_instance_multi_az", "redshift_cluster_automated_snapshot", "s3_bucket_object_versioning", @@ -3667,7 +3582,6 @@ "dynamodb_tables_pitr_enabled", "efs_have_backup_enabled", "rds_instance_backup_enabled", - "rds_instance_backup_enabled", "redshift_cluster_automated_snapshot", "s3_bucket_object_versioning" ] @@ -3702,7 +3616,6 @@ "Checks": [ "iam_root_hardware_mfa_enabled", "iam_root_mfa_enabled", - "iam_user_mfa_enabled_console_access", "iam_user_mfa_enabled_console_access" ] }, @@ -3721,7 +3634,6 @@ "Checks": [ "iam_root_hardware_mfa_enabled", "iam_root_mfa_enabled", - "iam_user_mfa_enabled_console_access", "iam_user_mfa_enabled_console_access" ] }, @@ -3740,7 +3652,6 @@ "Checks": [ "iam_root_hardware_mfa_enabled", "iam_root_mfa_enabled", - "iam_user_mfa_enabled_console_access", "iam_user_mfa_enabled_console_access" ] }, @@ -3760,7 +3671,6 @@ "Checks": [ "iam_root_hardware_mfa_enabled", "iam_root_mfa_enabled", - "iam_user_mfa_enabled_console_access", "iam_user_mfa_enabled_console_access" ] }, @@ -3779,7 +3689,6 @@ "Checks": [ "iam_root_hardware_mfa_enabled", "iam_root_mfa_enabled", - "iam_user_mfa_enabled_console_access", "iam_user_mfa_enabled_console_access" ] }, @@ -3800,7 +3709,6 @@ "cloudtrail_multi_region_enabled", "apigateway_restapi_logging_enabled", "cloudtrail_cloudwatch_logging_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", "elbv2_logging_enabled", @@ -4040,23 +3948,6 @@ "iam_password_policy_minimum_length_14" ] }, - { - "Id": "ia_5_1_h", - "Name": "IA-5(1)(h)", - "Description": "For password-based authentication: (h) Enforce the following composition and complexity rules: [Assignment: organization-defined composition and complexity rules].", - "Attributes": [ - { - "ItemId": "ia_5_1_h", - "Section": "Identification and Authentication (IA)", - "SubSection": "Authenticator Management (IA-5)", - "SubGroup": "IA-5(1) Password-Based Authentication", - "Service": "iam" - } - ], - "Checks": [ - "iam_password_policy_minimum_length_14" - ] - }, { "Id": "ia_5_8", "Name": "IA-5(8) Multiple System Accounts", @@ -4173,7 +4064,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "elbv2_logging_enabled", "elb_logging_enabled", @@ -4214,7 +4104,6 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured" ] }, @@ -4302,7 +4191,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "cloudwatch_changes_to_network_acls_alarm_configured", "cloudwatch_changes_to_network_gateways_alarm_configured", @@ -4351,7 +4239,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "cloudwatch_changes_to_network_acls_alarm_configured", "cloudwatch_changes_to_network_gateways_alarm_configured", @@ -4459,7 +4346,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "cloudwatch_changes_to_network_acls_alarm_configured", "cloudwatch_changes_to_network_gateways_alarm_configured", @@ -4844,7 +4730,6 @@ "rds_instance_backup_enabled", "rds_instance_deletion_protection", "rds_instance_multi_az", - "rds_instance_backup_enabled", "redshift_cluster_automated_snapshot", "redshift_cluster_automatic_upgrades", "s3_bucket_object_versioning" @@ -4987,7 +4872,6 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured" ] }, @@ -5015,7 +4899,6 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_confidential_workload_host_public_ip" ] @@ -5078,7 +4961,6 @@ "s3_bucket_secure_transport_policy", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", - "ec2_networkacl_allow_ingress_any_port", "ec2_confidential_workload_host_unrestricted_ingress" ] }, @@ -5105,12 +4987,10 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" ] }, { @@ -5138,7 +5018,6 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured" ] }, @@ -5160,7 +5039,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "elbv2_logging_enabled", "elb_logging_enabled", @@ -5193,12 +5071,10 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port", "ec2_confidential_workload_host_public_ip" ] }, @@ -5230,8 +5106,7 @@ "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" ] }, { @@ -5262,8 +5137,7 @@ "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" ] }, { @@ -5290,7 +5164,6 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured" ] }, @@ -5318,12 +5191,10 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" ] }, { @@ -5351,12 +5222,10 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" ] }, { @@ -5382,7 +5251,6 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", @@ -5412,7 +5280,6 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", @@ -5442,7 +5309,6 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", @@ -5472,7 +5338,6 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", @@ -5503,12 +5368,10 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port", "ec2_confidential_workload_host_unrestricted_ingress" ] }, @@ -5536,7 +5399,6 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured" ] }, @@ -5564,12 +5426,10 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" ] }, { @@ -5657,10 +5517,8 @@ "opensearch_service_domains_node_to_node_encryption_enabled", "cloudwatch_log_group_kms_encryption_enabled", "rds_instance_storage_encrypted", - "rds_instance_storage_encrypted", "redshift_cluster_audit_logging", "s3_bucket_default_encryption", - "s3_bucket_default_encryption", "s3_bucket_secure_transport_policy", "sagemaker_notebook_instance_encryption_enabled", "sns_topics_kms_encryption_at_rest_enabled" @@ -5690,10 +5548,8 @@ "opensearch_service_domains_node_to_node_encryption_enabled", "cloudwatch_log_group_kms_encryption_enabled", "rds_instance_storage_encrypted", - "rds_instance_storage_encrypted", "redshift_cluster_audit_logging", "s3_bucket_default_encryption", - "s3_bucket_default_encryption", "s3_bucket_secure_transport_policy", "sagemaker_notebook_instance_encryption_enabled", "sns_topics_kms_encryption_at_rest_enabled" @@ -5792,10 +5648,8 @@ "opensearch_service_domains_node_to_node_encryption_enabled", "cloudwatch_log_group_kms_encryption_enabled", "rds_instance_storage_encrypted", - "rds_instance_storage_encrypted", "redshift_cluster_audit_logging", "s3_bucket_default_encryption", - "s3_bucket_default_encryption", "s3_bucket_secure_transport_policy", "sagemaker_notebook_instance_encryption_enabled", "sns_topics_kms_encryption_at_rest_enabled" @@ -5879,7 +5733,6 @@ "iam_no_root_access_key", "iam_rotate_access_key_90_days", "iam_user_mfa_enabled_console_access", - "iam_user_mfa_enabled_console_access", "iam_user_accesskey_unused", "iam_user_console_access_unused", "secretsmanager_automatic_rotation_enabled" @@ -5932,7 +5785,6 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured" ] }, @@ -5957,10 +5809,8 @@ "opensearch_service_domains_encryption_at_rest_enabled", "cloudwatch_log_group_kms_encryption_enabled", "rds_instance_storage_encrypted", - "rds_instance_storage_encrypted", "redshift_cluster_audit_logging", "s3_bucket_default_encryption", - "s3_bucket_default_encryption", "sagemaker_notebook_instance_encryption_enabled", "sns_topics_kms_encryption_at_rest_enabled", "kms_key_enclave_attestation_not_enforced", @@ -6080,7 +5930,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "rds_instance_integration_cloudwatch_logs", "redshift_cluster_audit_logging", @@ -6189,7 +6038,6 @@ ], "Checks": [ "ec2_instance_managed_by_ssm", - "ssm_managed_compliant_patching", "ssm_managed_compliant_patching" ] }, @@ -6235,7 +6083,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "rds_instance_integration_cloudwatch_logs", "redshift_cluster_audit_logging", @@ -6419,7 +6266,6 @@ "Checks": [ "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", "guardduty_is_enabled", @@ -6621,7 +6467,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "cloudwatch_log_group_retention_policy_specific_days_enabled", "elbv2_logging_enabled", @@ -6648,7 +6493,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "rds_instance_integration_cloudwatch_logs", "redshift_cluster_audit_logging", @@ -6823,7 +6667,6 @@ "cloudtrail_multi_region_enabled", "apigateway_restapi_logging_enabled", "cloudtrail_cloudwatch_logging_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", "elbv2_logging_enabled", @@ -6868,7 +6711,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "rds_instance_integration_cloudwatch_logs", "redshift_cluster_audit_logging", @@ -6908,7 +6750,6 @@ "rds_instance_backup_enabled", "rds_instance_deletion_protection", "rds_instance_multi_az", - "rds_instance_backup_enabled", "redshift_cluster_automated_snapshot", "s3_bucket_object_versioning" ] @@ -6934,10 +6775,8 @@ "opensearch_service_domains_encryption_at_rest_enabled", "cloudwatch_log_group_kms_encryption_enabled", "rds_instance_storage_encrypted", - "rds_instance_storage_encrypted", "redshift_cluster_audit_logging", "s3_bucket_default_encryption", - "s3_bucket_default_encryption", "sagemaker_notebook_instance_encryption_enabled" ] } diff --git a/prowler/compliance/aws/nist_csf_1.1_aws.json b/prowler/compliance/aws/nist_csf_1.1_aws.json index 9921efce56..4d0be48a04 100644 --- a/prowler/compliance/aws/nist_csf_1.1_aws.json +++ b/prowler/compliance/aws/nist_csf_1.1_aws.json @@ -20,7 +20,6 @@ "Checks": [ "apigateway_restapi_logging_enabled", "cloudtrail_multi_region_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "elbv2_logging_enabled", "elb_logging_enabled", @@ -29,8 +28,7 @@ "ec2_securitygroup_default_restrict_traffic", "vpc_flow_logs_enabled", "ec2_networkacl_allow_ingress_any_port", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" ] }, { @@ -81,7 +79,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "elbv2_logging_enabled", "elb_logging_enabled", @@ -106,7 +103,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "elbv2_logging_enabled", "elb_logging_enabled", "guardduty_is_enabled", @@ -165,7 +161,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "elbv2_logging_enabled", "elb_logging_enabled", "guardduty_is_enabled", @@ -259,7 +254,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "guardduty_is_enabled", "s3_bucket_server_access_logging_enabled", "securityhub_enabled" @@ -365,7 +359,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "guardduty_is_enabled", "s3_bucket_server_access_logging_enabled", "securityhub_enabled" @@ -402,7 +395,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "elbv2_logging_enabled", "elb_logging_enabled", "guardduty_is_enabled", @@ -537,7 +529,6 @@ "Checks": [ "apigateway_restapi_logging_enabled", "cloudtrail_multi_region_enabled", - "cloudtrail_multi_region_enabled", "elbv2_logging_enabled", "elb_logging_enabled", "redshift_cluster_audit_logging", @@ -838,7 +829,6 @@ "iam_root_hardware_mfa_enabled", "iam_root_mfa_enabled", "iam_user_mfa_enabled_console_access", - "iam_user_mfa_enabled_console_access", "awslambda_function_not_publicly_accessible", "awslambda_function_url_public", "rds_instance_no_public_access", @@ -850,8 +840,7 @@ "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" ] }, { @@ -908,8 +897,7 @@ "sagemaker_notebook_instance_without_direct_internet_access_configured", "ec2_securitygroup_default_restrict_traffic", "ec2_networkacl_allow_ingress_any_port", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" ] }, { @@ -925,7 +913,6 @@ } ], "Checks": [ - "cloudtrail_multi_region_enabled", "cloudtrail_multi_region_enabled", "redshift_cluster_audit_logging", "s3_bucket_server_access_logging_enabled" @@ -946,7 +933,6 @@ "Checks": [ "iam_root_hardware_mfa_enabled", "iam_root_mfa_enabled", - "iam_user_mfa_enabled_console_access", "iam_user_mfa_enabled_console_access" ] }, @@ -1047,7 +1033,6 @@ "cloudtrail_multi_region_enabled", "cloudtrail_s3_dataevents_read_enabled", "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled", "ec2_ebs_public_snapshot", "elbv2_logging_enabled", "elb_logging_enabled", @@ -1247,7 +1232,6 @@ "s3_bucket_public_access", "s3_bucket_policy_public_write_access", "s3_account_level_public_access_blocks", - "s3_bucket_public_access", "sagemaker_notebook_instance_without_direct_internet_access_configured" ] }, @@ -1265,13 +1249,10 @@ ], "Checks": [ "dynamodb_tables_pitr_enabled", - "dynamodb_tables_pitr_enabled", - "efs_have_backup_enabled", "efs_have_backup_enabled", "elbv2_deletion_protection", "rds_instance_backup_enabled", "rds_instance_multi_az", - "rds_instance_backup_enabled", "redshift_cluster_automated_snapshot", "s3_bucket_object_versioning" ] @@ -1291,7 +1272,6 @@ "Checks": [ "config_recorder_all_regions_enabled", "ec2_instance_managed_by_ssm", - "ssm_managed_compliant_patching", "ssm_managed_compliant_patching" ], "ConfigRequirements": [ @@ -1316,7 +1296,6 @@ } ], "Checks": [ - "cloudtrail_multi_region_enabled", "cloudtrail_multi_region_enabled" ] }, @@ -1335,7 +1314,6 @@ "Checks": [ "apigateway_restapi_logging_enabled", "cloudtrail_multi_region_enabled", - "cloudtrail_multi_region_enabled", "cloudtrail_cloudwatch_logging_enabled", "elbv2_logging_enabled", "elb_logging_enabled", @@ -1386,8 +1364,7 @@ "rds_instance_no_public_access", "redshift_cluster_public_access", "ec2_networkacl_allow_ingress_any_port", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", - "ec2_networkacl_allow_ingress_any_port" + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22" ] }, { @@ -1410,12 +1387,12 @@ ] }, { - "Id": "rp_1", + "Id": "rc_rp_1", "Name": "RC.RP-1", "Description": "Recovery plan is executed during or after a cybersecurity incident.", "Attributes": [ { - "ItemId": "rp_1", + "ItemId": "rc_rp_1", "Section": "Recover (RC)", "SubSection": "Recovery Planning (RC.RP)", "Service": "aws" @@ -1423,14 +1400,10 @@ ], "Checks": [ "dynamodb_tables_pitr_enabled", - "dynamodb_tables_pitr_enabled", - "efs_have_backup_enabled", "efs_have_backup_enabled", "elbv2_deletion_protection", "rds_instance_backup_enabled", - "rds_instance_backup_enabled", "rds_instance_multi_az", - "rds_instance_backup_enabled", "redshift_cluster_automated_snapshot", "s3_bucket_object_versioning" ] @@ -1481,14 +1454,10 @@ ], "Checks": [ "dynamodb_tables_pitr_enabled", - "dynamodb_tables_pitr_enabled", - "efs_have_backup_enabled", "efs_have_backup_enabled", "elbv2_deletion_protection", "rds_instance_backup_enabled", - "rds_instance_backup_enabled", "rds_instance_multi_az", - "rds_instance_backup_enabled", "redshift_cluster_automated_snapshot", "s3_bucket_object_versioning" ] diff --git a/prowler/compliance/aws/pci_3.2.1_aws.json b/prowler/compliance/aws/pci_3.2.1_aws.json index 85fef0b3e5..d5fc0ccf23 100644 --- a/prowler/compliance/aws/pci_3.2.1_aws.json +++ b/prowler/compliance/aws/pci_3.2.1_aws.json @@ -277,7 +277,7 @@ "Checks": [ "ec2_ebs_public_snapshot", "rds_instance_no_public_access", - "eks_endpoints_not_publicly_accessible", + "eks_cluster_not_publicly_accessible", "bedrock_vpc_endpoints_configured", "vpc_endpoint_for_ec2_enabled", "s3_account_level_public_access_blocks", @@ -474,7 +474,7 @@ "s3_bucket_cross_region_replication", "ec2_ebs_public_snapshot", "rds_instance_no_public_access", - "eks_endpoints_not_publicly_accessible", + "eks_cluster_not_publicly_accessible", "vpc_endpoint_for_ec2_enabled", "s3_account_level_public_access_blocks", "awslambda_function_not_publicly_accessible", @@ -506,7 +506,7 @@ "Checks": [ "ec2_ebs_public_snapshot", "rds_instance_no_public_access", - "eks_endpoints_not_publicly_accessible", + "eks_cluster_not_publicly_accessible", "vpc_endpoint_for_ec2_enabled", "s3_account_level_public_access_blocks", "awslambda_function_not_publicly_accessible", @@ -742,7 +742,7 @@ "elbv2_logging_enabled", "apigateway_restapi_logging_enabled", "cloudtrail_multi_region_enabled", - "wafv2_web_acl_logging_enabled", + "wafv2_webacl_logging_enabled", "cloudtrail_cloudwatch_logging_enabled", "s3_bucket_lifecycle_enabled" ], @@ -763,7 +763,7 @@ "elbv2_logging_enabled", "apigateway_restapi_logging_enabled", "cloudtrail_multi_region_enabled", - "wafv2_web_acl_logging_enabled", + "wafv2_webacl_logging_enabled", "cloudtrail_cloudwatch_logging_enabled" ], "Attributes": [ @@ -794,7 +794,7 @@ "Name": "Render PAN unreadable anywhere it is stored (including on portable digital media, backup media, and in logs) by using approaches like one-way hashes based on strong cryptography, truncation etc", "Description": "The following approaches should be used to render PAN unreadable anywhere it is stored: One-way hashes based on strong cryptography, (hash must be of the entire PAN), truncation (hashing cannot be used to replace the truncated segment of PAN), index tokens and pads (pads must be securely stored) and strong cryptography with associated key-management processes and procedures. Note: It is a relatively trivial effort for a malicious individual to reconstruct original PAN data if they have access to both the truncated and hashed version of a PAN. Where hashed and truncated versions of the same PAN are present in an entity's environment, additional controls must be in place to ensure that the hashed and truncated versions cannot be correlated to reconstruct the original PAN. PANs stored in primary storage (databases, or flat files such as text files spreadsheets) as well as non-primary storage (backup, audit logs, exception or troubleshooting logs) must all be protected. One-way hash functions based on strong cryptography can be used to render cardholder data unreadable. Hash functions are appropriate when there is no need to retrieve the original number (one-way hashes are irreversible). It is recommended, but not currently a requirement, that an additional, random input value be added to the cardholder data prior to hashing to reduce the feasibility of an attacker comparing the data against (and deriving the PAN from) tables of pre- computed hash values. The intent of truncation is to permanently remove a segment of PAN data so that only a portion (generally not to exceed the first six and last four digits) of the PAN is stored. An index token is a cryptographic token that replaces the PAN based on a given index for an unpredictable value. A one-time pad is a system in which a randomly generated private key is used only once to encrypt a message that is then decrypted using a matching one-time pad and key. The intent of strong cryptography (as defined in the PCI DSS and PA-DSS Glossary of Terms, Abbreviations, and Acronyms) is that the encryption be based on an industry-tested and accepted algorithm (not a proprietary or `home- grown` algorithm) with strong cryptographic keys. By correlating hashed and truncated versions of a given PAN, a malicious individual may easily derive the original PAN value. Controls that prevent the correlation of this data will help ensure that the original PAN remains unreadable.", "Checks": [ - "dynamodb_table_encryption_enabled", + "dynamodb_tables_kms_cmk_encryption_enabled", "cloudtrail_kms_encryption_enabled", "sagemaker_notebook_instance_encryption_enabled", "cloudwatch_log_group_kms_encryption_enabled", @@ -807,7 +807,6 @@ "opensearch_service_domains_encryption_at_rest_enabled", "sns_topics_kms_encryption_at_rest_enabled", "dynamodb_accelerator_cluster_encryption_enabled", - "dynamodb_tables_kms_cmk_encryption_enabled", "ec2_ebs_volume_encryption", "rds_instance_storage_encrypted", "redshift_cluster_audit_logging", @@ -816,7 +815,7 @@ "elbv2_logging_enabled", "apigateway_restapi_logging_enabled", "cloudtrail_multi_region_enabled", - "wafv2_web_acl_logging_enabled", + "wafv2_webacl_logging_enabled", "cloudtrail_cloudwatch_logging_enabled" ], "Attributes": [ @@ -832,7 +831,7 @@ "Name": "If disk encryption is used (rather than file- or column-level database encryption), logical access must be managed separately and independently of native operating system authentication and access control mechanisms (for example, by not using local user account databases or general network login credentials)", "Description": "Decryption keys must not be associated with user accounts. Note: This requirement applies in addition to all other PCI DSS encryption and key- management requirements. PANs stored in primary storage (databases, or flat files such as text files spreadsheets) as well as non-primary storage (backup, audit logs, exception or troubleshooting logs) must all be protected. One-way hash functions based on strong cryptography can be used to render cardholder data unreadable. Hash functions are appropriate when there is no need to retrieve the original number (one-way hashes are irreversible). It is recommended, but not currently a requirement, that an additional, random input value be added to the cardholder data prior to hashing to reduce the feasibility of an attacker comparing the data against (and deriving the PAN from) tables of pre- computed hash values. The intent of truncation is to permanently remove a segment of PAN data so that only a portion (generally not to exceed the first six and last four digits) of the PAN is stored. An index token is a cryptographic token that replaces the PAN based on a given index for an unpredictable value. A one-time pad is a system in which a randomly generated private key is used only once to encrypt a message that is then decrypted using a matching one-time pad and key. The intent of strong cryptography (as defined in the PCI DSS and PA-DSS Glossary of Terms, Abbreviations, and Acronyms) is that the encryption be based on an industry-tested and accepted algorithm (not a proprietary or `home-grown` algorithm) with strong cryptographic keys. By correlating hashed and truncated versions of a given PAN, a malicious individual may easily derive the original PAN value. Controls that prevent the correlation of this data will help ensure that the original PAN remains unreadable.", "Checks": [ - "dynamodb_table_encryption_enabled", + "dynamodb_tables_kms_cmk_encryption_enabled", "cloudtrail_kms_encryption_enabled", "sagemaker_notebook_instance_encryption_enabled", "cloudwatch_log_group_kms_encryption_enabled", @@ -845,7 +844,6 @@ "opensearch_service_domains_encryption_at_rest_enabled", "sns_topics_kms_encryption_at_rest_enabled", "dynamodb_accelerator_cluster_encryption_enabled", - "dynamodb_tables_kms_cmk_encryption_enabled", "ec2_ebs_volume_encryption", "rds_instance_storage_encrypted", "redshift_cluster_audit_logging" @@ -863,7 +861,7 @@ "Name": "If disk encryption is used, inspect the configuration and observe the authentication process to verify that logical access to encrypted file systems is implemented via a mechanism that is separate from the native operating system's authentication mechanism (for example, not using local user account databases or general network login credentials)", "Description": "The intent of this requirement is to address the acceptability of disk-level encryption for rendering cardholder data unreadable. Disk-level encryption encrypts the entire disk/partition on a computer and automatically decrypts the information when an authorized user requests it. Many disk- encryption solutions intercept operating system read/write operations and carry out the appropriate cryptographic transformations without any special action by the user other than supplying a password or pass phrase upon system startup or at the beginning of a session. Based on these characteristics of disk-level encryption, to be compliant with this requirement, the method cannot: 1) Use the same user account authenticator as the operating system, or 2) Use a decryption key that is associated with or derived from the system's local user account database or general network login credentials. Full disk encryption helps to protect data in the event of physical loss of a disk and therefore may be appropriate for portable devices that store cardholder data.", "Checks": [ - "dynamodb_table_encryption_enabled", + "dynamodb_tables_kms_cmk_encryption_enabled", "cloudtrail_kms_encryption_enabled", "sagemaker_notebook_instance_encryption_enabled", "cloudwatch_log_group_kms_encryption_enabled", @@ -876,7 +874,6 @@ "opensearch_service_domains_encryption_at_rest_enabled", "sns_topics_kms_encryption_at_rest_enabled", "dynamodb_accelerator_cluster_encryption_enabled", - "dynamodb_tables_kms_cmk_encryption_enabled", "ec2_ebs_volume_encryption", "rds_instance_storage_encrypted", "redshift_cluster_audit_logging" @@ -894,7 +891,7 @@ "Name": "Examine the configurations and observe the processes to verify that cardholder data on removable media is encrypted wherever stored", "Description": "Note: If disk encryption is not used to encrypt removable media, the data stored on this media will need to be rendered unreadable through some other method. The intent of this requirement is to address the acceptability of disk-level encryption for rendering cardholder data unreadable. Disk-level encryption encrypts the entire disk/partition on a computer and automatically decrypts the information when an authorized user requests it. Many disk- encryption solutions intercept operating system read/write operations and carry out the appropriate cryptographic transformations without any special action by the user other than supplying a password or pass phrase upon system startup or at the beginning of a session. Based on these characteristics of disk-level encryption, to be compliant with this requirement, the method cannot: 1) Use the same user account authenticator as the operating system, or 2) Use a decryption key that is associated with or derived from the system's local user account database or general network login credentials. Full disk encryption helps to protect data in the event of physical loss of a disk and therefore may be appropriate for portable devices that store cardholder data.", "Checks": [ - "dynamodb_table_encryption_enabled", + "dynamodb_tables_kms_cmk_encryption_enabled", "cloudtrail_kms_encryption_enabled", "sagemaker_notebook_instance_encryption_enabled", "cloudwatch_log_group_kms_encryption_enabled", @@ -907,7 +904,6 @@ "opensearch_service_domains_encryption_at_rest_enabled", "sns_topics_kms_encryption_at_rest_enabled", "dynamodb_accelerator_cluster_encryption_enabled", - "dynamodb_tables_kms_cmk_encryption_enabled", "ec2_ebs_volume_encryption", "rds_instance_storage_encrypted", "redshift_cluster_audit_logging" @@ -925,7 +921,7 @@ "Name": "Examine documentation about the system used to protect the PAN, including the vendor, type of system/process, and the encryption algorithms (if applicable) to verify that the PAN is rendered unreadable using methods like truncation,one-way hashes based on strong cryptography etc", "Description": "Verify documentation about the system used to protect the PAN, including the vendor, type of system/process, and the encryption algorithms (if applicable) to verify that the PAN is rendered unreadable using any of the following methods: One-way hashes based on strong cryptography, truncation, index tokens and pads with the pads being securely stored, strong cryptography, with associated key-management processes and procedures. PANs stored in primary storage (databases, or flat files such as text files spreadsheets) as well as non-primary storage (backup, audit logs, exception or troubleshooting logs) must all be protected. One-way hash functions based on strong cryptography can be used to render cardholder data unreadable. Hash functions are appropriate when there is no need to retrieve the original number (one-way hashes are irreversible). It is recommended, but not currently a requirement, that an additional, random input value be added to the cardholder data prior to hashing to reduce the feasibility of an attacker comparing the data against (and deriving the PAN from) tables of pre- computed hash values. The intent of truncation is to permanently remove a segment of PAN data so that only a portion (generally not to exceed the first six and last four digits) of the PAN is stored. An index token is a cryptographic token that replaces the PAN based on a given index for an unpredictable value. A one-time pad is a system in which a randomly generated private key is used only once to encrypt a message that is then decrypted using a matching one-time pad and key. The intent of strong cryptography (as defined in the PCI DSS and PA-DSS Glossary of Terms, Abbreviations, and Acronyms) is that the encryption be based on an industry-tested and accepted algorithm (not a proprietary or `home-grown` algorithm) with strong cryptographic keys. By correlating hashed and truncated versions of a given PAN, a malicious individual may easily derive the original PAN value. Controls that prevent the correlation of this data will help ensure that the original PAN remains unreadable.", "Checks": [ - "dynamodb_table_encryption_enabled", + "dynamodb_tables_kms_cmk_encryption_enabled", "cloudtrail_kms_encryption_enabled", "sagemaker_notebook_instance_encryption_enabled", "cloudwatch_log_group_kms_encryption_enabled", @@ -933,7 +929,6 @@ "opensearch_service_domains_audit_logging_enabled", "eks_cluster_kms_cmk_encryption_in_secrets_enabled", "rds_snapshots_encrypted", - "dynamodb_tables_kms_cmk_encryption_enabled", "s3_bucket_default_encryption", "efs_encryption_at_rest_enabled", "ec2_ebs_default_encryption", @@ -957,7 +952,7 @@ "Name": "Examine several tables or files from a sample of data repositories to verify the PAN is rendered unreadable (that is, not stored in plain-text)", "Description": "PANs stored in primary storage (databases, or flat files such as text files spreadsheets) as well as non-primary storage (backup, audit logs, exception or troubleshooting logs) must all be protected. One-way hash functions based on strong cryptography can be used to render cardholder data unreadable. Hash functions are appropriate when there is no need to retrieve the original number (one-way hashes are irreversible). It is recommended, but not currently a requirement, that an additional, random input value be added to the cardholder data prior to hashing to reduce the feasibility of an attacker comparing the data against (and deriving the PAN from) tables of pre- computed hash values. The intent of truncation is to permanently remove a segment of PAN data so that only a portion (generally not to exceed the first six and last four digits) of the PAN is stored. An index token is a cryptographic token that replaces the PAN based on a given index for an unpredictable value. A one-time pad is a system in which a randomly generated private key is used only once to encrypt a message that is then decrypted using a matching one-time pad and key. The intent of strong cryptography (as defined in the PCI DSS and PA-DSS Glossary of Terms, Abbreviations, and Acronyms) is that the encryption be based on an industry-tested and accepted algorithm (not a proprietary or `home- grown` algorithm) with strong cryptographic keys. By correlating hashed and truncated versions of a given PAN, a malicious individual may easily derive the original PAN value. Controls that prevent the correlation of this data will help ensure that the original PAN remains unreadable.", "Checks": [ - "dynamodb_table_encryption_enabled", + "dynamodb_tables_kms_cmk_encryption_enabled", "cloudtrail_kms_encryption_enabled", "sagemaker_notebook_instance_encryption_enabled", "cloudwatch_log_group_kms_encryption_enabled", @@ -965,7 +960,6 @@ "opensearch_service_domains_audit_logging_enabled", "eks_cluster_kms_cmk_encryption_in_secrets_enabled", "rds_snapshots_encrypted", - "dynamodb_tables_kms_cmk_encryption_enabled", "s3_bucket_default_encryption", "efs_encryption_at_rest_enabled", "ec2_ebs_default_encryption", @@ -997,7 +991,7 @@ "apigateway_restapi_logging_enabled", "s3_bucket_default_encryption", "cloudtrail_multi_region_enabled", - "wafv2_web_acl_logging_enabled", + "wafv2_webacl_logging_enabled", "cloudtrail_cloudwatch_logging_enabled" ], "Attributes": [ @@ -1084,7 +1078,7 @@ "Description": "Following should be used to safeguard sensitive cardholder data during transmission over open, public networks: only trusted keys and certificates are accepted, the protocol in use only supports secure versions or configurations and the encryption strength is appropriate for the encryption methodology in use. Examples of open, public networks include but are not limited to the Internet, wireless technologies, including 802.11 and Bluetooth, cellular technologies, for example, Global System for Mobile communications (GSM), Code division multiple access (CDMA), general Packet Radio Service (GPRS) and satellite communications. Sensitive information must be encrypted during transmission over public networks, because it is easy and common for a malicious individual to intercept and/or divert data while in transit. Secure transmission of cardholder data requires using trusted keys/certificates, a secure protocol for transport, and proper encryption strength to encrypt cardholder data. Connection requests from systems that do not support the required encryption strength, and that would result in an insecure connection, should not be accepted. Note that some protocol implementations (such as SSL, SSH v1.0, and early TLS) have known vulnerabilities that an attacker can use to gain control of the affected system. Whichever security protocol is used, ensure it is configured to use only secure versions and configurations to prevent use of an insecure connection—for example, by using only trusted certificates and supporting only strong encryption (not supporting weaker, insecure protocols or methods). Verifying that certificates are trusted (for example, have not expired and are issued from a trusted source) helps ensure the integrity of the secure connection. Generally, the web page URL should begin with `HTTPS` and/or the web browser display a padlock icon somewhere in the window of the browser. Many TLS certificate vendors also provide a highly visible verification seal— sometimes referred to as a “security seal,” `secure site seal,` or “secure trust seal”)—which may provide the ability to click on the seal to reveal information about the website. Refer to industry standards and best practices for information on strong cryptography and secure protocols (e.g., NIST SP 800-52 and SP 800-57, OWASP, etc.) Note: SSL/early TLS is not considered strong cryptography and may not be used as a security control, except by POS POI terminals that are verified as not being susceptible to known exploits and the termination points to which they connect as defined in Appendix A2.", "Checks": [ "acm_certificates_expiration_check", - "s3_bucket_enforces_ssl", + "s3_bucket_secure_transport_policy", "elbv2_ssl_listeners", "opensearch_service_domains_node_to_node_encryption_enabled", "elb_ssl_listeners", @@ -1110,7 +1104,7 @@ "cloudfront_distributions_using_deprecated_ssl_protocols", "acm_certificates_expiration_check", "cloudfront_distributions_origin_traffic_encrypted", - "s3_bucket_enforces_ssl", + "s3_bucket_secure_transport_policy", "elbv2_ssl_listeners", "opensearch_service_domains_node_to_node_encryption_enabled", "elb_ssl_listeners" @@ -1178,7 +1172,7 @@ "cloudfront_distributions_using_deprecated_ssl_protocols", "acm_certificates_expiration_check", "cloudfront_distributions_origin_traffic_encrypted", - "s3_bucket_enforces_ssl", + "s3_bucket_secure_transport_policy", "elbv2_ssl_listeners", "opensearch_service_domains_node_to_node_encryption_enabled", "elb_ssl_listeners" @@ -1497,7 +1491,7 @@ "Checks": [ "ec2_ebs_public_snapshot", "rds_instance_no_public_access", - "eks_endpoints_not_publicly_accessible", + "eks_cluster_not_publicly_accessible", "s3_account_level_public_access_blocks", "awslambda_function_not_publicly_accessible", "emr_cluster_master_nodes_no_public_ip", @@ -1528,7 +1522,7 @@ "Checks": [ "ec2_ebs_public_snapshot", "rds_instance_no_public_access", - "eks_endpoints_not_publicly_accessible", + "eks_cluster_not_publicly_accessible", "s3_account_level_public_access_blocks", "awslambda_function_not_publicly_accessible", "emr_cluster_master_nodes_no_public_ip", @@ -1637,7 +1631,7 @@ "iam_password_policy_reuse_24", "codebuild_project_no_secrets_in_variables", "codebuild_project_source_repo_url_no_sensitive_credentials", - "s3_bucket_enforces_ssl", + "s3_bucket_secure_transport_policy", "elbv2_ssl_listeners", "opensearch_service_domains_node_to_node_encryption_enabled", "elb_ssl_listeners", @@ -1652,11 +1646,10 @@ "eks_cluster_kms_cmk_encryption_in_secrets_enabled", "rds_snapshots_encrypted", "dynamodb_accelerator_cluster_encryption_enabled", - "dynamodb_table_encryption_enabled", + "dynamodb_tables_kms_cmk_encryption_enabled", "cloudtrail_kms_encryption_enabled", "cloudwatch_log_group_kms_encryption_enabled", "sns_topics_kms_encryption_at_rest_enabled", - "dynamodb_tables_kms_cmk_encryption_enabled", "redshift_cluster_audit_logging" ], "Attributes": [ @@ -1674,7 +1667,7 @@ "Checks": [ "codebuild_project_no_secrets_in_variables", "codebuild_project_source_repo_url_no_sensitive_credentials", - "s3_bucket_enforces_ssl", + "s3_bucket_secure_transport_policy", "elbv2_ssl_listeners", "opensearch_service_domains_node_to_node_encryption_enabled", "elb_ssl_listeners", @@ -1689,11 +1682,10 @@ "eks_cluster_kms_cmk_encryption_in_secrets_enabled", "rds_snapshots_encrypted", "dynamodb_accelerator_cluster_encryption_enabled", - "dynamodb_table_encryption_enabled", + "dynamodb_tables_kms_cmk_encryption_enabled", "cloudtrail_kms_encryption_enabled", "cloudwatch_log_group_kms_encryption_enabled", "sns_topics_kms_encryption_at_rest_enabled", - "dynamodb_tables_kms_cmk_encryption_enabled", "redshift_cluster_audit_logging" ], "Attributes": [ @@ -1723,12 +1715,11 @@ "eks_cluster_kms_cmk_encryption_in_secrets_enabled", "rds_snapshots_encrypted", "dynamodb_accelerator_cluster_encryption_enabled", - "dynamodb_table_encryption_enabled", + "dynamodb_tables_kms_cmk_encryption_enabled", "cloudtrail_kms_encryption_enabled", "cloudwatch_log_group_kms_encryption_enabled", - "s3_bucket_enforces_ssl", + "s3_bucket_secure_transport_policy", "sns_topics_kms_encryption_at_rest_enabled", - "dynamodb_tables_kms_cmk_encryption_enabled", "redshift_cluster_audit_logging" ], "Attributes": [ @@ -2118,7 +2109,7 @@ "cloudwatch_log_group_retention_policy_specific_days_enabled", "apigateway_restapi_logging_enabled", "cloudtrail_multi_region_enabled", - "wafv2_web_acl_logging_enabled", + "wafv2_webacl_logging_enabled", "cloudtrail_cloudwatch_logging_enabled", "vpc_flow_logs_enabled", "redshift_cluster_audit_logging" diff --git a/prowler/compliance/aws/rbi_cyber_security_framework_aws.json b/prowler/compliance/aws/rbi_cyber_security_framework_aws.json index 554b40cdad..b44511a206 100644 --- a/prowler/compliance/aws/rbi_cyber_security_framework_aws.json +++ b/prowler/compliance/aws/rbi_cyber_security_framework_aws.json @@ -109,9 +109,7 @@ "guardduty_no_high_severity_findings", "rds_instance_minor_version_upgrade_enabled", "redshift_cluster_automatic_upgrades", - "ssm_managed_compliant_patching", - "ssm_managed_compliant_patching", - "rds_instance_minor_version_upgrade_enabled" + "ssm_managed_compliant_patching" ] }, { diff --git a/prowler/compliance/aws/soc2_aws.json b/prowler/compliance/aws/soc2_aws.json index 0e8c9f70ae..6cd6d31e08 100644 --- a/prowler/compliance/aws/soc2_aws.json +++ b/prowler/compliance/aws/soc2_aws.json @@ -643,8 +643,6 @@ "ec2_client_vpn_endpoint_connection_logging_enabled", "ecs_task_definitions_logging_enabled", "elasticbeanstalk_environment_cloudwatch_logging_enabled", - "elb_logging_enabled", - "elbv2_logging_enabled", "glue_etl_jobs_logging_enabled", "mq_broker_logging_enabled", "networkfirewall_logging_enabled", diff --git a/prowler/compliance/azure/c5_azure.json b/prowler/compliance/azure/c5_azure.json index 6fff7a3691..37963e9f99 100644 --- a/prowler/compliance/azure/c5_azure.json +++ b/prowler/compliance/azure/c5_azure.json @@ -3157,8 +3157,6 @@ "app_http_logs_enabled", "defender_auto_provisioning_log_analytics_agent_vms_on", "keyvault_logging_enabled", - "monitor_storage_account_with_activity_logs_cmk_encrypted", - "monitor_storage_account_with_activity_logs_is_private", "mysql_flexible_server_audit_log_connection_activated", "mysql_flexible_server_audit_log_enabled", "network_flow_log_captured_sent", @@ -5780,13 +5778,11 @@ "sqlserver_tde_encrypted_with_cmk", "sqlserver_tde_encryption_enabled", "vm_ensure_unattached_disks_encrypted_with_cmk", - "entra_conditional_access_policy_require_mfa_for_management_app", + "entra_conditional_access_policy_require_mfa_for_management_api", "app_minimum_tls_version_12", "mysql_flexible_server_minimum_tls_version_12", "sqlserver_recommended_minimal_tls_version", - "storage_ensure_minimum_tls_version_12", - "sqlserver_tde_encrypted_with_cmk", - "sqlserver_tde_encryption_enabled" + "storage_ensure_minimum_tls_version_12" ], "ConfigRequirements": [ { @@ -5818,13 +5814,11 @@ "sqlserver_tde_encrypted_with_cmk", "sqlserver_tde_encryption_enabled", "vm_ensure_unattached_disks_encrypted_with_cmk", - "entra_conditional_access_policy_require_mfa_for_management_app", + "entra_conditional_access_policy_require_mfa_for_management_api", "app_minimum_tls_version_12", "mysql_flexible_server_minimum_tls_version_12", "sqlserver_recommended_minimal_tls_version", - "storage_ensure_minimum_tls_version_12", - "sqlserver_tde_encrypted_with_cmk", - "sqlserver_tde_encryption_enabled" + "storage_ensure_minimum_tls_version_12" ], "ConfigRequirements": [ { @@ -6577,7 +6571,6 @@ "sqlserver_tde_encryption_enabled", "app_minimum_tls_version_12", "mysql_flexible_server_minimum_tls_version_12", - "sqlserver_recommended_minimal_tls_version", "storage_ensure_minimum_tls_version_12" ], "ConfigRequirements": [ @@ -7077,7 +7070,6 @@ "app_http_logs_enabled", "defender_auto_provisioning_log_analytics_agent_vms_on", "keyvault_logging_enabled", - "monitor_storage_account_with_activity_logs_cmk_encrypted", "monitor_storage_account_with_activity_logs_is_private", "mysql_flexible_server_audit_log_connection_activated", "mysql_flexible_server_audit_log_enabled", @@ -7920,8 +7912,6 @@ "app_http_logs_enabled", "defender_auto_provisioning_log_analytics_agent_vms_on", "keyvault_logging_enabled", - "monitor_storage_account_with_activity_logs_cmk_encrypted", - "monitor_storage_account_with_activity_logs_is_private", "mysql_flexible_server_audit_log_connection_activated", "mysql_flexible_server_audit_log_enabled", "network_flow_log_captured_sent", @@ -7953,8 +7943,6 @@ "app_http_logs_enabled", "defender_auto_provisioning_log_analytics_agent_vms_on", "keyvault_logging_enabled", - "monitor_storage_account_with_activity_logs_cmk_encrypted", - "monitor_storage_account_with_activity_logs_is_private", "mysql_flexible_server_audit_log_connection_activated", "mysql_flexible_server_audit_log_enabled", "network_flow_log_captured_sent", @@ -8991,7 +8979,6 @@ "app_http_logs_enabled", "defender_auto_provisioning_log_analytics_agent_vms_on", "keyvault_logging_enabled", - "monitor_storage_account_with_activity_logs_cmk_encrypted", "mysql_flexible_server_audit_log_connection_activated", "mysql_flexible_server_audit_log_enabled", "network_flow_log_captured_sent", diff --git a/prowler/compliance/azure/ens_rd2022_azure.json b/prowler/compliance/azure/ens_rd2022_azure.json index 5078c6567e..aafabfa75a 100644 --- a/prowler/compliance/azure/ens_rd2022_azure.json +++ b/prowler/compliance/azure/ens_rd2022_azure.json @@ -642,7 +642,7 @@ } ], "Checks": [ - " app_http_logs_enabled" + "app_http_logs_enabled" ] }, { @@ -1185,7 +1185,7 @@ ] }, { - "Id": "op.mon.3.az.nw.1", + "Id": "op.mon.3.az.nw.2", "Description": "Vigilancia", "Attributes": [ { diff --git a/prowler/compliance/azure/fedramp_20x_ksi_low_azure.json b/prowler/compliance/azure/fedramp_20x_ksi_low_azure.json deleted file mode 100644 index 655f649abe..0000000000 --- a/prowler/compliance/azure/fedramp_20x_ksi_low_azure.json +++ /dev/null @@ -1,359 +0,0 @@ -{ - "Framework": "FedRAMP-20x-KSI-Low", - "Name": "FedRAMP 20x Key Security Indicators (KSIs) - Low Impact Level v25.05C", - "Version": "25.05C", - "Provider": "Azure", - "Description": "FedRAMP 20x Key Security Indicators (KSIs) Low Impact Level represent core security indicators for cloud service providers, focusing on automation, continuous monitoring, and cloud-native security principles per FedRAMP 20x Phase One pilot requirements for Low impact systems.", - "Requirements": [ - { - "Id": "ksi-cmt", - "Name": "KSI-CMT: Change Management", - "Description": "A secure cloud service provider will ensure that all system changes are properly documented and configuration baselines are updated accordingly", - "Attributes": [ - { - "ItemId": "ksi-cmt", - "Section": "Change Management", - "Service": "azure" - } - ], - "Checks": [ - "monitor_activity_log_alert_cmk_delete", - "monitor_activity_log_alert_create_policy_assignment", - "monitor_activity_log_alert_create_update_delete_network_sg", - "monitor_activity_log_alert_create_update_delete_network_sg_rule", - "monitor_activity_log_alert_create_update_delete_sql_server_fw_rule", - "monitor_activity_log_alert_create_update_nsg", - "monitor_activity_log_alert_create_update_public_ip_address", - "monitor_activity_log_alert_create_update_security_solution", - "monitor_activity_log_alert_delete_nsg", - "monitor_activity_log_alert_delete_policy_assignment", - "monitor_activity_log_alert_delete_public_ip_address", - "monitor_activity_log_alert_delete_security_solution", - "monitor_log_profile_all_categories", - "monitor_log_profile_all_regions", - "vm_agent_installed", - "vm_antimalware_solution_installed", - "vm_endpoint_protection_installed", - "vm_guest_configuration_installed", - "vm_guest_configuration_with_no_managed_identity", - "vm_guest_configuration_with_user_identity" - ] - }, - { - "Id": "ksi-cna", - "Name": "KSI-CNA: Cloud Native Architecture", - "Description": "A secure cloud service offering will use cloud native architecture and design principles to enforce and enhance the Confidentiality, Integrity and Availability of the system", - "Attributes": [ - { - "ItemId": "ksi-cna", - "Section": "Cloud Native Architecture", - "Service": "azure" - } - ], - "Checks": [ - "aks_clusters_created_with_private_nodes", - "aks_clusters_public_access_disabled", - "aks_network_policy_enabled", - "app_function_vnet_integration_enabled", - "app_function_not_publicly_accessible", - "containerregistry_not_publicly_accessible", - "containerregistry_uses_private_link", - "cosmosdb_account_use_private_endpoints", - "cosmosdb_account_firewall_use_selected_networks", - "databricks_workspace_vnet_injection_enabled", - "keyvault_access_only_through_private_endpoints", - "keyvault_private_endpoints", - "network_bastion_host_exists", - "network_flow_logs_enabled", - "network_security_group_not_empty", - "network_sg_ssh_access_restricted", - "network_sg_rdp_access_restricted", - "network_sg_open_all_ports_to_any_source", - "network_watcher_enabled", - "postgresql_flexible_server_public_network_access_disabled", - "sqlserver_public_network_access_disabled", - "storage_default_network_access_rule_set_to_deny", - "vm_availability_zones_enabled", - "vm_availability_set_deployed" - ] - }, - { - "Id": "ksi-iam", - "Name": "KSI-IAM: Identity and Access Management", - "Description": "A secure cloud service offering will protect user data, control access, and apply zero trust principles", - "Attributes": [ - { - "ItemId": "ksi-iam", - "Section": "Identity and Access Management", - "Service": "azure" - } - ], - "Checks": [ - "entra_conditional_access_policy_require_mfa_for_management_api", - "entra_global_admin_in_less_than_five_users", - "entra_non_privileged_user_has_mfa", - "entra_policy_default_users_cannot_create_security_groups", - "entra_policy_ensure_default_user_cannot_create_apps", - "entra_policy_ensure_default_user_cannot_create_tenants", - "entra_policy_guest_invite_only_for_admin_roles", - "entra_policy_guest_users_access_restrictions", - "entra_policy_restricts_user_consent_for_apps", - "entra_policy_user_consent_for_verified_apps", - "entra_privileged_user_has_mfa", - "entra_security_defaults_enabled", - "entra_trusted_named_locations_exists", - "entra_user_with_vm_access_has_mfa", - "entra_users_cannot_create_microsoft_365_groups", - "iam_custom_role_has_permissions_to_administer_resource_locks", - "iam_role_user_access_admin_restricted", - "iam_subscription_roles_owner_custom_not_created", - "keyvault_rbac_enabled", - "app_function_identity_is_configured", - "app_function_identity_without_admin_privileges", - "app_ensure_auth_is_set_up", - "app_register_with_identity", - "vm_managed_identity_enabled" - ] - }, - { - "Id": "ksi-inr", - "Name": "KSI-INR: Incident Response", - "Description": "A secure cloud service offering will respond to incidents according to FedRAMP requirements and cloud service provider policies", - "Attributes": [ - { - "ItemId": "ksi-inr", - "Section": "Incident Response", - "Service": "azure" - } - ], - "Checks": [ - "defender_attack_path_notifications_properly_configured", - "defender_ensure_notify_alerts_severity_is_high", - "defender_ensure_notify_emails_to_owners", - "defender_additional_email_configured_with_a_security_contact", - "defender_container_images_resolved_vulnerabilities", - "defender_container_images_scan_enabled", - "defender_ensure_defender_for_app_services_is_on", - "defender_ensure_defender_for_arm_is_on", - "defender_ensure_defender_for_azure_sql_databases_is_on", - "defender_ensure_defender_for_containers_is_on", - "defender_ensure_defender_for_cosmosdb_is_on", - "defender_ensure_defender_for_databases_is_on", - "defender_ensure_defender_for_dns_is_on", - "defender_ensure_defender_for_keyvault_is_on", - "defender_ensure_defender_for_os_relational_databases_is_on", - "defender_ensure_defender_for_server_is_on", - "defender_ensure_defender_for_sql_servers_is_on", - "defender_ensure_defender_for_storage_is_on", - "defender_ensure_iot_hub_defender_is_on", - "defender_ensure_wdatp_is_enabled" - ] - }, - { - "Id": "ksi-mla", - "Name": "KSI-MLA: Monitoring, Logging, and Auditing", - "Description": "A secure cloud service offering will monitor, log, and audit all important events, activity, and changes", - "Attributes": [ - { - "ItemId": "ksi-mla", - "Section": "Monitoring, Logging, and Auditing", - "Service": "azure" - } - ], - "Checks": [ - "app_function_application_insights_enabled", - "app_http_logs_enabled", - "appinsights_ensure_is_configured", - "defender_auto_provisioning_log_analytics_agent_vms_on", - "defender_auto_provisioning_vulnerabilty_assessments_machines_on", - "keyvault_logging_enabled", - "monitor_activity_log_retention_policy_set", - "monitor_diagnostic_logs_categories", - "monitor_diagnostic_setting_deployed_for_all_resources", - "monitor_diagnostic_settings_captures_proper_categories", - "monitor_log_profile_all_categories", - "monitor_log_profile_all_regions", - "monitor_log_profile_captures_all_activities", - "monitor_log_profile_retention_policy_at_least_365", - "network_flow_logs_enabled", - "network_flow_log_retention_policy_at_least_90", - "network_watcher_enabled", - "postgresql_flexible_server_audit_logs_enabled", - "postgresql_flexible_server_log_checkpoints_enabled", - "postgresql_flexible_server_log_connections_enabled", - "postgresql_flexible_server_log_disconnections_enabled", - "sqlserver_auditing_on", - "sqlserver_auditing_retention_90_days", - "storage_storage_account_logging_queue_read_write_delete_enabled" - ] - }, - { - "Id": "ksi-piy", - "Name": "KSI-PIY: Policy and Inventory", - "Description": "A secure cloud service offering will have intentional, organized, universal guidance for how every information resource, including personnel, is secured", - "Attributes": [ - { - "ItemId": "ksi-piy", - "Section": "Policy and Inventory", - "Service": "azure" - } - ], - "Checks": [ - "policy_ensure_asc_for_aks_is_enabled", - "policy_ensure_asc_for_app_services_is_enabled", - "policy_ensure_asc_for_azure_sql_is_enabled", - "policy_ensure_asc_for_key_vault_is_enabled", - "policy_ensure_asc_for_servers_is_enabled", - "policy_ensure_asc_for_sql_servers_is_enabled", - "policy_ensure_asc_for_storage_is_enabled", - "policy_ensure_allowed_extensions_are_installed", - "policy_ensure_allowed_locations_is_enabled", - "policy_ensure_allowed_resource_types_is_enabled", - "policy_ensure_audit_diagnostic_log_enabled_for_all_services", - "policy_ensure_not_allowed_resource_types_is_enabled", - "vm_guest_configuration_installed", - "vm_guest_configuration_with_no_managed_identity", - "vm_guest_configuration_with_user_identity" - ] - }, - { - "Id": "ksi-rpl", - "Name": "KSI-RPL: Recovery Planning", - "Description": "A secure cloud service offering will define, maintain, and test incident response plan(s) and recovery capabilities to ensure minimal service disruption and data loss", - "Attributes": [ - { - "ItemId": "ksi-rpl", - "Section": "Recovery Planning", - "Service": "azure" - } - ], - "Checks": [ - "mysql_flexible_server_geo_redundant_backup_enabled", - "mysql_flexible_server_retain_backup_35_days", - "postgresql_flexible_server_geo_redundant_backup_enabled", - "postgresql_flexible_server_backup_retention_period_35_days", - "recovery_services_vault_uses_private_link", - "recovery_services_vault_uses_private_link_for_backup", - "sqlserver_database_long_term_geo_redundant_backup", - "sqlserver_database_retention_policy_exceeds_90_days", - "storage_default_storage_account_encrypted_with_cmk_not_stored_in_storage_account", - "storage_geo_redundant_enabled", - "storage_infrastructure_encryption_is_enabled", - "storage_soft_delete_containers_enabled", - "storage_soft_delete_enabled", - "vm_backup_enabled", - "vm_sufficient_daily_backup_retention_period" - ] - }, - { - "Id": "ksi-svc", - "Name": "KSI-SVC: Service Configuration", - "Description": "A secure cloud service offering will follow FedRAMP encryption policies, continuously verify information resource integrity, and restrict access to third-party information resources", - "Attributes": [ - { - "ItemId": "ksi-svc", - "Section": "Service Configuration", - "Service": "azure" - } - ], - "Checks": [ - "app_client_certificates_on", - "app_ensure_http_is_redirected_to_https", - "app_minimum_tls_version_12", - "containerregistry_admin_user_disabled", - "cosmosdb_account_use_aad_and_rbac", - "databricks_workspace_cmk_encryption_enabled", - "keyvault_key_expiration_set_in_non_rbac", - "keyvault_key_rotation_enabled", - "keyvault_non_rbac_secret_expiration_set", - "mysql_flexible_server_encrypted_at_rest_using_cmk", - "mysql_flexible_server_encrypted_in_transit", - "mysql_flexible_server_minimum_tls_version_tls12", - "postgresql_flexible_server_encrypted_at_rest_using_cmk", - "postgresql_flexible_server_encrypted_in_transit", - "postgresql_flexible_server_minimum_tls_version_tls12", - "sqlserver_advanced_data_security_enabled", - "sqlserver_database_encryption_with_cmk", - "sqlserver_database_tde_encryption_enabled", - "sqlserver_minimum_tls_version_12", - "storage_secure_transfer_required_enabled", - "storage_default_storage_account_encrypted_with_cmk", - "storage_infrastructure_encryption_is_enabled", - "storage_storage_account_encrypted_with_cmk", - "storage_storage_account_minimum_tls_version_tls12", - "vm_encrypted_at_host", - "vm_data_disks_encrypted_with_cmk", - "vm_managed_disks_encrypted_with_cmk", - "vm_os_disk_are_encrypted_with_cmk", - "vm_temporary_disks_and_cache_encrypted" - ] - }, - { - "Id": "ksi-tpr", - "Name": "KSI-TPR: Third-Party Information Resources", - "Description": "A secure cloud service offering will understand, monitor, and manage supply chain risks from third-party information resources", - "Attributes": [ - { - "ItemId": "ksi-tpr", - "Section": "Third-Party Information Resources", - "Service": "azure" - } - ], - "Checks": [ - "app_ensure_java_version_is_latest", - "app_ensure_php_version_is_latest", - "app_ensure_python_version_is_latest", - "app_function_latest_runtime_version", - "defender_container_images_resolved_vulnerabilities", - "defender_container_images_scan_enabled", - "defender_ensure_system_updates_are_applied", - "vm_agent_installed", - "vm_antimalware_solution_installed", - "vm_endpoint_protection_installed", - "vm_os_update_system_updates", - "vm_security_patch_assessment" - ] - }, - { - "Id": "ksi-iam-07", - "Name": "KSI-IAM-07: Account Lifecycle Management", - "Description": "Securely manage the lifecycle and privileges of all accounts, roles, and groups", - "Attributes": [ - { - "ItemId": "ksi-iam-07", - "Section": "Identity and Access Management", - "Service": "azure" - } - ], - "Checks": [ - "entra_non_privileged_user_has_mfa", - "entra_privileged_user_has_mfa", - "entra_user_with_recent_sign_in", - "entra_user_with_vm_access_has_mfa", - "iam_custom_role_has_permissions_to_administer_resource_locks", - "iam_role_user_access_admin_restricted", - "app_function_identity_is_configured", - "vm_managed_identity_enabled" - ] - }, - { - "Id": "ksi-mla-07", - "Name": "KSI-MLA-07: Monitoring and Logging Inventory", - "Description": "Maintain a list of information resources and event types that will be monitored, logged, and audited", - "Attributes": [ - { - "ItemId": "ksi-mla-07", - "Section": "Monitoring, Logging, and Auditing", - "Service": "azure" - } - ], - "Checks": [ - "monitor_log_profile_all_categories", - "monitor_log_profile_all_regions", - "monitor_log_profile_captures_all_activities", - "monitor_diagnostic_setting_deployed_for_all_resources", - "network_watcher_enabled" - ] - } - ] -} diff --git a/prowler/compliance/azure/iso27001_2022_azure.json b/prowler/compliance/azure/iso27001_2022_azure.json index 23392533de..edd2612c4e 100644 --- a/prowler/compliance/azure/iso27001_2022_azure.json +++ b/prowler/compliance/azure/iso27001_2022_azure.json @@ -1056,7 +1056,9 @@ "entra_policy_guest_invite_only_for_admin_roles", "entra_policy_guest_users_access_restrictions", "entra_policy_restricts_user_consent_for_apps", - "entra_policy_user_consent_for_verified_apps storage_blob_public_access_level_is_disabled storage_ensure_azure_services_are_trusted_to_access_is_enabled" + "entra_policy_user_consent_for_verified_apps", + "storage_blob_public_access_level_is_disabled", + "storage_ensure_azure_services_are_trusted_to_access_is_enabled" ] }, { @@ -1106,8 +1108,9 @@ ], "Checks": [ "entra_authentication_methods_policy_strong_auth_enforced", - "entra_conditional_access_policy_require_mfa_for_management_app", - "entra_non_privileged_user_has_mfa entra_privileged_user_has_mfa", + "entra_conditional_access_policy_require_mfa_for_management_api", + "entra_non_privileged_user_has_mfa", + "entra_privileged_user_has_mfa", "entra_user_with_vm_access_has_mfa", "app_minimum_tls_version_12", "sqlserver_tde_encryption_enabled", diff --git a/prowler/compliance/azure/mitre_attack_azure.json b/prowler/compliance/azure/mitre_attack_azure.json index 8b338da534..10d24b67fd 100644 --- a/prowler/compliance/azure/mitre_attack_azure.json +++ b/prowler/compliance/azure/mitre_attack_azure.json @@ -29,7 +29,6 @@ "app_ensure_php_version_is_latest", "app_ensure_python_version_is_latest", "defender_assessments_vm_endpoint_protection_installed", - "defender_assessments_vm_endpoint_protection_installed", "defender_auto_provisioning_log_analytics_agent_vms_on", "defender_auto_provisioning_vulnerabilty_assessments_machines_on", "defender_container_images_resolved_vulnerabilities", @@ -1601,8 +1600,6 @@ "mysql_flexible_server_minimum_tls_version_12", "mysql_flexible_server_ssl_connection_enabled", "postgresql_flexible_server_enforce_ssl_enabled", - "sqlserver_tde_encrypted_with_cmk", - "sqlserver_tde_encryption_enabled", "storage_blob_public_access_level_is_disabled", "storage_ensure_azure_services_are_trusted_to_access_is_enabled", "storage_ensure_encryption_with_customer_managed_keys", diff --git a/prowler/compliance/azure/nis2_azure.json b/prowler/compliance/azure/nis2_azure.json index 0ae814fad7..229eea5093 100644 --- a/prowler/compliance/azure/nis2_azure.json +++ b/prowler/compliance/azure/nis2_azure.json @@ -1565,7 +1565,6 @@ "containerregistry_uses_private_link", "cosmosdb_account_use_private_endpoints", "keyvault_private_endpoints", - "monitor_storage_account_with_activity_logs_is_private", "storage_ensure_private_endpoints_in_storage_accounts" ], "Attributes": [ diff --git a/prowler/compliance/azure/prowler_threatscore_azure.json b/prowler/compliance/azure/prowler_threatscore_azure.json index a030bb845b..60ba4ca57f 100644 --- a/prowler/compliance/azure/prowler_threatscore_azure.json +++ b/prowler/compliance/azure/prowler_threatscore_azure.json @@ -743,24 +743,6 @@ } ] }, - { - "Id": "3.2.1", - "Description": "Ensure that 'Auditing' Retention is 'greater than 90 days'", - "Checks": [ - "sqlserver_auditing_retention_90_days" - ], - "Attributes": [ - { - "Title": "Auditing' Retention is 'greater than 90 days'", - "Section": "3. Logging and Monitoring", - "SubSection": "3.2 Retention", - "AttributeDescription": "Configure SQL Server Audit Retention to retain logs for more than 90 days to ensure long-term visibility into database activity and security events.", - "AdditionalInformation": "Maintaining audit logs for over 90 days helps detect anomalies, security breaches, and unauthorized access. Longer retention periods allow organizations to analyze historical data, support compliance requirements, and strengthen forensic investigations.", - "LevelOfRisk": 3, - "Weight": 10 - } - ] - }, { "Id": "3.2.1", "Description": "Ensure that Network Watcher flow log retention period is '0 or at least 90 days'", @@ -815,6 +797,24 @@ } ] }, + { + "Id": "3.2.4", + "Description": "Ensure that 'Auditing' Retention is 'greater than 90 days'", + "Checks": [ + "sqlserver_auditing_retention_90_days" + ], + "Attributes": [ + { + "Title": "Auditing' Retention is 'greater than 90 days'", + "Section": "3. Logging and Monitoring", + "SubSection": "3.2 Retention", + "AttributeDescription": "Configure SQL Server Audit Retention to retain logs for more than 90 days to ensure long-term visibility into database activity and security events.", + "AdditionalInformation": "Maintaining audit logs for over 90 days helps detect anomalies, security breaches, and unauthorized access. Longer retention periods allow organizations to analyze historical data, support compliance requirements, and strengthen forensic investigations.", + "LevelOfRisk": 3, + "Weight": 10 + } + ] + }, { "Id": "3.3.1", "Description": "Ensure that 'Auditing' is set to 'On' ", diff --git a/prowler/compliance/fedramp_20x_frr_class_c_2026.json b/prowler/compliance/fedramp_20x_frr_class_c_2026.json new file mode 100644 index 0000000000..f4db7f37bd --- /dev/null +++ b/prowler/compliance/fedramp_20x_frr_class_c_2026.json @@ -0,0 +1,2970 @@ +{ + "framework": "FedRAMP-20x-FRR-Class-C", + "name": "FedRAMP 20x Class C Rules (FRR) 2026", + "version": "2026.09.13.02", + "description": "FedRAMP Rules (FRR) that cloud service providers must follow for a FedRAMP 20x Class C Certification, from the FedRAMP Consolidated Rules for 2026 (release 2026.09.13.02, https://github.com/FedRAMP/rules). Covers the 158 provider rules of the 15 rulesets on the 20x Class C reference page; rules that only bind FedRAMP, agencies, assessors or advisors are excluded, and class-varying rules use their Class C statement. Most rules are program and process obligations that need manual evidence. The Key Security Indicators of the same ruleset are in the fedramp_20x_ksi_2026 framework.", + "icon": "fedramp", + "attributes_metadata": [ + { + "key": "Ruleset", + "label": "Ruleset", + "type": "str", + "required": true, + "enum": [ + "AFC: Addressing FedRAMP Communication", + "CCM: Collaborative Continuous Monitoring", + "CDS: Certification Data Sharing", + "CMU: Cryptographic Module Use", + "CPO: Certification Package Overview", + "FRC: FedRAMP Certification", + "IEC: Incident Evaluation and Communication", + "IVV: Independent Verification and Validation", + "MAS: Minimum Assessment Scope", + "MKT: Marketplace Listing", + "SCG: Secure Configuration Guide", + "SCN: Significant Change Notification", + "SDR: Security Decision Record", + "VDR: Vulnerability Detection and Response", + "VER: Vulnerability Evaluation and Reporting" + ] + }, + { + "key": "Subset", + "label": "Subset", + "type": "str", + "required": true + }, + { + "key": "Force", + "label": "Force", + "type": "str", + "required": true, + "enum": [ + "MUST", + "MUST NOT", + "SHOULD", + "SHOULD NOT", + "MAY" + ] + } + ], + "outputs": { + "table_config": { + "group_by": "Ruleset" + }, + "pdf_config": { + "language": "en", + "primary_color": "#1B3A5C", + "secondary_color": "#2E6DA4", + "bg_color": "#F0F4FA", + "group_by_field": "Ruleset", + "sections": [ + "AFC: Addressing FedRAMP Communication", + "CCM: Collaborative Continuous Monitoring", + "CDS: Certification Data Sharing", + "CMU: Cryptographic Module Use", + "CPO: Certification Package Overview", + "FRC: FedRAMP Certification", + "IEC: Incident Evaluation and Communication", + "IVV: Independent Verification and Validation", + "MAS: Minimum Assessment Scope", + "MKT: Marketplace Listing", + "SCG: Secure Configuration Guide", + "SCN: Significant Change Notification", + "SDR: Security Decision Record", + "VDR: Vulnerability Detection and Response", + "VER: Vulnerability Evaluation and Reporting" + ], + "section_short_names": { + "AFC: Addressing FedRAMP Communication": "AFC", + "CCM: Collaborative Continuous Monitoring": "CCM", + "CDS: Certification Data Sharing": "CDS", + "CMU: Cryptographic Module Use": "CMU", + "CPO: Certification Package Overview": "CPO", + "FRC: FedRAMP Certification": "FRC", + "IEC: Incident Evaluation and Communication": "IEC", + "IVV: Independent Verification and Validation": "IVV", + "MAS: Minimum Assessment Scope": "MAS", + "MKT: Marketplace Listing": "MKT", + "SCG: Secure Configuration Guide": "SCG", + "SCN: Significant Change Notification": "SCN", + "SDR: Security Decision Record": "SDR", + "VDR: Vulnerability Detection and Response": "VDR", + "VER: Vulnerability Evaluation and Reporting": "VER" + }, + "charts": [ + { + "id": "ruleset_compliance", + "type": "horizontal_bar", + "group_by": "Ruleset", + "title": "Compliance Score by FRR Ruleset", + "y_label": "Ruleset", + "x_label": "Compliance %", + "value_source": "compliance_percent", + "color_mode": "by_value" + } + ], + "filter": { + "only_failed": true, + "include_manual": false + } + } + }, + "requirements": [ + { + "id": "AFC-CSO-INB", + "name": "Maintain a FedRAMP Security Inbox", + "description": "Providers MUST establish and maintain an email address to receive messages from FedRAMP; this inbox is a FedRAMP Security Inbox (FSI).", + "attributes": { + "Ruleset": "AFC: Addressing FedRAMP Communication", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "AFC-CSO-NOC", + "name": "Notification of Changes", + "description": "Providers MUST immediately notify FedRAMP of any changes to the email address for their FedRAMP Security Inbox.", + "attributes": { + "Ruleset": "AFC: Addressing FedRAMP Communication", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "AFC-CSO-TFG", + "name": "Trust @fedramp.gov and @gsa.gov", + "description": "Providers MUST treat any email originating from an @fedramp.gov or @gsa.gov email address as if it was sent from FedRAMP by default; if such a message is confirmed to originate from someone other than FedRAMP then the FedRAMP Security Inbox rules no longer apply.", + "attributes": { + "Ruleset": "AFC: Addressing FedRAMP Communication", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "AFC-CSO-RCV", + "name": "Receive Email Without Disruption", + "description": "Providers MUST receive and react to email messages from FedRAMP without disruption and without requiring additional actions from FedRAMP.", + "attributes": { + "Ruleset": "AFC: Addressing FedRAMP Communication", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "AFC-CSO-CRA", + "name": "Complete Required Actions", + "description": "Providers MUST complete the required actions in Emergency or Emergency Test designated messages sent by FedRAMP within the timeframe included in the message.", + "attributes": { + "Ruleset": "AFC: Addressing FedRAMP Communication", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "AFC-CSO-EMR", + "name": "Emergency Message Routing", + "description": "Providers MUST route Emergency designated messages sent by FedRAMP to a senior security official for their awareness.", + "attributes": { + "Ruleset": "AFC: Addressing FedRAMP Communication", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "AFC-CSO-IMA", + "name": "Important Message Actions", + "description": "Providers SHOULD complete the required actions in Important designated messages sent by FedRAMP within the timeframe specified in the message.", + "attributes": { + "Ruleset": "AFC: Addressing FedRAMP Communication", + "Subset": "CSO: General Provider Responsibilities", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "AFC-CSO-ACK", + "name": "Acknowledge Receipt", + "description": "Providers SHOULD promptly and automatically acknowledge the receipt of messages received from FedRAMP in their FedRAMP Security Inbox.", + "attributes": { + "Ruleset": "AFC: Addressing FedRAMP Communication", + "Subset": "CSO: General Provider Responsibilities", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CCM-OCR-AVL", + "name": "Report Availability", + "description": "Providers MUST supply an Ongoing Certification Report to all necessary parties every 3 months, covering the entire period since the previous summary, in a consistent format that is human readable; this report MUST include high-level summaries of at least the following information (if applicable): Changes to FedRAMP Certification Data; Planned changes to FedRAMP Certification Data during at least the next 3 months; Accepted vulnerabilities; Transformative changes; Updated recommendations or best practices for security, configuration, usage, or similar aspects of the cloud service offering; A list of all agencies that are directly using the product; FedRAMP Reportable Incidents or an attestation that no such incidents occurred; Lessons learned and changes planned or made as a result of FedRAMP Reportable Incidents (if such occurred)", + "attributes": { + "Ruleset": "CCM: Collaborative Continuous Monitoring", + "Subset": "OCR: Ongoing Certification Reports", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CCM-OCR-NRD", + "name": "Next Report Date", + "description": "Providers MUST supply the target date for their next Ongoing Certification Report with other public FedRAMP Certification Data.", + "attributes": { + "Ruleset": "CCM: Collaborative Continuous Monitoring", + "Subset": "OCR: Ongoing Certification Reports", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CCM-OCR-FBM", + "name": "Feedback Mechanism", + "description": "Providers MUST supply an asynchronous mechanism for all necessary parties to provide feedback or ask questions about each Ongoing Certification Report.", + "attributes": { + "Ruleset": "CCM: Collaborative Continuous Monitoring", + "Subset": "OCR: Ongoing Certification Reports", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CCM-OCR-AFS", + "name": "Anonymized Feedback Summary", + "description": "Providers MUST supply an anonymized and desensitized summary of the feedback, questions, and answers about each Ongoing Certification Report as an addendum to the Ongoing Certification Report OR in the next Ongoing Certification Report.", + "attributes": { + "Ruleset": "CCM: Collaborative Continuous Monitoring", + "Subset": "OCR: Ongoing Certification Reports", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CCM-OCR-LSI", + "name": "Limit Sensitive Information", + "description": "Providers MUST NOT irresponsibly disclose sensitive information in an Ongoing Certification Report that would likely have an adverse effect on the cloud service offering.", + "attributes": { + "Ruleset": "CCM: Collaborative Continuous Monitoring", + "Subset": "OCR: Ongoing Certification Reports", + "Force": "MUST NOT" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CCM-OCR-SOR", + "name": "Spread Out Reports", + "description": "Providers SHOULD establish a regular 3 month cycle for Ongoing Certification Reports that is spread out from the beginning, middle, or end of each quarter.", + "attributes": { + "Ruleset": "CCM: Collaborative Continuous Monitoring", + "Subset": "OCR: Ongoing Certification Reports", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CCM-OCR-RPS", + "name": "Responsible Public Certification Report Sharing", + "description": "Providers MAY responsibly supply some or all of the information an Ongoing Certification Report to the public or other parties if the provider determines doing so will NOT likely have an adverse effect on the cloud service offering.", + "attributes": { + "Ruleset": "CCM: Collaborative Continuous Monitoring", + "Subset": "OCR: Ongoing Certification Reports", + "Force": "MAY" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CCM-QTR-MTG", + "name": "Quarterly Review Meeting", + "description": "Providers with Class C Certifications MUST host a synchronous Quarterly Review every 3 months, open to all necessary parties, to review aspects of the most recent Ongoing Certification Reports that the provider determines are of the most relevance to agencies.", + "attributes": { + "Ruleset": "CCM: Collaborative Continuous Monitoring", + "Subset": "QTR: Quarterly Reviews", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CCM-QTR-REG", + "name": "Meeting Registration Info", + "description": "Providers MUST supply either a registration link or a downloadable calendar file with meeting information for Quarterly Reviews to all necessary parties.", + "attributes": { + "Ruleset": "CCM: Collaborative Continuous Monitoring", + "Subset": "QTR: Quarterly Reviews", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CCM-QTR-NRD", + "name": "Next Review Date", + "description": "Providers MUST publicly supply the target date for their next Quarterly Review with other public FedRAMP Certification Data.", + "attributes": { + "Ruleset": "CCM: Collaborative Continuous Monitoring", + "Subset": "QTR: Quarterly Reviews", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CCM-QTR-NID", + "name": "No Irresponsible Disclosure", + "description": "Providers MUST NOT irresponsibly disclose sensitive information in a Quarterly Review that would likely have an adverse effect on the cloud service offering.", + "attributes": { + "Ruleset": "CCM: Collaborative Continuous Monitoring", + "Subset": "QTR: Quarterly Reviews", + "Force": "MUST NOT" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CCM-QTR-SAR", + "name": "Schedule Around Reports", + "description": "Providers SHOULD regularly schedule Quarterly Reviews to occur at least 3 business days after releasing an Ongoing Certification Report AND within 10 business days of such release.", + "attributes": { + "Ruleset": "CCM: Collaborative Continuous Monitoring", + "Subset": "QTR: Quarterly Reviews", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CCM-QTR-ACT", + "name": "Additional Content", + "description": "Providers SHOULD supply additional information in Quarterly Reviews that the provider determines is of interest, use, or otherwise relevant to agencies.", + "attributes": { + "Ruleset": "CCM: Collaborative Continuous Monitoring", + "Subset": "QTR: Quarterly Reviews", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CCM-QTR-RTR", + "name": "Record/Transcribe Reviews", + "description": "Providers SHOULD record or transcribe Quarterly Reviews and supply them to all necessary parties.", + "attributes": { + "Ruleset": "CCM: Collaborative Continuous Monitoring", + "Subset": "QTR: Quarterly Reviews", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CCM-QTR-RTP", + "name": "Restrict Third Parties", + "description": "Providers SHOULD NOT invite third parties to attend Quarterly Reviews intended for agencies unless they have specific relevance.", + "attributes": { + "Ruleset": "CCM: Collaborative Continuous Monitoring", + "Subset": "QTR: Quarterly Reviews", + "Force": "SHOULD NOT" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CCM-QTR-SRR", + "name": "Share Recordings Responsibly", + "description": "Providers MAY responsibly supply recordings or transcriptions of Quarterly Reviews to the public or other parties ONLY if the provider removes all agency information (comments, questions, names, etc.) AND determines doing so will NOT likely have an adverse effect on the cloud service offering.", + "attributes": { + "Ruleset": "CCM: Collaborative Continuous Monitoring", + "Subset": "QTR: Quarterly Reviews", + "Force": "MAY" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CCM-QTR-SCR", + "name": "Share Content Responsibly", + "description": "Providers MAY responsibly supply content prepared for a Quarterly Review to the public or other parties if the provider determines doing so will NOT likely have an adverse effect on the cloud service offering.", + "attributes": { + "Ruleset": "CCM: Collaborative Continuous Monitoring", + "Subset": "QTR: Quarterly Reviews", + "Force": "MAY" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-CSO-PUB", + "name": "Public Information", + "description": "Providers MUST publicly share up-to-date information about the cloud service offering in both human-readable and JSON formats, including at least the following information that is available and applicable: FedRAMP ID; Service Model; Deployment Model; Business Category; UEI Number; Sales Contact Information; Security Contact Information; Product Website Link; Link to Product Logo; Overall Service Description; Detailed list of specific services and their security categories (see CDS-CSO-SVC (Public Service List) (Service List)); Link to Secure Configuration Guidance; Overview of documentation supplied by the provider for the cloud service offering; Link to Trust Center landing page that includes instructions on accessing information in the trust center; Next Ongoing Certification Report date (see CCM-OCR-NRD (Next Report Date)); Current FedRAMP Recognized independent assessment service", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-CSO-SVC", + "name": "Public Service List", + "description": "Providers MUST publicly share a detailed list of specific services and their security categories that are included in the cloud service offering using clear feature or service names that align with standard public marketing materials; this list MUST be complete enough for a potential customer to determine which services are and are not included in the FedRAMP Minimum Assessment Scope without requesting access to underlying FedRAMP Certification Data.", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-CSO-FID", + "name": "Always Include FedRAMP ID", + "description": "Providers MUST always include the FedRAMP ID of the related cloud service offering in all FedRAMP Certification Data once assigned, including all reports, notifications, and other communication that results from FedRAMP rules.", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-CSO-FRC", + "name": "FedRAMP Certification Reports", + "description": "Providers MUST include FedRAMP Certification Reports with their FedRAMP Certification Data without inappropriate modifications, and make such reports available within 2 weeks of receiving the materials from FedRAMP.", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-CSO-AVR", + "name": "Availability Reporting", + "description": "Providers with Class C Certifications MUST maintain a web service, available to all necessary parties, that indicates current and historical availability of core services within the cloud service offering over at least the past 30 days, including availability incidents, in both human-readable and machine-readable formats; this service MUST be available even if the primary cloud service offering is unavailable.", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-CSO-UTC", + "name": "Use Trust Centers", + "description": "Providers MUST use a FedRAMP-compatible trust center to store and share FedRAMP Certification Data with all necessary parties.", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-CSO-CBF", + "name": "Consistency Between Formats", + "description": "Providers MUST use automation to ensure information remains consistent between human-readable and machine-readable formats when FedRAMP Certification Data is provided in both formats.", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-CSO-RIS", + "name": "Responsible Information Sharing", + "description": "Providers MUST provide sufficient information in FedRAMP Certification Data to support agency authorization decisions but SHOULD NOT include sensitive information that would likely enable a threat actor to gain unauthorized access, cause harm, disrupt operations, or otherwise have a negative adverse impact on the cloud service offering.", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-CSO-IRP", + "name": "Include Relevant Policies", + "description": "Providers MUST supply all relevant policies and procedures in the FedRAMP Certification Data, including a human-readable and machine-readable reference that explains at least the following about each included policy and procedure: Name of policy or procedure; Name of file, document, web page, etc.; Brief summary of policy or procedure; Word count of document; Current version; Date of last update; Related FedRAMP Practices (if applicable)", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-CSO-HAD", + "name": "Historical FedRAMP Certification Data", + "description": "Providers MUST supply snapshots of FedRAMP Certification Data aligned to Ongoing Certification Reports to all necessary parties; these snapshots MUST be available for the duration of FedRAMP Certification.", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-CSO-PSM", + "name": "Per-Service Certification Materials", + "description": "Providers with Class C Certifications MAY supply per-service FedRAMP Certification materials.", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MAY" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-CSO-RPS", + "name": "Responsible Public Package Sharing", + "description": "Providers MAY responsibly share some or all of the information in a FedRAMP Certification Package publicly or with other parties if the provider determines doing so will NOT likely have an adverse effect on the cloud service offering.", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MAY" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-TRC-USH", + "name": "Uninterrupted Sharing", + "description": "Trust centers MUST share FedRAMP Certification Data with all necessary parties without interruption.", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "TRC: FedRAMP-Compatible Trust Centers", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-TRC-PAC", + "name": "Programmatic Access", + "description": "Trust centers MUST provide documented programmatic access to all FedRAMP Certification Data, including programmatic access to human-readable materials.", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "TRC: FedRAMP-Compatible Trust Centers", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-TRC-AAI", + "name": "Agency Access Inventory", + "description": "Trust centers MUST maintain an inventory and history of federal agency users or systems with access to FedRAMP Certification Data and MUST make this information available to FedRAMP upon request.", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "TRC: FedRAMP-Compatible Trust Centers", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-TRC-ACL", + "name": "Access Logging", + "description": "Trust centers MUST log access to FedRAMP Certification Data and store summaries of access for at least six months; such information, as it pertains to specific parties, SHOULD be made available upon request by those parties.", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "TRC: FedRAMP-Compatible Trust Centers", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-TRC-HMR", + "name": "Human and Machine-Readable Certification Data", + "description": "Trust centers SHOULD make FedRAMP Certification Data available to view and download in both human-readable and machine-readable formats.", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "TRC: FedRAMP-Compatible Trust Centers", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-TRC-SSM", + "name": "Self-Service Access Management", + "description": "Trust centers SHOULD include features that encourage all necessary parties to provision and manage access to FedRAMP Certification Data for their users and services directly.", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "TRC: FedRAMP-Compatible Trust Centers", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-UTC-AAD", + "name": "Agency Access Denial", + "description": "Providers MUST notify FedRAMP within 5 business days of denying an agency access request for FedRAMP Certification Data.", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "UTC: Using a Trust Center", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CDS-UTC-AGA", + "name": "Agency Access", + "description": "Providers SHOULD supply access to the FedRAMP Certification Package with agencies upon request.", + "attributes": { + "Ruleset": "CDS: Certification Data Sharing", + "Subset": "UTC: Using a Trust Center", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CMU-CSO-CMD", + "name": "Cryptographic Module Documentation", + "description": "Providers MUST document the cryptographic modules used in each service (or groups of services that use the same modules) where cryptographic services are used to protect federal customer data, including whether these modules are validated under the NIST Cryptographic Module Validation Program or are update streams of such modules.", + "attributes": { + "Ruleset": "CMU: Cryptographic Module Use", + "Subset": "CSO: Cloud Service Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CMU-CSO-UVM", + "name": "Using Validated Cryptographic Modules", + "description": "Providers with Class C Certifications SHOULD use cryptographic modules or update streams of cryptographic modules with active validations under the NIST Cryptographic Module Validation Program when using cryptographic services to protect federal customer data.", + "attributes": { + "Ruleset": "CMU: Cryptographic Module Use", + "Subset": "CSO: Cloud Service Provider Responsibilities", + "Force": "SHOULD" + }, + "checks": { + "aws": [ + "elbv2_listener_fips_tls_enabled", + "transfer_server_fips_security_policy_enabled" + ], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CMU-CSO-CAT", + "name": "Configuration of Agency Tenants", + "description": "Providers SHOULD configure agency tenants by default to use cryptographic services that use cryptographic modules or update streams of cryptographic modules with active validations under the NIST Cryptographic Module Validation Program when such modules are available.", + "attributes": { + "Ruleset": "CMU: Cryptographic Module Use", + "Subset": "CSO: Cloud Service Provider Responsibilities", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CPO-CSO-OVR", + "name": "Overview of the Cloud Service Offering", + "description": "Providers MUST supply a Certification Package Overview within their FedRAMP Certification Package, in both human-readable and JSON formats, that includes at least all of the information required by the following rules: Certification Package Overview: CPO-CSO-MTD (Certification Package Overview Metadata); Certification Data Sharing: CDS-CSO-PUB (Public Information); Certification Data Sharing: CDS-CSO-SVC (Public Service List); Certification Data Sharing: CDS-CSO-IRP (Include Relevant Policies); Minimum Assessment Scope: MAS-CSO-IIR (Identify Information Resources); Minimum Assessment Scope: MAS-CSO-FLO (Information Flows and Security Categories); Minimum Assessment Scope: MAS-CSO-TPR (Third-Party Information Resources); Using Cryptographic Modules: CMU-CSO-CMD (Cryptographic Module Documentation); Independent Verification and Validation: IVV-CSO-ICP (Inclusion in Certification Package)", + "attributes": { + "Ruleset": "CPO: Certification Package Overview", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CPO-CSO-MTD", + "name": "Certification Package Overview Metadata", + "description": "Providers MUST also include the following basic metadata in their Certification Package Overview: Name, title, and contact information of official that is responsible and accountable for the FedRAMP Certification Package; Version; Date and time of last update; Source of update", + "attributes": { + "Ruleset": "CPO: Certification Package Overview", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CPO-CSO-OSA", + "name": "Overall Summary of Assessment in Certification Package", + "description": "Providers seeking Class C Certification MUST also include the overall summary of their FedRAMP independent assessment, supplied by the assessor per IVV-IAS-OSA (Overall Summary of Assessment), in their Certification Package Overview.", + "attributes": { + "Ruleset": "CPO: Certification Package Overview", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "CPO-CSX-CPM", + "name": "Certification Package Maintenance for 20x", + "description": "Providers with 20x Class C Certifications MUST persistently maintain their FedRAMP Certification Package to ensure it is up to date and complete at least once every 2 weeks.", + "attributes": { + "Ruleset": "CPO: Certification Package Overview", + "Subset": "CSX: 20x-Specific Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "FRC-CSO-FCP", + "name": "FedRAMP Certification Profile", + "description": "Providers MUST identify a target FedRAMP Certification Profile and apply all relevant FedRAMP Practices to the cloud service offering.", + "attributes": { + "Ruleset": "FRC: FedRAMP Certification", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "FRC-CSO-PKG", + "name": "FedRAMP Certification Package", + "description": "Providers seeking a Certification MUST supply a complete FedRAMP Certification Package to FedRAMP for initial certification; the FedRAMP Certification Package MUST include at least the following information: Information about the Cloud Service Offering following CPO-CSO-OVR (Overview of the Cloud Service Offering); Implementation, Validation, and Assessment information for each relevant FedRAMP requirement/control/ksi as defined in SDR-CSO-FRR (FedRAMP Rules); A real or example Ongoing Certification Report following CCM-OCR-AVL (Report Availability)", + "attributes": { + "Ruleset": "FRC: FedRAMP Certification", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "FRC-CSO-JSN", + "name": "FedRAMP JSON Schemas", + "description": "Providers MUST supply machine-readable information in JSON documents that are valid against the corresponding JSON schema when a rule contains a FedRAMP JSON schema, UNLESS otherwise specified in the rule.", + "attributes": { + "Ruleset": "FRC: FedRAMP Certification", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "FRC-CSO-MRA", + "name": "Maintain Responsibility and Accountability", + "description": "Providers MUST maintain responsibility and accountability for the accuracy and completeness of all information in the FedRAMP Certification Package, especially when they engage a third party (such as an independent assessor, advisory service, or external tools) to supply information on their behalf.", + "attributes": { + "Ruleset": "FRC: FedRAMP Certification", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "FRC-CSO-POP", + "name": "Pick One Program Certification Type", + "description": "Providers MUST NOT seek both FedRAMP Rev5 Program Certification and FedRAMP 20x Program Certification for the same cloud service offering; pick one type.", + "attributes": { + "Ruleset": "FRC: FedRAMP Certification", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST NOT" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "FRC-APP-MLF", + "name": "Marketplace Listing First", + "description": "Providers MUST be listed in the FedRAMP Marketplace before applying for FedRAMP Certification, including: FedRAMP Marketplace: MKT-CSO-MLR (Marketplace Listing Requirements); FedRAMP Marketplace: MKT-CSO-PML (Provider Marketplace Listing Requests); FedRAMP Marketplace: MKT-IIP-AGU (Agency Use Cases); FedRAMP Marketplace: MKT-IIP-DCP (Demonstrating Continuous Progress)", + "attributes": { + "Ruleset": "FRC: FedRAMP Certification", + "Subset": "APP: Applying for FedRAMP Certification", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "FRC-APP-AFC", + "name": "Applying for FedRAMP Certification", + "description": "Providers MUST complete the FedRAMP Certification Application Form in full to request an initial assessment by FedRAMP.", + "attributes": { + "Ruleset": "FRC: FedRAMP Certification", + "Subset": "APP: Applying for FedRAMP Certification", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "FRC-APP-FCP", + "name": "Fresh FedRAMP Certification Package", + "description": "Providers MUST supply a fresh initial FedRAMP Certification Package that shows the current status of the cloud service offering as verified and validated by the provider within the previous 7 days.", + "attributes": { + "Ruleset": "FRC: FedRAMP Certification", + "Subset": "APP: Applying for FedRAMP Certification", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "FRC-APP-FIA", + "name": "Fresh Independent Assessment", + "description": "Providers seeking Class C Certification MUST supply a fresh initial FedRAMP independent assessment that was completed by a FedRAMP Recognized independent assessment service within the previous 3 months.", + "attributes": { + "Ruleset": "FRC: FedRAMP Certification", + "Subset": "APP: Applying for FedRAMP Certification", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "FRC-APP-NTP", + "name": "No Third-Party Applicants", + "description": "Providers MUST NOT use a third party to apply for a FedRAMP Certification on their behalf; this includes independent assessment services.", + "attributes": { + "Ruleset": "FRC: FedRAMP Certification", + "Subset": "APP: Applying for FedRAMP Certification", + "Force": "MUST NOT" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "FRC-APP-USA", + "name": "Updating Stale Assessments", + "description": "Providers MAY freshen a stale initial independent verification and validation assessment by having a FedRAMP Recognized independent assessment service review any changes between the original assessment and the current status of the cloud service offering in place of a full re-assessment, UNLESS the stale assessment is more than 9 months old.", + "attributes": { + "Ruleset": "FRC: FedRAMP Certification", + "Subset": "APP: Applying for FedRAMP Certification", + "Force": "MAY" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "FRC-CSX-VVK", + "name": "Automated Verification and Validation of Key Security Indicators", + "description": "Providers seeking 20x Class C Certification MUST implement automated methods to persistently verify and validate the accuracy and completeness of Key Security Indicators with at least 2 automated methods for each Key Security Indicator.", + "attributes": { + "Ruleset": "FRC: FedRAMP Certification", + "Subset": "CSX: 20x-Specific Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "FRC-CSX-MOT", + "name": "Metrics Over Time for Key Security Indicators", + "description": "Providers seeking 20x Class C Certification MUST supply historical metrics including status from persistent validation over at least the past 6 months for all Key Security Indicators.", + "attributes": { + "Ruleset": "FRC: FedRAMP Certification", + "Subset": "CSX: 20x-Specific Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "FRC-CSX-VVR", + "name": "Automated Verification and Validation of FedRAMP Rules", + "description": "Providers seeking 20x Class C Certification SHOULD implement automated methods to persistently verify and validate the accuracy and completeness of the Security Decision Record for FedRAMP rules when applicable.", + "attributes": { + "Ruleset": "FRC: FedRAMP Certification", + "Subset": "CSX: 20x-Specific Provider Responsibilities", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "FRC-CSX-MAS", + "name": "Application within MAS", + "description": "Providers SHOULD apply ALL Key Security Indicators to ALL aspects of their cloud service offering that are within the FedRAMP Minimum Assessment Scope.", + "attributes": { + "Ruleset": "FRC: FedRAMP Certification", + "Subset": "CSX: 20x-Specific Provider Responsibilities", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "IEC-CSO-EFR", + "name": "Evaluate FedRAMP Reportability", + "description": "Providers MUST promptly evaluate incidents to determine if they affect confidentiality or integrity of federal customer data or are likely to affect confidentiality or integrity of federal customer data; such incidents are FedRAMP Reportable Incidents and must be reported following the FedRAMP Incident Evaluation and Communication rules.", + "attributes": { + "Ruleset": "IEC: Incident Evaluation and Communication", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "IEC-CSO-DPR", + "name": "Default PAIN Rating", + "description": "Providers MUST treat FedRAMP Reportable Incidents as if they have a Potential Agency Impact N-rating (PAIN) of 5 UNLESS they promptly estimate the PAIN rating following the rule in IEC-CSO-EFI (Estimate Federal Impact).", + "attributes": { + "Ruleset": "IEC: Incident Evaluation and Communication", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "IEC-CSO-IIR", + "name": "Initial Incident Report", + "description": "Providers with Class C Certifications MUST responsibly notify all affected parties after identifying FedRAMP Reportable Incidents by providing an Initial Incident Report with as much of the following information that is available at the time of reporting and/or the current relevant status for each item: Contact information for the federal incident response coordinator.; Provider's internally assigned tracking identifier; Description of the incident; Timeline of the incident, including start time, time and source of detection, time of completed FedRAMP Reportable Incident evaluation, and other major incident milestones determined by the provider; Historically and currently estimated Potential Agency Impact N-rating (PAIN) of the incident, including an explanation of the evaluation following the requirements in IEC-CSO-EFI (Estimate Federal Impact) (if applicable); Functional impact to federal agency customers (include impact to confidentiality and/or integrity and the impacted federal customer data types); Estimated recovery plan, milestones, and timelines; List of likely affected customer agencies; N1 Initial Incident Report: 1 bizdays; N2 Initial Incident Report: 24 hours; N3 Initial Incident Report: 1 hours; N4 Initial Incident Report: 1 hours; N5 Initial Incident Report: 1 hours", + "attributes": { + "Ruleset": "IEC: Incident Evaluation and Communication", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "IEC-CSO-OIR", + "name": "Ongoing Incident Reports", + "description": "Providers with Class C Certifications MUST responsibly notify all affected parties of ongoing activity as new information becomes available during incident response for FedRAMP Reportable Incidents, including updates (or lack of updates) to all previously reported information and as much of the following additional information that is available and/or the current relevant status for each item: Observed incident activity; Indicators of compromise; Related Common Vulnerabilities and Exposures (CVE) identifier, if applicable; Root cause; Response and recovery activities; N1 Ongoing Incident Report: 1 bizdays; N2 Ongoing Incident Report: 24 hours; N3 Ongoing Incident Report: 6 hours; N4 Ongoing Incident Report: 6 hours; N5 Ongoing Incident Report: 6 hours", + "attributes": { + "Ruleset": "IEC: Incident Evaluation and Communication", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "IEC-CSO-FIR", + "name": "Final Incident Report", + "description": "Providers with Class C Certifications MUST responsibly notify all affected parties by providing a Final Incident Report once the incident has been resolved and recovery is complete, including final updates to all previously reported information. N1 Final Incident Report: 1 bizdays; N2 Final Incident Report: 1 bizdays; N3 Final Incident Report: 6 hours; N4 Final Incident Report: 6 hours; N5 Final Incident Report: 6 hours", + "attributes": { + "Ruleset": "IEC: Incident Evaluation and Communication", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "IEC-CSO-EFI", + "name": "Estimate Federal Impact", + "description": "Providers SHOULD promptly estimate the likely adverse impact of an incident on agency customers to assign a Potential Agency Impact N-rating; this step is called Incident Rating. N1 for a likely minimal customer effect on 1 or more agencies.; N2 for a likely narrow customer effect on 1 or more agencies.; N3 for a likely disruptive customer effect on 1 agency.; N4 for a likely debilitating customer effect on 1 agency or a likely disruptive customer effect on more than 1 agency.; N5 for a likely debilitating customer effect on more than 1 agency.", + "attributes": { + "Ruleset": "IEC: Incident Evaluation and Communication", + "Subset": "CSO: General Provider Responsibilities", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "IEC-CSO-AIR", + "name": "Automated Incident Reporting", + "description": "Providers SHOULD use automation to minimize human intervention in the process of reporting FedRAMP Reportable Incidents to all affected parties.", + "attributes": { + "Ruleset": "IEC: Incident Evaluation and Communication", + "Subset": "CSO: General Provider Responsibilities", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "IVV-CSO-FIA", + "name": "FedRAMP Independent Assessments", + "description": "Providers with Class C Certifications MUST persistently complete an independent verification and validation assessment of all applicable FedRAMP rules with a FedRAMP Recognized independent assessment service OR FedRAMP at least once per year; this is a FedRAMP independent assessment.", + "attributes": { + "Ruleset": "IVV: Independent Verification and Validation", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "IVV-CSO-SEI", + "name": "Supply Evidence of Implementation", + "description": "Providers MUST supply evidence to all necessary assessors of the implementation of the measures that have been documented to meet FedRAMP Practices; this evidence is the result of verification.", + "attributes": { + "Ruleset": "IVV: Independent Verification and Validation", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "IVV-CSO-SEE", + "name": "Supply Evidence of Effectiveness", + "description": "Providers MUST supply evidence to all necessary assessors of the effectiveness of the measures that have been implemented to meet FedRAMP Practices; this evidence is the result of validation.", + "attributes": { + "Ruleset": "IVV: Independent Verification and Validation", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "IVV-CSO-ICP", + "name": "Inclusion in Certification Package", + "description": "Providers MUST supply the results of FedRAMP independent assessments in their FedRAMP Certification Package without inappropriate modification.", + "attributes": { + "Ruleset": "IVV: Independent Verification and Validation", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "IVV-CSO-DUS", + "name": "Document Use of Representative Samples", + "description": "Providers MUST document and explain the use of representative samples during verification and validation when using representative samples as allowed by IVV-CSO-USR (Use Representative Samples).", + "attributes": { + "Ruleset": "IVV: Independent Verification and Validation", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "IVV-CSO-STE", + "name": "Supply Technical Explanations", + "description": "Providers SHOULD supply all necessary assessors with technical explanations, demonstrations, and other relevant supporting information about the technical capabilities they employ to address FedRAMP rules; this SHOULD be supplied as necessary to ensure the assessor can effectively complete verification and validation.", + "attributes": { + "Ruleset": "IVV: Independent Verification and Validation", + "Subset": "CSO: General Provider Responsibilities", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "IVV-CSO-USR", + "name": "Use Representative Samples", + "description": "Providers MAY use representative samples as appropriate during verification and validation.", + "attributes": { + "Ruleset": "IVV: Independent Verification and Validation", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MAY" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "IVV-CSO-RAA", + "name": "Receiving Assessor Advice", + "description": "Providers MAY ask for and accept advice from their assessor during assessment regarding techniques and procedures that will improve their security posture or the effectiveness, clarity, and accuracy of their verification, validation and reporting procedures, UNLESS doing so is likely to compromise the objectivity and integrity of the assessment.", + "attributes": { + "Ruleset": "IVV: Independent Verification and Validation", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MAY" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "IVV-CSX-AIA", + "name": "Annual Independent Assessments for 20x", + "description": "Providers with 20x Class C Certifications MUST include all Key Security Indicators in a FedRAMP independent assessment at least once per year.", + "attributes": { + "Ruleset": "IVV: Independent Verification and Validation", + "Subset": "CSX: 20x-Specific Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "MAS-CSO-IIR", + "name": "Identify Information Resources", + "description": "Providers MUST identify a set of information resources to assess for FedRAMP Certification that includes all information resources that are likely to handle federal customer data or likely to impact the confidentiality, integrity, or availability of federal customer data handled by the cloud service offering; this set of information resources is the cloud service offering.", + "attributes": { + "Ruleset": "MAS: Minimum Assessment Scope", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [ + "config_recorder_all_regions_enabled", + "resourceexplorer2_indexes_found" + ], + "azure": [], + "gcp": [ + "iam_cloud_asset_inventory_enabled" + ], + "kubernetes": [], + "m365": [] + }, + "config_requirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + } + ] + }, + { + "id": "MAS-CSO-FLO", + "name": "Information Flows and Security Categories", + "description": "Providers MUST clearly identify, document, and explain information flows and security categories for ALL information resources or sets of information resources in the cloud service offering.", + "attributes": { + "Ruleset": "MAS: Minimum Assessment Scope", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "MAS-CSO-TPR", + "name": "Third-Party Information Resources", + "description": "Providers MUST address the potential impact to federal customer data from third-party information resources used by the cloud service offering, ONLY IF MAS-CSO-IIR (Identify Information Resources) APPLIES, by documenting the following information about each applicable third-party information resource: General usage and configuration; Explanation or justification for use; Mitigation measures in place to reduce the potential impact to federal customer data; Compensating controls in place to reduce the potential impact to federal customer data", + "attributes": { + "Ruleset": "MAS: Minimum Assessment Scope", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "MAS-CSO-MDI", + "name": "Metadata Inclusion", + "description": "Providers MUST include metadata (including metadata about federal customer data) in the Minimum Assessment Scope ONLY IF MAS-CSO-IIR (Identify Information Resources) APPLIES.", + "attributes": { + "Ruleset": "MAS: Minimum Assessment Scope", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "MAS-CSO-SUP", + "name": "Supplemental Information", + "description": "Providers MAY include additional materials about other information resources that are not part of the cloud service offering in a FedRAMP Certification Package supplement; these resources will not be FedRAMP Certified and MUST be clearly marked and separated from the cloud service offering.", + "attributes": { + "Ruleset": "MAS: Minimum Assessment Scope", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MAY" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "MKT-CSO-MLR", + "name": "Marketplace Listing Requirements", + "description": "Providers MUST address at least these FedRAMP rules to apply for a new FedRAMP Marketplace listing OR to request updates to an existing listing: Certification Data Sharing: CDS-CSO-PUB (Public Information)", + "attributes": { + "Ruleset": "MKT: Marketplace Listing", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "MKT-CSO-PML", + "name": "Provider Marketplace Listing Requests", + "description": "Providers MUST notify FedRAMP using the FedRAMP Marketplace Providing Listing Request Form to request a listing in the FedRAMP Marketplace.", + "attributes": { + "Ruleset": "MKT: Marketplace Listing", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "MKT-IIP-AGU", + "name": "Agency Use Cases", + "description": "Providers MUST demonstrate that a cloud service offering is intended for one of the following use cases: Direct Use: The product will be used directly by agency customers for integration into a federal information system that falls within the scope of 44 USC § 3506 and will receive an agency Authorization to Operate.; Indirect Use: The product will be included as a third-party information resource in other cloud service offerings that are directly used by agency customers.", + "attributes": { + "Ruleset": "MKT: Marketplace Listing", + "Subset": "IIP: Provider Responsibilities for Initial Implementation Phase Listings", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "MKT-IIP-DCP", + "name": "Demonstrating Continuous Progress", + "description": "Providers MUST demonstrate continuous progress towards a FedRAMP Certification, documented in their Trust Center or website and updated at least quarterly; progress is measured by the provider against documented goals and milestones.", + "attributes": { + "Ruleset": "MKT: Marketplace Listing", + "Subset": "IIP: Provider Responsibilities for Initial Implementation Phase Listings", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "MKT-IIP-DLA", + "name": "Deadline for Assessment", + "description": "Providers MUST demonstrate that an assessment for a FedRAMP Certification Class B, C, or D has been scheduled within 2 years of initial listing in the Initial Implementation Phase.", + "attributes": { + "Ruleset": "MKT: Marketplace Listing", + "Subset": "IIP: Provider Responsibilities for Initial Implementation Phase Listings", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCG-CSO-RSC", + "name": "Recommended Secure Configuration", + "description": "Providers MUST create, maintain, and make available recommendations for securely configuring their cloud services (the Secure Configuration Guide) that includes at least the following information: Required: Instructions on how to securely access, configure, operate, and decommission top-level administrative accounts that control enterprise access to the entire cloud service offering.; Required: Explanations of security-related settings that can be operated only by top-level administrative accounts and their security implications.; Recommended: Explanations of security-related settings that can be operated only by privileged accounts and their security implications.", + "attributes": { + "Ruleset": "SCG: Secure Configuration Guide", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCG-CSO-AUP", + "name": "Use Instructions", + "description": "Providers MUST include instructions in the FedRAMP Certification Package that explain how to obtain and use the Secure Configuration Guide.", + "attributes": { + "Ruleset": "SCG: Secure Configuration Guide", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCG-CSO-PUB", + "name": "Public Secure Configuration Guidance", + "description": "Providers SHOULD make the Secure Configuration Guide available publicly.", + "attributes": { + "Ruleset": "SCG: Secure Configuration Guide", + "Subset": "CSO: General Provider Responsibilities", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCG-CSO-SDF", + "name": "Secure Defaults", + "description": "Providers SHOULD set all settings to their recommended secure defaults for top-level administrative accounts and privileged accounts when initially provisioned.", + "attributes": { + "Ruleset": "SCG: Secure Configuration Guide", + "Subset": "CSO: General Provider Responsibilities", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCG-ENH-CMP", + "name": "Comparison Capability", + "description": "Providers SHOULD offer the capability to compare all current settings for top-level administrative accounts and privileged accounts to the recommended secure defaults.", + "attributes": { + "Ruleset": "SCG: Secure Configuration Guide", + "Subset": "ENH: Enhanced Capabilities", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCG-ENH-EXP", + "name": "Export Capability", + "description": "Providers SHOULD offer the capability to export all security settings in a machine-readable format.", + "attributes": { + "Ruleset": "SCG: Secure Configuration Guide", + "Subset": "ENH: Enhanced Capabilities", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCG-ENH-API", + "name": "API Capability", + "description": "Providers SHOULD offer the capability to view and adjust security settings via an API or similar capability.", + "attributes": { + "Ruleset": "SCG: Secure Configuration Guide", + "Subset": "ENH: Enhanced Capabilities", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCG-ENH-MRG", + "name": "Machine-Readable Guidance", + "description": "Providers SHOULD also provide the Secure Configuration Guide in a machine-readable format that can be used by customers or third-party tools to compare against current settings.", + "attributes": { + "Ruleset": "SCG: Secure Configuration Guide", + "Subset": "ENH: Enhanced Capabilities", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCG-ENH-VRH", + "name": "Versioning and Release History", + "description": "Providers SHOULD provide versioning and a release history for recommended secure default settings for top-level administrative accounts and privileged accounts as they are adjusted over time.", + "attributes": { + "Ruleset": "SCG: Secure Configuration Guide", + "Subset": "ENH: Enhanced Capabilities", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCN-CSO-EVA", + "name": "Evaluate Changes", + "description": "Providers MUST evaluate all potential significant changes to determine the type of significant change and follow the appropriate Significant Change Notification rules. Is it a significant change? --> Continue evaluation and follow the Significant Change Notification rules.; If it is, is it an FedRAMP Certification class change? --> This requires a new assessment and cannot be done under the Significant Change Notification rules.; If it is not, is it a routine recurring change? --> Follow the Routine Recurring Change rules (SCN-RTR Routine Recurring Changes).; If it is not, is it a transformative change? --> Follow the Transformative Change rules (SCN-TRF Transformative Changes).; If it is not, then it is an adaptive change --> Follow the Adaptive Change rules (SCN-ADP Adaptive Changes).", + "attributes": { + "Ruleset": "SCN: Significant Change Notification", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCN-CSO-MAR", + "name": "Maintain Audit Records", + "description": "Providers MUST maintain auditable records of the significant change evaluation activities required by SCN-CSO-EVA (Evaluate Changes) and make them available to FedRAMP as requested.", + "attributes": { + "Ruleset": "SCN: Significant Change Notification", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCN-CSO-INF", + "name": "Required Information", + "description": "Providers MUST include at least the following information in Significant Change Notifications: Service Offering FedRAMP ID; Assessor Name (if applicable); Related Vulnerability (if applicable); Significant Change type and explanation of categorization; Short description of change; Reason for change; Summary of customer impact, including changes to services and customer configuration responsibilities; Plan and timeline for the change, including for the verification, assessment, and/or validation of impacted Key Security Indicators or Rev5 Controls; Copy of the business or security impact analysis; Name and title of approver", + "attributes": { + "Ruleset": "SCN: Significant Change Notification", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCN-CSO-HIS", + "name": "Historical Notifications", + "description": "Providers MUST keep 12 months of historical Significant Change Notifications available with their FedRAMP Certification Data.", + "attributes": { + "Ruleset": "SCN: Significant Change Notification", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCN-CSO-HRM", + "name": "Human and Machine-Readable Notifications", + "description": "Providers MUST make ALL Significant Change Notifications and related audit records available in human-readable and JSON formats.", + "attributes": { + "Ruleset": "SCN: Significant Change Notification", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCN-CSO-ARI", + "name": "Additional Relevant Information", + "description": "Providers MAY include additional relevant information in Significant Change Notifications.", + "attributes": { + "Ruleset": "SCN: Significant Change Notification", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MAY" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCN-CSO-NOM", + "name": "Notification Mechanisms", + "description": "Providers MAY notify necessary parties in a variety of ways as long as the mechanism for notification is clearly documented in the FedRAMP Certification Package and easily accessible.", + "attributes": { + "Ruleset": "SCN: Significant Change Notification", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MAY" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCN-CSO-EMG", + "name": "Emergency Changes", + "description": "Providers MAY execute significant changes (including transformative changes) during an emergency or incident without following the Significant Change Notification rules in advance. In such emergencies, providers MUST follow all relevant procedures, notify all necessary parties, retroactively provide all Significant Change Notification materials, and complete appropriate assessment after the incident.", + "attributes": { + "Ruleset": "SCN: Significant Change Notification", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MAY" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCN-ADP-NTF", + "name": "Notification Requirements", + "description": "Providers MUST notify all necessary parties within 10 business days after finishing adaptive changes, also including the following information: Summary of any new risks identified and/or vulnerabilities resulting from the change (if applicable)", + "attributes": { + "Ruleset": "SCN: Significant Change Notification", + "Subset": "ADP: Adaptive Changes", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCN-RTR-NNR", + "name": "No Notification Requirements", + "description": "Providers SHOULD NOT make formal Significant Change Notifications for routine recurring changes; this type of change is exempted from notification requirements.", + "attributes": { + "Ruleset": "SCN: Significant Change Notification", + "Subset": "RTR: Routine Recurring Changes", + "Force": "SHOULD NOT" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCN-TRF-NIP", + "name": "Notification of Initial Plans", + "description": "Providers MUST notify all necessary parties of initial plans for transformative changes at least 30 business days before starting transformative changes, including a summary of any likely security impacts or changes in risk.", + "attributes": { + "Ruleset": "SCN: Significant Change Notification", + "Subset": "TRF: Transformative Changes", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCN-TRF-NFP", + "name": "Notification of Final Plans", + "description": "Providers MUST notify all necessary parties of final plans for transformative changes at least 10 business days before starting transformative changes, including updates to all previously sent information.", + "attributes": { + "Ruleset": "SCN: Significant Change Notification", + "Subset": "TRF: Transformative Changes", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCN-TRF-NAF", + "name": "Notification After Finishing", + "description": "Providers MUST notify all necessary parties within 5 business days after finishing transformative changes, including updates to all previously sent information.", + "attributes": { + "Ruleset": "SCN: Significant Change Notification", + "Subset": "TRF: Transformative Changes", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCN-TRF-NAV", + "name": "Notification After Verification", + "description": "Providers MUST notify all necessary parties within 5 business days after completing the verification, assessment, and/or validation of transformative changes, also including the following information: Updates to all previously sent information; Summary of any new risks identified and/or vulnerabilities resulting from the change (if applicable); Copy of the security assessment report (if applicable)", + "attributes": { + "Ruleset": "SCN: Significant Change Notification", + "Subset": "TRF: Transformative Changes", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCN-TRF-UPD", + "name": "Update Documentation", + "description": "Providers MUST publish updated service documentation and other materials to reflect transformative changes within 30 business days after finishing transformative changes.", + "attributes": { + "Ruleset": "SCN: Significant Change Notification", + "Subset": "TRF: Transformative Changes", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SCN-TRF-TPR", + "name": "Third-Party Review", + "description": "Providers SHOULD engage a third-party assessor to review the scope and impact of the planned change before starting transformative changes if human validation is necessary; such reviews SHOULD be limited to security decisions that require human validation.", + "attributes": { + "Ruleset": "SCN: Significant Change Notification", + "Subset": "TRF: Transformative Changes", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SDR-CSO-FRR", + "name": "FedRAMP Rules", + "description": "Providers MUST supply a Security Decision Record, in both human-readable and JSON formats, that includes at least all of the following information for each applicable FedRAMP rule: Explanation of how the rule is followed, or an explanation of the reason and resulting risk to customers for not following the rule.; Verification that the implementation is appropriate for the rule, or that the reason for not implementing is accepted by a senior official.; Validation that the implementation is in place and working as intended, or that the reason for not implementing is accepted by a senior official.; Independent verification.; Independent validation.; Any responses or clarifications to the comments in the independent verification or validation.; Rule-specific artifacts (if applicable).", + "attributes": { + "Ruleset": "SDR: Security Decision Record", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SDR-CSO-MTD", + "name": "Security Decision Record Metadata", + "description": "Providers MUST also include the following basic metadata in their Security Decision Record: Version; Date and time of last update; Source of update", + "attributes": { + "Ruleset": "SDR: Security Decision Record", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SDR-CSX-KSI", + "name": "Key Security Indicators", + "description": "Providers MUST also include short and simple high-level summaries of at least the following for each applicable Key Security Indicator: Explanation of measures (and their objectives) that demonstrate the Key Security Indicator, or an explanation of the reason and resulting risk to customers for not having measures available for that Key Security Indicator.; Explanation of the cycle for any measures that are implemented persistently (if applicable).; Verification that the measures demonstrate the Key Security Indicator, or that the reason for not having them is accepted.; Verification that the automation in place is accurate and sufficient to demonstrate appropriate measures for the Key Security Indicator, or that automation is not necessary for each measure.; Validation that the measures are accurately produced and are in place and working as intended, or that the reason for not having them is valid.", + "attributes": { + "Ruleset": "SDR: Security Decision Record", + "Subset": "CSX: 20x-Specific Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "SDR-CSX-KMT", + "name": "Key Security Indicator Metrics", + "description": "Providers with 20x Class C Certifications MUST also include historical metrics in their Security Decision Record, supplying at least the following information for each applicable Key Security Indicator: Summary of each metric over the past 30 days; Summary of metric up to the past year (where available); All daily metric data up to the past year (where available)", + "attributes": { + "Ruleset": "SDR: Security Decision Record", + "Subset": "CSX: 20x-Specific Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VDR-CSO-DET", + "name": "Vulnerability Detection", + "description": "Providers MUST systematically, persistently, and promptly discover and identify vulnerabilities within their cloud service offering using appropriate techniques such as assessment, scanning, threat intelligence, vulnerability disclosure mechanisms, bug bounties, penetration testing, incident response, automated control testing, supply chain monitoring, and other relevant capabilities; this process is called vulnerability detection. Vulnerability detection includes persistently verifying and validating that information resources and processes are operating as intended and documented for FedRAMP Practices.", + "attributes": { + "Ruleset": "VDR: Vulnerability Detection and Response", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [ + "ecr_registry_enhanced_scanning_enabled", + "ecr_registry_scan_images_on_push_enabled", + "inspector2_is_enabled", + "securityhub_enabled" + ], + "azure": [ + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "defender_container_images_scan_enabled", + "defender_ensure_defender_cspm_is_on", + "sqlserver_va_periodic_recurring_scans_enabled" + ], + "gcp": [ + "gcr_container_scanning_enabled" + ], + "kubernetes": [], + "m365": [] + }, + "config_requirements": [ + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + } + ] + }, + { + "id": "VDR-CSO-RES", + "name": "Vulnerability Response", + "description": "Providers MUST systematically, persistently, and promptly track, evaluate, monitor, mitigate, remediate, assess exploitation of, report, and otherwise manage all detected vulnerabilities within their cloud service offering; this process is called vulnerability response.", + "attributes": { + "Ruleset": "VDR: Vulnerability Detection and Response", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [ + "ecr_repositories_scan_vulnerabilities_in_latest_image", + "inspector2_active_findings_exist" + ], + "azure": [ + "defender_container_images_resolved_vulnerabilities" + ], + "gcp": [], + "kubernetes": [], + "m365": [] + }, + "config_requirements": [ + { + "Check": "ecr_repositories_scan_vulnerabilities_in_latest_image", + "ConfigKey": "ecr_repository_vulnerability_minimum_severity", + "Operator": "eq", + "Value": "MEDIUM", + "Provider": "aws" + } + ] + }, + { + "id": "VDR-CSO-FAV", + "name": "Failures Are Vulnerabilities", + "description": "Providers MUST treat problems or failures with their vulnerability detection and response processes as vulnerabilities.", + "attributes": { + "Ruleset": "VDR: Vulnerability Detection and Response", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MUST" + }, + "checks": { + "aws": [ + "inspector2_coverage_scan_status_active" + ], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VDR-CSO-DFR", + "name": "Design For Resilience", + "description": "Providers SHOULD make design and architecture decisions for their cloud service offering that mitigate the risk of vulnerabilities by default AND decrease the risk and complexity of vulnerability detection and response.", + "attributes": { + "Ruleset": "VDR: Vulnerability Detection and Response", + "Subset": "CSO: General Provider Responsibilities", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VDR-CSO-ADT", + "name": "Automate Detection", + "description": "Providers SHOULD use automated services to improve and streamline vulnerability detection and response.", + "attributes": { + "Ruleset": "VDR: Vulnerability Detection and Response", + "Subset": "CSO: General Provider Responsibilities", + "Force": "SHOULD" + }, + "checks": { + "aws": [ + "ecr_registry_enhanced_scanning_enabled", + "ecr_registry_scan_images_on_push_enabled", + "inspector2_is_enabled" + ], + "azure": [ + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "defender_container_images_scan_enabled", + "sqlserver_va_periodic_recurring_scans_enabled" + ], + "gcp": [ + "gcr_container_scanning_enabled" + ], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VDR-CSO-DAC", + "name": "Detect After Changes", + "description": "Providers SHOULD automatically perform vulnerability detection on representative samples of new or significantly changed information resources.", + "attributes": { + "Ruleset": "VDR: Vulnerability Detection and Response", + "Subset": "CSO: General Provider Responsibilities", + "Force": "SHOULD" + }, + "checks": { + "aws": [ + "ecr_registry_scan_images_on_push_enabled", + "inspector2_is_enabled" + ], + "azure": [ + "defender_container_images_scan_enabled" + ], + "gcp": [ + "gcr_container_scanning_enabled" + ], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VDR-CSO-MSP", + "name": "Maintain Security", + "description": "Providers SHOULD NOT weaken the security of information resources to facilitate vulnerability scanning, detection, or assessment activities.", + "attributes": { + "Ruleset": "VDR: Vulnerability Detection and Response", + "Subset": "CSO: General Provider Responsibilities", + "Force": "SHOULD NOT" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VDR-CSO-AKE", + "name": "Avoid KEVs", + "description": "Providers SHOULD NOT deploy or otherwise activate new machine-based information resources with Known Exploited Vulnerabilities.", + "attributes": { + "Ruleset": "VDR: Vulnerability Detection and Response", + "Subset": "CSO: General Provider Responsibilities", + "Force": "SHOULD NOT" + }, + "checks": { + "aws": [ + "inspector2_active_findings_no_known_exploited_vulnerabilities" + ], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VDR-CSO-SIR", + "name": "Sampling", + "description": "Providers MAY sample effectively identical information resources, especially machine-based information resources, when performing vulnerability detection UNLESS doing so would decrease the efficiency or effectiveness of vulnerability detection.", + "attributes": { + "Ruleset": "VDR: Vulnerability Detection and Response", + "Subset": "CSO: General Provider Responsibilities", + "Force": "MAY" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VDR-TFR-NMV", + "name": "Non-Machine Verification and Validation", + "description": "Providers MUST verify and validate the status of non-machine-based information resources at least once every 3 months.", + "attributes": { + "Ruleset": "VDR: Vulnerability Detection and Response", + "Subset": "TFR: Timeframes", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VDR-TFR-PDD", + "name": "Persistent Drift Detection", + "description": "Providers with Class C Certifications SHOULD persistently perform vulnerability detection on all information resources that are likely to drift, at least once every 14 days.", + "attributes": { + "Ruleset": "VDR: Vulnerability Detection and Response", + "Subset": "TFR: Timeframes", + "Force": "SHOULD" + }, + "checks": { + "aws": [ + "inspector2_coverage_recently_scanned", + "inspector2_is_enabled", + "securityhub_enabled" + ], + "azure": [ + "defender_ensure_defender_cspm_is_on" + ], + "gcp": [], + "kubernetes": [], + "m365": [] + }, + "config_requirements": [ + { + "Check": "inspector2_coverage_recently_scanned", + "ConfigKey": "inspector2_max_days_since_last_scan", + "Operator": "lte", + "Value": 14, + "Provider": "aws" + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + } + ] + }, + { + "id": "VDR-TFR-PCD", + "name": "Persistently Complete Detection", + "description": "Providers with Class C Certifications SHOULD persistently perform vulnerability detection on all information resources that are NOT likely to drift, at least once every month.", + "attributes": { + "Ruleset": "VDR: Vulnerability Detection and Response", + "Subset": "TFR: Timeframes", + "Force": "SHOULD" + }, + "checks": { + "aws": [ + "inspector2_coverage_recently_scanned" + ], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + }, + "config_requirements": [ + { + "Check": "inspector2_coverage_recently_scanned", + "ConfigKey": "inspector2_max_days_since_last_scan", + "Operator": "lte", + "Value": 30, + "Provider": "aws" + } + ] + }, + { + "id": "VDR-TFR-PVR", + "name": "Mitigation and Remediation Expectations", + "description": "Providers with Class C Certifications SHOULD partially mitigate vulnerabilities, fully mitigate vulnerabilities, or remediate vulnerabilities to a lower Potential Agency Impact N-rating within the timeframes from evaluation shown below, factoring for the current Potential Agency Impact N-rating as defined in VER-EVA-EPA (Estimate Potential Agency Impact), internet reachability, and likely exploitability: N2 Internet-Reachable Vulnerability + Likely Exploitable Vulnerability: 48 days; N2 Not Internet-Reachable Vulnerability + Likely Exploitable Vulnerability: 128 days; N2 Not Likely Exploitable Vulnerability: 192 days; N3 Internet-Reachable Vulnerability + Likely Exploitable Vulnerability: 16 days; N3 Not Internet-Reachable Vulnerability + Likely Exploitable Vulnerability: 32 days; N3 Not Likely Exploitable Vulnerability: 128 days; N4 Internet-Reachable Vulnerability + Likely Exploitable Vulnerability: 4 days; N4 Not Internet-Reachable Vulnerability + Likely Exploitable Vulnerability: 8 days; N4 Not Likely Exploitable Vulnerability: 64 days; N5 Internet-Reachable Vulnerability + Likely Exploitable Vulnerability: 2 days; N5 Not Internet-Reachable Vulnerability + Likely Exploitable Vulnerability: 4 days; N5 Not Likely Exploitable Vulnerability: 16 days", + "attributes": { + "Ruleset": "VDR: Vulnerability Detection and Response", + "Subset": "TFR: Timeframes", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VDR-TFR-RMN", + "name": "Remaining Vulnerabilities", + "description": "Providers SHOULD mitigate or remediate remaining vulnerabilities during routine operations as determined necessary by the provider.", + "attributes": { + "Ruleset": "VDR: Vulnerability Detection and Response", + "Subset": "TFR: Timeframes", + "Force": "SHOULD" + }, + "checks": { + "aws": [ + "ssm_managed_compliant_patching" + ], + "azure": [ + "defender_ensure_system_updates_are_applied" + ], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VDR-TFR-KEV", + "name": "Remediate KEVs", + "description": "Providers SHOULD remediate Known Exploited Vulnerabilities according to the due dates in the CISA Known Exploited Vulnerabilities Catalog (even if the vulnerability has been fully mitigated) as required by CISA Binding Operational Directive (BOD) 26-04 or any successor guidance from CISA.", + "attributes": { + "Ruleset": "VDR: Vulnerability Detection and Response", + "Subset": "TFR: Timeframes", + "Force": "SHOULD" + }, + "checks": { + "aws": [ + "inspector2_active_findings_kev_within_due_date" + ], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VDR-TFR-PSD", + "name": "Persistent Sample Detection", + "description": "Providers with Class C Certifications SHOULD persistently perform vulnerability detection on representative samples of similar machine-based information resources, at least once every 3 days.", + "attributes": { + "Ruleset": "VDR: Vulnerability Detection and Response", + "Subset": "TFR: Timeframes", + "Force": "SHOULD" + }, + "checks": { + "aws": [ + "inspector2_coverage_recently_scanned" + ], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + }, + "config_requirements": [ + { + "Check": "inspector2_coverage_recently_scanned", + "ConfigKey": "inspector2_max_days_since_last_scan", + "Operator": "lte", + "Value": 3, + "Provider": "aws" + } + ] + }, + { + "id": "VDR-TFR-MVX", + "name": "Persistent Machine Verification and Validation for 20x", + "description": "Providers of FedRAMP 20x Class C offerings MUST verify and validate the status of machine-based information resources at least once every 3 days.", + "attributes": { + "Ruleset": "VDR: Vulnerability Detection and Response", + "Subset": "TFR: Timeframes", + "Force": "MUST" + }, + "checks": { + "aws": [ + "config_recorder_all_regions_enabled", + "inspector2_coverage_recently_scanned", + "securityhub_enabled" + ], + "azure": [ + "defender_ensure_defender_cspm_is_on" + ], + "gcp": [ + "iam_cloud_asset_inventory_enabled" + ], + "kubernetes": [], + "m365": [] + }, + "config_requirements": [ + { + "Check": "inspector2_coverage_recently_scanned", + "ConfigKey": "inspector2_max_days_since_last_scan", + "Operator": "lte", + "Value": 3, + "Provider": "aws" + }, + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + } + ] + }, + { + "id": "VER-EVA-ELX", + "name": "Evaluate Exploitability", + "description": "Providers MUST evaluate detected vulnerabilities, considering the context of the cloud service offering, to determine if they are likely exploitable vulnerabilities.", + "attributes": { + "Ruleset": "VER: Vulnerability Evaluation and Reporting", + "Subset": "EVA: Evaluation", + "Force": "MUST" + }, + "checks": { + "aws": [ + "inspector2_is_enabled" + ], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VER-EVA-EIR", + "name": "Evaluate Internet-Reachability", + "description": "Providers MUST evaluate detected vulnerabilities, considering the context of the cloud service offering, to determine if they are internet-reachable vulnerabilities.", + "attributes": { + "Ruleset": "VER: Vulnerability Evaluation and Reporting", + "Subset": "EVA: Evaluation", + "Force": "MUST" + }, + "checks": { + "aws": [ + "inspector2_is_enabled" + ], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VER-EVA-EPA", + "name": "Estimate Potential Agency Impact", + "description": "Providers MUST evaluate detected vulnerabilities, considering the context of the cloud service offering, to estimate the potential agency impact of exploitation on government customers AND assign one of the following Potential Agency Impact N-ratings (PAIN): N1: Exploitation could be expected to have minimal customer effects on one or more agencies that use the cloud service offering.; N2: Exploitation could be expected to have narrow customer effects on one or more agencies that use the cloud service offering.; N3: Exploitation could be expected to have a disruptive customer effect on one agency that uses the cloud service offering.; N4: Exploitation could be expected to have a debilitating customer effect on one agency that uses the cloud service offering OR a disruptive customer effect on more than one federal agency that uses the cloud service offering.; N5: Exploitation could be expected to have a debilitating customer effect on more than one agency that uses the cloud service offering.", + "attributes": { + "Ruleset": "VER: Vulnerability Evaluation and Reporting", + "Subset": "EVA: Evaluation", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VER-EVA-AIA", + "name": "Assume It's Automatable", + "description": "Providers MUST assume the exploitation of vulnerabilities can be automated UNLESS they have evidence proving otherwise.", + "attributes": { + "Ruleset": "VER: Vulnerability Evaluation and Reporting", + "Subset": "EVA: Evaluation", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VER-EVA-GRV", + "name": "Group Vulnerabilities", + "description": "Providers SHOULD evaluate detected vulnerabilities, considering the context of the cloud service offering, to identify logical groupings of affected information resources that may improve the efficiency and effectiveness of vulnerability response by consolidating further activity; FedRAMP Vulnerability Detection and Response rules are then applied to these consolidated groupings of vulnerabilities instead of each individual detected instance.", + "attributes": { + "Ruleset": "VER: Vulnerability Evaluation and Reporting", + "Subset": "EVA: Evaluation", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VER-EVA-EFP", + "name": "Evaluate False Positives", + "description": "Providers SHOULD evaluate detected vulnerabilities, considering the context of the cloud service offering, to determine if they are false positive vulnerabilities.", + "attributes": { + "Ruleset": "VER: Vulnerability Evaluation and Reporting", + "Subset": "EVA: Evaluation", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VER-EVA-EFA", + "name": "Evaluation Factors", + "description": "Providers SHOULD consider at least the following factors when considering the context of the cloud service offering to evaluate detected vulnerabilities: Criticality: How important are the systems or information that might be impacted by the vulnerability?; Reachability: How might a threat actor reach the vulnerability and how likely is that?; Exploitability: How easy is it for a threat actor to exploit the vulnerability and how likely is that?; Detectability: How easy is it for a threat actor to become aware of the vulnerability and how likely is that?; Prevalence: How much of the cloud service offering is affected by the vulnerability?; Privilege: How much privileged authority or access is granted or can be gained from exploiting the vulnerability?; Proximate Vulnerabilities: How does this vulnerability interact with previously detected vulnerabilities, especially partially or fully mitigated vulnerabilities?; Known Threats: How might already known threats leverage the vulnerability and how likely is that?", + "attributes": { + "Ruleset": "VER: Vulnerability Evaluation and Reporting", + "Subset": "EVA: Evaluation", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VER-RPT-PER", + "name": "Persistent Reporting", + "description": "Providers MUST report vulnerability detection and response activity (including persistent verification and validation) to all necessary parties persistently, summarizing ALL activity since the previous report; these reports are FedRAMP Certification Data and are subject to FedRAMP Certification Data Sharing rules.", + "attributes": { + "Ruleset": "VER: Vulnerability Evaluation and Reporting", + "Subset": "RPT: Reporting", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VER-RPT-VDT", + "name": "Vulnerability Details", + "description": "Providers MUST include the following information (if applicable) on detected vulnerabilities when reporting on vulnerability detection and response activity, UNLESS it is an accepted vulnerability: Provider's internally assigned tracking identifier; Time and source of the detection; Time of completed evaluation; Is it an internet-reachable vulnerability or not?; Is it a likely exploitable vulnerability or not?; Historically and currently estimated Potential Agency Impact N-rating of exploitation; Time and Potential Agency Impact N-rating of each completed and evaluated reduction in Potential Agency Impact N-rating; Estimated time and target Potential Agency Impact N-rating of next reduction in Potential Agency Impact N-rating; Is it currently or is it likely to become an overdue vulnerability or not? If so, explain.; Any supplementary information the provider responsibly determines will help federal agencies assess or mitigate the risk to their federal customer data within the cloud service offering resulting from the vulnerability; Final disposition of the vulnerability", + "attributes": { + "Ruleset": "VER: Vulnerability Evaluation and Reporting", + "Subset": "RPT: Reporting", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VER-RPT-AVI", + "name": "Accepted Vulnerability Info", + "description": "Providers MUST include the following information on accepted vulnerabilities when reporting on vulnerability detection and response activity: Provider's internally assigned tracking identifier; Time and source of the detection; Time of completed evaluation; Is it an internet-reachable vulnerability or not?; Is it a likely exploitable vulnerability or not?; Currently estimated Potential Agency Impact N-rating; Explanation of why this is an accepted vulnerability; Any supplementary information the provider determines will responsibly help federal agencies assess or mitigate the risk to their federal customer data within the cloud service offering resulting from the accepted vulnerability", + "attributes": { + "Ruleset": "VER: Vulnerability Evaluation and Reporting", + "Subset": "RPT: Reporting", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VER-RPT-NID", + "name": "Responsible Disclosure", + "description": "Providers MUST NOT irresponsibly disclose specific sensitive information about vulnerabilities that would likely lead to exploitation, but MUST disclose sufficient information for informed risk-based decision-making to all necessary parties.", + "attributes": { + "Ruleset": "VER: Vulnerability Evaluation and Reporting", + "Subset": "RPT: Reporting", + "Force": "MUST NOT" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VER-RPT-HLO", + "name": "High-Level Overviews", + "description": "Providers SHOULD include high-level overviews of ALL vulnerability detection and response activities conducted during this period for the cloud service offering; this includes vulnerability disclosure programs, bug bounty programs, penetration testing, assessments, etc.", + "attributes": { + "Ruleset": "VER: Vulnerability Evaluation and Reporting", + "Subset": "RPT: Reporting", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VER-RPT-RPD", + "name": "Responsible Public Disclosure", + "description": "Providers MAY responsibly disclose vulnerabilities publicly or with other parties if the provider determines doing so will NOT likely lead to exploitation.", + "attributes": { + "Ruleset": "VER: Vulnerability Evaluation and Reporting", + "Subset": "RPT: Reporting", + "Force": "MAY" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VER-TFR-MHR", + "name": "Monthly Activity Report", + "description": "Providers MUST report vulnerability detection and response activity to all necessary parties in a consistent format that is human readable at least monthly.", + "attributes": { + "Ruleset": "VER: Vulnerability Evaluation and Reporting", + "Subset": "TFR: Timeframes", + "Force": "MUST" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VER-TFR-MAV", + "name": "Mark Accepted Vulnerabilities", + "description": "Providers MUST categorize any vulnerability that is not or will not be fully mitigated or remediated within 192 days of evaluation as an accepted vulnerability.", + "attributes": { + "Ruleset": "VER: Vulnerability Evaluation and Reporting", + "Subset": "TFR: Timeframes", + "Force": "MUST" + }, + "checks": { + "aws": [ + "inspector2_active_findings_within_max_age" + ], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + }, + "config_requirements": [ + { + "Check": "inspector2_active_findings_within_max_age", + "ConfigKey": "inspector2_active_finding_max_age_days", + "Operator": "lte", + "Value": 192, + "Provider": "aws" + } + ] + }, + { + "id": "VER-TFR-MRH", + "name": "Historical Activity", + "description": "Providers with Class C Certifications SHOULD make all recent historical vulnerability detection and response activity available in JSON format for automated retrieval by all necessary parties (e.g. using an API service or similar); this information SHOULD be updated persistently, at least once every 14 days.", + "attributes": { + "Ruleset": "VER: Vulnerability Evaluation and Reporting", + "Subset": "TFR: Timeframes", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VER-TFR-EVU", + "name": "Evaluate Vulnerabilities Quickly", + "description": "Providers with Class C Certifications SHOULD evaluate ALL vulnerabilities as required by VER-EVA (Evaluation) within 5 days of detection.", + "attributes": { + "Ruleset": "VER: Vulnerability Evaluation and Reporting", + "Subset": "TFR: Timeframes", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VER-TFR-IRI", + "name": "Internet-Reachable Incidents", + "description": "Providers with Class C Certifications SHOULD treat internet-reachable likely exploitable vulnerabilities where Potential Agency Impact N-rating > 3 as a FedRAMP Reportable Incident until they are partially mitigated vulnerabilities at N3 or below.", + "attributes": { + "Ruleset": "VER: Vulnerability Evaluation and Reporting", + "Subset": "TFR: Timeframes", + "Force": "SHOULD" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "VER-TFR-NRI", + "name": "Non-Internet-Reachable Incidents", + "description": "Providers with Class C Certifications MAY treat likely exploitable vulnerabilities that are NOT internet-reachable where Potential Agency Impact N-rating = 5 as a FedRAMP Reportable Incident until they are partially mitigated vulnerabilities at N4 or below.", + "attributes": { + "Ruleset": "VER: Vulnerability Evaluation and Reporting", + "Subset": "TFR: Timeframes", + "Force": "MAY" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + } + ] +} diff --git a/prowler/compliance/fedramp_20x_ksi_2026.json b/prowler/compliance/fedramp_20x_ksi_2026.json new file mode 100644 index 0000000000..b893675faf --- /dev/null +++ b/prowler/compliance/fedramp_20x_ksi_2026.json @@ -0,0 +1,2284 @@ +{ + "framework": "FedRAMP-20x-KSI", + "name": "FedRAMP 20x Key Security Indicators (KSI) 2026", + "version": "2026.07.14.01", + "description": "FedRAMP 20x Key Security Indicators (KSIs) from the FedRAMP Consolidated Rules for 2026 (release 2026.07.14.01, https://github.com/FedRAMP/rules). KSIs are outcome-oriented indicators that cloud service providers must demonstrate through automation and continuous monitoring; they do not replace the FedRAMP Rules (FRR) program obligations. Class A authorizations mandate a subset of seven KSIs via FRC-CLA-MFR; Classes B and C apply the full catalog within the Minimum Assessment Scope, with five indicators optional on Class B and required on Class C.", + "icon": "fedramp", + "attributes_metadata": [ + { + "key": "Theme", + "label": "Theme", + "type": "str", + "required": true, + "enum": [ + "KSI-CED: Cybersecurity Education", + "KSI-CMT: Change Management", + "KSI-CNA: Cloud Native Architecture", + "KSI-IAM: Identity and Access Management", + "KSI-INR: Incident Response", + "KSI-MLA: Monitoring, Logging, and Auditing", + "KSI-PIY: Policy and Inventory", + "KSI-RPL: Recovery Planning", + "KSI-SCR: Supply Chain Risk", + "KSI-SVC: Service Configuration" + ] + }, + { + "key": "NISTControls", + "label": "NIST SP 800-53 Controls", + "type": "str" + }, + { + "key": "ClassApplicability", + "label": "Class Applicability", + "type": "str", + "required": true, + "enum": [ + "Required for Classes B and C", + "Optional for Class B, required for Class C" + ] + } + ], + "outputs": { + "table_config": { + "group_by": "Theme" + }, + "pdf_config": { + "language": "en", + "primary_color": "#1B3A5C", + "secondary_color": "#2E6DA4", + "bg_color": "#F0F4FA", + "group_by_field": "Theme", + "sections": [ + "KSI-CED: Cybersecurity Education", + "KSI-CMT: Change Management", + "KSI-CNA: Cloud Native Architecture", + "KSI-IAM: Identity and Access Management", + "KSI-INR: Incident Response", + "KSI-MLA: Monitoring, Logging, and Auditing", + "KSI-PIY: Policy and Inventory", + "KSI-RPL: Recovery Planning", + "KSI-SCR: Supply Chain Risk", + "KSI-SVC: Service Configuration" + ], + "section_short_names": { + "KSI-CED: Cybersecurity Education": "KSI-CED", + "KSI-CMT: Change Management": "KSI-CMT", + "KSI-CNA: Cloud Native Architecture": "KSI-CNA", + "KSI-IAM: Identity and Access Management": "KSI-IAM", + "KSI-INR: Incident Response": "KSI-INR", + "KSI-MLA: Monitoring, Logging, and Auditing": "KSI-MLA", + "KSI-PIY: Policy and Inventory": "KSI-PIY", + "KSI-RPL: Recovery Planning": "KSI-RPL", + "KSI-SCR: Supply Chain Risk": "KSI-SCR", + "KSI-SVC: Service Configuration": "KSI-SVC" + }, + "charts": [ + { + "id": "theme_compliance", + "type": "horizontal_bar", + "group_by": "Theme", + "title": "Compliance Score by KSI Theme", + "y_label": "Theme", + "x_label": "Compliance %", + "value_source": "compliance_percent", + "color_mode": "by_value" + } + ], + "filter": { + "only_failed": true, + "include_manual": false + } + } + }, + "requirements": [ + { + "id": "KSI-CED-RAT", + "name": "Reviewing All Training", + "description": "The effectiveness of relevant cybersecurity education and training is persistently reviewed, including at least general training for all employees, role-specific training for employees in high risk roles, training for development and engineering staff on secure software delivery, and training for staff involved with incident response or disaster recovery.", + "attributes": { + "Theme": "KSI-CED: Cybersecurity Education", + "NISTControls": "CP-3, IR-2, PS-6, AT-2, AT-2.2, AT-2.3, AT-3.5, AT-4, IR-2.3, AT-3, SR-11.1", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "KSI-CMT-LMC", + "name": "Logging Changes", + "description": "Modifications to the cloud service offering are logged and monitored.", + "attributes": { + "Theme": "KSI-CMT: Change Management", + "NISTControls": "AU-2, CM-3, CM-3.2, CM-4.2, CM-6, CM-8.3, MA-2", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "cloudtrail_cloudwatch_logging_enabled", + "cloudtrail_multi_region_enabled", + "cloudtrail_multi_region_enabled_logging_management_events", + "cloudtrail_s3_dataevents_write_enabled", + "cloudwatch_changes_to_network_acls_alarm_configured", + "cloudwatch_changes_to_network_gateways_alarm_configured", + "cloudwatch_changes_to_network_route_tables_alarm_configured", + "cloudwatch_changes_to_vpcs_alarm_configured", + "cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled", + "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", + "cloudwatch_log_metric_filter_aws_organizations_changes", + "cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk", + "cloudwatch_log_metric_filter_for_s3_bucket_policy_changes", + "cloudwatch_log_metric_filter_policy_changes", + "cloudwatch_log_metric_filter_security_group_changes", + "config_recorder_all_regions_enabled" + ], + "azure": [ + "monitor_alert_create_policy_assignment", + "monitor_alert_create_update_nsg", + "monitor_alert_create_update_public_ip_address_rule", + "monitor_alert_create_update_security_solution", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_nsg", + "monitor_alert_delete_policy_assignment", + "monitor_alert_delete_public_ip_address_rule", + "monitor_alert_delete_security_solution", + "monitor_alert_delete_sqlserver_fr", + "monitor_diagnostic_setting_with_appropriate_categories", + "monitor_diagnostic_settings_exists" + ], + "gcp": [ + "iam_audit_logs_enabled", + "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", + "logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", + "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", + "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled" + ], + "kubernetes": [ + "apiserver_audit_log_path_set" + ], + "m365": [ + "exchange_organization_mailbox_auditing_enabled", + "exchange_user_mailbox_auditing_enabled", + "purview_audit_log_search_enabled" + ] + }, + "config_requirements": [ + { + "Check": "exchange_user_mailbox_auditing_enabled", + "ConfigKey": "audit_log_age", + "Operator": "gte", + "Value": 90, + "Provider": "m365" + }, + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + } + ] + }, + { + "id": "KSI-CMT-RMV", + "name": "Redeploying vs Modifying", + "description": "Changes to machine-based information resources are executed through the redeployment of version controlled resources rather than direct modification wherever reasonable.", + "attributes": { + "Theme": "KSI-CMT: Change Management", + "NISTControls": "CM-2, CM-3, CM-5, CM-6, CM-7, CM-8.1, SI-3", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "autoscaling_group_using_ec2_launch_template", + "ecs_task_definitions_containers_readonly_access" + ], + "azure": [], + "gcp": [], + "kubernetes": [ + "apiserver_always_pull_images_plugin", + "core_image_tag_fixed", + "core_readonly_root_filesystem_enabled" + ], + "m365": [] + } + }, + { + "id": "KSI-CMT-RVP", + "name": "Reviewing Change Procedures", + "description": "The effectiveness of documented change management procedures is persistently reviewed.", + "attributes": { + "Theme": "KSI-CMT: Change Management", + "NISTControls": "CM-3, CM-3.2, CM-3.4, CM-5, CM-7.1, CM-9", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "KSI-CMT-VTD", + "name": "Validating Throughout Deployment", + "description": "Persistent testing and validation of changes throughout deployment is automated.", + "attributes": { + "Theme": "KSI-CMT: Change Management", + "NISTControls": "CM-3, CM-3.2, CM-4.2, SI-2", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "KSI-CNA-DFP", + "name": "Defining Functionality and Privileges", + "description": "The functionality and privileges for infrastructure and services are strictly defined.", + "attributes": { + "Theme": "KSI-CNA: Cloud Native Architecture", + "NISTControls": "CM-2, SI-3", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "ec2_instance_imdsv2_enabled", + "ecs_task_definitions_host_namespace_not_shared", + "ecs_task_definitions_host_networking_mode_users", + "ecs_task_definitions_no_privileged_containers", + "organizations_scp_check_deny_regions", + "sagemaker_notebook_instance_root_access_disabled" + ], + "azure": [], + "gcp": [ + "cloudstorage_bucket_uniform_bucket_level_access" + ], + "kubernetes": [ + "apiserver_auth_mode_include_node", + "apiserver_auth_mode_include_rbac", + "apiserver_auth_mode_not_always_allow", + "apiserver_namespace_lifecycle_plugin", + "apiserver_node_restriction_plugin", + "apiserver_security_context_deny_plugin", + "apiserver_service_account_plugin" + ], + "m365": [ + "entra_admin_portals_access_restriction", + "entra_all_apps_conditional_access_coverage", + "entra_conditional_access_policy_app_enforced_restrictions", + "entra_conditional_access_policy_approved_client_app_required_for_mobile", + "entra_conditional_access_policy_device_code_flow_blocked", + "entra_managed_device_required_for_authentication" + ] + } + }, + { + "id": "KSI-CNA-EIS", + "name": "Enforcing Intended State", + "description": "Automated services are used to persistently assess the security of all machine-based information resources and automatically enforce their intended operational state.", + "attributes": { + "Theme": "KSI-CNA: Cloud Native Architecture", + "NISTControls": "CA-2.1, CA-7.1", + "ClassApplicability": "Optional for Class B, required for Class C" + }, + "checks": { + "aws": [ + "config_delegated_admin_and_org_aggregator_all_regions", + "config_recorder_all_regions_enabled", + "securityhub_enabled", + "ssm_managed_compliant_patching" + ], + "azure": [ + "defender_ensure_defender_cspm_is_on", + "policy_ensure_asc_enforcement_enabled" + ], + "gcp": [], + "kubernetes": [], + "m365": [] + }, + "config_requirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + }, + { + "Check": "securityhub_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + }, + { + "Check": "config_delegated_admin_and_org_aggregator_all_regions", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + } + ] + }, + { + "id": "KSI-CNA-IBP", + "name": "Implementing Best Practices", + "description": "The use and configuration of third-party machine-based information resources is persistently compared against the original provider's best practices and guidance.", + "attributes": { + "Theme": "KSI-CNA: Cloud Native Architecture", + "NISTControls": "AC-17.3, CM-2, PL-10", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "awslambda_function_using_supported_runtimes", + "ec2_instance_with_outdated_ami", + "ecs_service_fargate_latest_platform_version", + "eks_cluster_uses_a_supported_version", + "kafka_cluster_uses_latest_version", + "opensearch_service_domains_updated_to_the_latest_service_software_version", + "rds_instance_deprecated_engine_version", + "wellarchitected_workload_no_high_or_medium_risks" + ], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [ + "defender_strict_preset_security_policy_enabled" + ] + }, + "config_requirements": [ + { + "Check": "awslambda_function_using_supported_runtimes", + "ConfigKey": "obsolete_lambda_runtimes", + "Operator": "superset", + "Value": [ + "java8", + "go1.x", + "provided", + "python3.6", + "python2.7", + "python3.7", + "python3.8", + "nodejs4.3", + "nodejs4.3-edge", + "nodejs6.10", + "nodejs", + "nodejs8.10", + "nodejs10.x", + "nodejs12.x", + "nodejs14.x", + "nodejs16.x", + "dotnet5.0", + "dotnet6", + "dotnet7", + "dotnetcore1.0", + "dotnetcore2.0", + "dotnetcore2.1", + "dotnetcore3.1", + "ruby2.5", + "ruby2.7" + ], + "Provider": "aws" + } + ] + }, + { + "id": "KSI-CNA-MAT", + "name": "Minimizing Attack Surface", + "description": "Machine-based information resources are persistently reviewed to ensure they have a minimal attack surface and that lateral movement is minimized if compromised.", + "attributes": { + "Theme": "KSI-CNA: Cloud Native Architecture", + "NISTControls": "AC-17.3, AC-18.1, AC-18.3, AC-20.1, CA-9, SC-7.3, SC-7.4, SC-7.5, SC-7.8, SC-8, SC-10, SI-10, SI-11, SI-16", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "apigateway_restapi_public", + "appstream_fleet_default_internet_access_disabled", + "autoscaling_group_launch_configuration_no_public_ip", + "awslambda_function_not_publicly_accessible", + "awslambda_function_url_public", + "codebuild_project_not_publicly_accessible", + "dms_instance_no_public_access", + "ec2_instance_internet_facing_with_instance_profile", + "ec2_instance_public_ip", + "ec2_instance_uses_single_eni", + "ec2_launch_template_no_public_ip", + "ecs_service_no_assign_public_ip", + "ecs_task_set_no_assign_public_ip", + "efs_mount_target_not_publicly_accessible", + "eks_cluster_not_publicly_accessible", + "eks_cluster_private_nodes_enabled", + "elasticache_cluster_uses_public_subnet", + "elb_internet_facing", + "elbv2_internet_facing", + "emr_cluster_account_public_block_enabled", + "emr_cluster_master_nodes_no_public_ip", + "emr_cluster_publicly_accesible", + "kafka_cluster_is_public", + "lightsail_database_public", + "lightsail_instance_public", + "mq_broker_not_publicly_accessible", + "neptune_cluster_uses_public_subnet", + "opensearch_service_domains_not_publicly_accessible", + "rds_instance_no_public_access", + "redshift_cluster_public_access", + "sagemaker_models_network_isolation_enabled", + "sagemaker_notebook_instance_without_direct_internet_access_configured", + "sagemaker_training_jobs_network_isolation_enabled", + "vpc_peering_routing_tables_with_least_privilege", + "vpc_subnet_no_public_ip_by_default" + ], + "azure": [ + "aisearch_service_not_publicly_accessible", + "aks_clusters_created_with_private_nodes", + "aks_clusters_public_access_disabled", + "app_function_ftps_deployment_disabled", + "app_function_not_publicly_accessible", + "containerregistry_not_publicly_accessible", + "cosmosdb_account_public_network_access_disabled", + "databricks_workspace_no_public_ip_enabled", + "databricks_workspace_public_network_access_disabled", + "postgresql_flexible_server_allow_access_services_disabled", + "sqlserver_unrestricted_inbound_access", + "storage_account_public_network_access_disabled", + "storage_default_network_access_rule_is_denied" + ], + "gcp": [ + "cloudfunction_function_not_publicly_accessible", + "cloudsql_instance_private_ip_assignment", + "cloudsql_instance_public_access", + "cloudsql_instance_public_ip", + "cloudsql_instance_sqlserver_cross_db_ownership_chaining_flag", + "cloudsql_instance_sqlserver_external_scripts_enabled_flag", + "cloudsql_instance_sqlserver_remote_access_flag", + "compute_instance_block_project_wide_ssh_keys_disabled", + "compute_instance_ip_forwarding_is_enabled", + "compute_instance_public_ip", + "compute_instance_single_network_interface" + ], + "kubernetes": [ + "apiserver_anonymous_requests", + "apiserver_disable_profiling", + "apiserver_no_always_admit_plugin", + "controllermanager_disable_profiling", + "core_minimize_admission_windows_hostprocess_containers", + "core_minimize_allowPrivilegeEscalation_containers", + "core_minimize_containers_added_capabilities", + "core_minimize_containers_capabilities_assigned", + "core_minimize_hostpath_volume_mounts", + "core_minimize_net_raw_capability_admission", + "core_minimize_privileged_containers", + "core_minimize_root_containers_admission", + "kubelet_disable_read_only_port", + "scheduler_profiling" + ], + "m365": [ + "entra_device_registration_laps_enabled", + "exchange_roles_assignment_policy_addins_disabled", + "sharepoint_onedrive_sync_restricted_unmanaged_devices", + "teams_email_sending_to_channel_disabled", + "teams_external_file_sharing_restricted", + "teams_external_users_cannot_start_conversations", + "teams_meeting_anonymous_user_join_disabled", + "teams_meeting_anonymous_user_start_disabled", + "teams_meeting_chat_anonymous_users_disabled", + "teams_meeting_dial_in_lobby_bypass_disabled", + "teams_meeting_external_chat_disabled", + "teams_meeting_external_control_disabled", + "teams_meeting_external_lobby_bypass_disabled", + "teams_meeting_presenters_restricted", + "teams_meeting_recording_disabled", + "teams_unmanaged_communication_disabled" + ] + } + }, + { + "id": "KSI-CNA-OFA", + "name": "Optimizing for Availability", + "description": "Machine-based information resources are persistently reviewed to ensure they are appropriately optimized for high availability and rapid recovery.", + "attributes": { + "Theme": "KSI-CNA: Cloud Native Architecture", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "autoscaling_group_capacity_rebalance_enabled", + "autoscaling_group_elb_health_check_enabled", + "autoscaling_group_multiple_az", + "autoscaling_group_multiple_instance_types", + "awslambda_function_vpc_multi_az", + "cloudfront_distributions_multiple_origin_failover_configured", + "directconnect_connection_redundancy", + "directconnect_virtual_interface_redundancy", + "dms_instance_multi_az_enabled", + "documentdb_cluster_multi_az_enabled", + "dynamodb_accelerator_cluster_multi_az", + "dynamodb_table_autoscaling_enabled", + "dynamodb_table_deletion_protection_enabled", + "dynamodb_tables_pitr_enabled", + "efs_multi_az_enabled", + "elasticache_redis_cluster_automatic_failover_enabled", + "elasticache_redis_cluster_multi_az_enabled", + "elb_cross_zone_load_balancing_enabled", + "elb_is_in_multiple_az", + "elbv2_cross_zone_load_balancing_enabled", + "elbv2_is_in_multiple_az", + "eventbridge_global_endpoint_event_replication_enabled", + "fsx_windows_file_system_multi_az_enabled", + "mq_broker_active_deployment_mode", + "mq_broker_cluster_deployment_mode", + "neptune_cluster_multi_az", + "networkfirewall_multi_az", + "opensearch_service_domains_fault_tolerant_data_nodes", + "opensearch_service_domains_fault_tolerant_master_nodes", + "rds_cluster_multi_az", + "rds_instance_multi_az", + "redshift_cluster_multi_az_enabled", + "sagemaker_endpoint_config_prod_variant_instances", + "storagegateway_gateway_fault_tolerant", + "vpc_endpoint_multi_az_enabled", + "vpc_subnet_different_az", + "vpc_vpn_connection_tunnels_up" + ], + "azure": [ + "cosmosdb_account_automatic_failover_enabled", + "mysql_flexible_server_high_availability_enabled", + "postgresql_flexible_server_high_availability_enabled", + "vm_backup_enabled", + "vm_scaleset_associated_with_load_balancer" + ], + "gcp": [ + "cloudsql_instance_high_availability_enabled", + "compute_instance_automatic_restart_enabled", + "compute_instance_group_autohealing_enabled", + "compute_instance_group_load_balancer_attached", + "compute_instance_group_multiple_zones", + "compute_instance_on_host_maintenance_migrate", + "compute_instance_preemptible_vm_disabled" + ], + "kubernetes": [ + "core_liveness_probe_configured", + "core_readiness_probe_configured" + ], + "m365": [] + }, + "config_requirements": [ + { + "Check": "awslambda_function_vpc_multi_az", + "ConfigKey": "lambda_min_azs", + "Operator": "gte", + "Value": 2, + "Provider": "aws" + }, + { + "Check": "elb_is_in_multiple_az", + "ConfigKey": "elb_min_azs", + "Operator": "gte", + "Value": 2, + "Provider": "aws" + }, + { + "Check": "compute_instance_group_multiple_zones", + "ConfigKey": "mig_min_zones", + "Operator": "gte", + "Value": 2, + "Provider": "gcp" + } + ] + }, + { + "id": "KSI-CNA-RNT", + "name": "Restricting Network Traffic", + "description": "Machine-based information resources are persistently reviewed to ensure they are appropriately configured to limit inbound and outbound network traffic.", + "attributes": { + "Theme": "KSI-CNA: Cloud Native Architecture", + "NISTControls": "AC-17.3, CA-9, CM-7.1, SC-7.5, SI-8", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "ec2_networkacl_allow_ingress_any_port", + "ec2_networkacl_allow_ingress_tcp_port_22", + "ec2_networkacl_allow_ingress_tcp_port_3389", + "ec2_securitygroup_allow_ingress_from_internet_to_all_ports", + "ec2_securitygroup_allow_ingress_from_internet_to_any_port", + "ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip", + "ec2_securitygroup_allow_ingress_from_internet_to_high_risk_tcp_ports", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_3389", + "ec2_securitygroup_allow_wide_open_public_ipv4", + "ec2_securitygroup_default_restrict_traffic" + ], + "azure": [ + "network_http_internet_access_restricted", + "network_rdp_internet_access_restricted", + "network_ssh_internet_access_restricted", + "network_udp_internet_access_restricted" + ], + "gcp": [ + "compute_firewall_rdp_access_from_the_internet_allowed", + "compute_firewall_ssh_access_from_the_internet_allowed", + "compute_network_default_in_use" + ], + "kubernetes": [ + "apiserver_deny_service_external_ips", + "controllermanager_bind_address", + "core_minimize_admission_hostport_containers", + "core_minimize_hostNetwork_containers", + "scheduler_bind_address" + ], + "m365": [] + }, + "config_requirements": [ + { + "Check": "ec2_securitygroup_allow_ingress_from_internet_to_any_port", + "ConfigKey": "ec2_allowed_interface_types", + "Operator": "subset", + "Value": [ + "api_gateway_managed", + "vpc_endpoint" + ], + "Provider": "aws" + }, + { + "Check": "ec2_securitygroup_allow_ingress_from_internet_to_any_port", + "ConfigKey": "ec2_allowed_instance_owners", + "Operator": "subset", + "Value": [ + "amazon-elb" + ], + "Provider": "aws" + }, + { + "Check": "ec2_securitygroup_allow_ingress_from_internet_to_high_risk_tcp_ports", + "ConfigKey": "ec2_high_risk_ports", + "Operator": "superset", + "Value": [ + 25, + 110, + 135, + 143, + 445, + 3000, + 4333, + 5000, + 5500, + 8080, + 8088 + ], + "Provider": "aws" + } + ] + }, + { + "id": "KSI-CNA-RVP", + "name": "Reviewing Protections", + "description": "The effectiveness of protection against denial of service attacks and other unwanted activity for machine-based information resources is persistently reviewed.", + "attributes": { + "Theme": "KSI-CNA: Cloud Native Architecture", + "NISTControls": "SC-5, SI-8, SI-8.2", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "apigateway_restapi_waf_acl_attached", + "cloudfront_distributions_using_waf", + "cognito_user_pool_waf_acl_attached", + "elb_desync_mitigation_mode", + "elbv2_desync_mitigation_mode", + "elbv2_waf_acl_attached", + "fms_policy_compliant", + "shield_advanced_protection_in_associated_elastic_ips", + "shield_advanced_protection_in_classic_load_balancers", + "shield_advanced_protection_in_cloudfront_distributions", + "shield_advanced_protection_in_global_accelerators", + "shield_advanced_protection_in_internet_facing_load_balancers", + "shield_advanced_protection_in_route53_hosted_zones", + "waf_global_rule_with_conditions", + "waf_global_rulegroup_not_empty", + "waf_global_webacl_with_rules", + "waf_regional_rule_with_conditions", + "waf_regional_rulegroup_not_empty", + "waf_regional_webacl_with_rules", + "wafv2_webacl_with_rules" + ], + "azure": [ + "network_vnet_ddos_protection_enabled", + "postgresql_flexible_server_connection_throttling_on" + ], + "gcp": [], + "kubernetes": [ + "apiserver_event_rate_limit", + "apiserver_request_timeout_set", + "core_cpu_limits_set", + "core_memory_limits_set", + "kubelet_streaming_connection_timeout" + ], + "m365": [] + } + }, + { + "id": "KSI-CNA-ULN", + "name": "Using Logical Networking", + "description": "Logical networking and related capabilities are used and persistently reviewed to enforce traffic flow controls.", + "attributes": { + "Theme": "KSI-CNA: Cloud Native Architecture", + "NISTControls": "AC-12, AC-17.3, CA-9, SC-4, SC-7, SC-7.7, SC-8, SC-10", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "eks_cluster_network_policy_enabled", + "eks_cluster_vpc_cni_network_policy_enforced", + "networkfirewall_in_all_vpc", + "networkfirewall_policy_default_action_fragmented_packets", + "networkfirewall_policy_default_action_full_packets", + "networkfirewall_policy_rule_group_associated", + "vpc_endpoint_connections_trust_boundaries", + "vpc_endpoint_services_allowed_principals_trust_boundaries", + "vpc_subnet_separate_private_public" + ], + "azure": [ + "aks_network_policy_enabled", + "network_bastion_host_exists", + "network_subnet_nsg_associated" + ], + "gcp": [ + "cloudfunction_function_inside_vpc", + "cloudstorage_uses_vpc_service_controls" + ], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "KSI-IAM-AAM", + "name": "Automating Account Management", + "description": "The lifecycle and privileges of all accounts, roles, and groups are securely managed using automation.", + "attributes": { + "Theme": "KSI-IAM: Identity and Access Management", + "NISTControls": "AC-2.2, AC-2.3, AC-2.13, AC-6.7, IA-4.4, IA-12, IA-12.2, IA-12.3, IA-12.5", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "iam_no_root_access_key", + "iam_root_credentials_management_enabled", + "iam_user_accesskey_unused", + "iam_user_console_access_unused", + "iam_user_no_setup_initial_access_key", + "organizations_delegated_administrators" + ], + "azure": [ + "entra_user_with_recent_sign_in" + ], + "gcp": [ + "iam_service_account_unused" + ], + "kubernetes": [], + "m365": [ + "entra_dynamic_group_for_guests_created" + ] + }, + "config_requirements": [ + { + "Check": "iam_user_accesskey_unused", + "ConfigKey": "max_unused_access_keys_days", + "Operator": "lte", + "Value": 45, + "Provider": "aws" + }, + { + "Check": "iam_user_console_access_unused", + "ConfigKey": "max_console_access_days", + "Operator": "lte", + "Value": 45, + "Provider": "aws" + }, + { + "Check": "iam_service_account_unused", + "ConfigKey": "max_unused_account_days", + "Operator": "lte", + "Value": 180, + "Provider": "gcp" + } + ] + }, + { + "id": "KSI-IAM-APM", + "name": "Adopting Passwordless Methods", + "description": "Secure passwordless methods are used for user authentication and authorization when feasible, otherwise strong passwords with phishing-resistant MFA is used.", + "attributes": { + "Theme": "KSI-IAM: Identity and Access Management", + "NISTControls": "AC-3, IA-5.1, IA-5.2, IA-5.6, IA-6, AC-2, IA-2, IA-2.1, IA-2.2, IA-2.8, IA-5, IA-8, SC-23", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "cognito_user_pool_password_policy_lowercase", + "cognito_user_pool_password_policy_minimum_length_14", + "cognito_user_pool_password_policy_number", + "cognito_user_pool_password_policy_symbol", + "cognito_user_pool_password_policy_uppercase", + "iam_password_policy_lowercase", + "iam_password_policy_minimum_length_14", + "iam_password_policy_number", + "iam_password_policy_reuse_24", + "iam_password_policy_symbol", + "iam_password_policy_uppercase", + "iam_root_hardware_mfa_enabled", + "iam_user_hardware_mfa_enabled" + ], + "azure": [ + "vm_linux_enforce_ssh_authentication" + ], + "gcp": [], + "kubernetes": [], + "m365": [ + "entra_admin_users_phishing_resistant_mfa_enabled", + "entra_break_glass_account_fido2_security_key_registered", + "entra_password_protection_custom_banned_list_enforced", + "entra_password_protection_on_premises_enforced" + ] + } + }, + { + "id": "KSI-IAM-ELP", + "name": "Ensuring Least Privilege", + "description": "Identity and access management measures are used and persistently reviewed to ensure each user or device can only access the resources they need.", + "attributes": { + "Theme": "KSI-IAM: Identity and Access Management", + "NISTControls": "AC-2.5, AC-2.6, AC-3, AC-4, AC-6, AC-12, AC-14, AC-17, AC-17.1, AC-17.2, AC-17.3, AC-20, AC-20.1, CM-2.7, CM-9, IA-2, IA-3, IA-4, IA-4.4, IA-5.2, IA-5.6, IA-11, PS-2, PS-3, PS-4, PS-5, PS-6, SC-4, SC-20, SC-21, SC-22, SC-23, SC-39, SI-3", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "accessanalyzer_enabled", + "accessanalyzer_enabled_without_findings", + "bedrock_agent_role_least_privilege", + "bedrock_agent_role_not_shared_across_agents", + "efs_access_point_enforce_root_directory", + "efs_access_point_enforce_user_identity", + "iam_role_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_bedrock", + "iam_user_access_not_stale_to_sagemaker", + "opensearch_service_domains_access_control_enabled" + ], + "azure": [ + "aks_cluster_rbac_enabled", + "entra_policy_default_users_cannot_create_security_groups", + "entra_policy_ensure_default_user_cannot_create_apps", + "entra_policy_ensure_default_user_cannot_create_tenants", + "entra_policy_guest_invite_only_for_admin_roles", + "entra_policy_guest_users_access_restrictions", + "entra_policy_restricts_user_consent_for_apps", + "entra_policy_user_consent_for_verified_apps", + "entra_users_cannot_create_microsoft_365_groups", + "keyvault_rbac_enabled" + ], + "gcp": [ + "compute_instance_default_service_account_in_use", + "compute_instance_default_service_account_in_use_with_full_api_access" + ], + "kubernetes": [ + "kubelet_authorization_mode", + "rbac_cluster_admin_usage", + "rbac_minimize_csr_approval_access", + "rbac_minimize_node_proxy_subresource_access", + "rbac_minimize_pod_creation_access", + "rbac_minimize_pv_creation_access", + "rbac_minimize_secret_access", + "rbac_minimize_service_account_token_creation", + "rbac_minimize_webhook_config_access", + "rbac_minimize_wildcard_use_roles" + ], + "m365": [ + "admincenter_users_admins_reduced_license_footprint", + "admincenter_users_between_two_and_four_global_admins", + "entra_access_review_guest_users_configured", + "entra_access_review_privileged_roles_configured", + "entra_admin_users_cloud_only", + "entra_conditional_access_policy_groups_management_restricted", + "entra_device_registration_global_admins_not_local_admins", + "entra_device_registration_registering_user_not_local_admin", + "entra_policy_default_user_cannot_create_m365_groups", + "entra_policy_default_user_cannot_create_security_groups", + "entra_policy_guest_invite_only_for_admin_roles", + "entra_policy_guest_users_access_restrictions" + ] + }, + "config_requirements": [ + { + "Check": "iam_user_access_not_stale_to_bedrock", + "ConfigKey": "max_unused_bedrock_access_days", + "Operator": "lte", + "Value": 60, + "Provider": "aws" + }, + { + "Check": "iam_role_access_not_stale_to_bedrock", + "ConfigKey": "max_unused_bedrock_access_days", + "Operator": "lte", + "Value": 60, + "Provider": "aws" + }, + { + "Check": "iam_user_access_not_stale_to_sagemaker", + "ConfigKey": "max_unused_sagemaker_access_days", + "Operator": "lte", + "Value": 90, + "Provider": "aws" + }, + { + "Check": "accessanalyzer_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + } + ] + }, + { + "id": "KSI-IAM-JIT", + "name": "Authorizing Just-in-Time", + "description": "A least-privileged, role and attribute-based, and just-in-time security authorization model is used and persistently reviewed for all user and non-user accounts and services.", + "attributes": { + "Theme": "KSI-IAM: Identity and Access Management", + "NISTControls": "AC-2, AC-2.1, AC-2.2, AC-2.3, AC-2.4, AC-2.6, AC-3, AC-4, AC-5, AC-6, AC-6.1, AC-6.2, AC-6.5, AC-6.7, AC-6.9, AC-6.10, AC-7, AC-20.1, AC-17, AU-9.4, CM-5, CM-7, CM-7.2, CM-7.5, CM-9, IA-4, IA-4.4, IA-7, PS-2, PS-3, PS-4, PS-5, PS-6, PS-9, RA-5.5, SC-2, SC-23, SC-39", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "bedrock_api_key_no_administrative_privileges", + "bedrock_full_access_policy_attached", + "iam_aws_attached_policy_no_administrative_privileges", + "iam_customer_attached_policy_no_administrative_privileges", + "iam_customer_unattached_policy_no_administrative_privileges", + "iam_group_administrator_access_policy", + "iam_inline_policy_allows_privilege_escalation", + "iam_inline_policy_no_administrative_privileges", + "iam_inline_policy_no_wildcard_marketplace_subscribe", + "iam_no_custom_policy_permissive_role_assumption", + "iam_policy_allows_privilege_escalation", + "iam_policy_attached_only_to_group_or_roles", + "iam_policy_cloudshell_admin_not_attached", + "iam_policy_no_agentcore_workload_access_token_wildcard", + "iam_policy_no_full_access_to_cloudtrail", + "iam_policy_no_full_access_to_kms", + "iam_policy_no_wildcard_marketplace_subscribe", + "iam_policy_passrole_to_bedrock_agentcore_restricted", + "iam_role_administratoraccess_policy", + "iam_role_cross_service_confused_deputy_prevention", + "iam_role_service_trust_restricts_source_to_account", + "iam_user_administrator_access_policy", + "iam_user_with_temporary_credentials", + "rolesanywhere_profile_restricts_session_permissions" + ], + "azure": [ + "app_function_identity_without_admin_privileges", + "entra_global_admin_in_less_than_five_users", + "iam_custom_role_has_permissions_to_administer_resource_locks", + "iam_role_user_access_admin_restricted", + "iam_subscription_roles_owner_custom_not_created", + "vm_jit_access_enabled" + ], + "gcp": [ + "iam_no_service_roles_at_project_level", + "iam_role_kms_enforce_separation_of_duties", + "iam_role_sa_enforce_separation_of_duties", + "iam_sa_no_administrative_privileges" + ], + "kubernetes": [], + "m365": [ + "entra_admin_users_sign_in_frequency_enabled", + "entra_intune_enrollment_sign_in_frequency_every_time", + "entra_pim_global_administrator_approval_required", + "entra_pim_privileged_role_administrator_approval_required", + "entra_service_principal_privileged_role_no_owners" + ] + } + }, + { + "id": "KSI-IAM-SNU", + "name": "Securing Non-User Authentication", + "description": "Appropriately secure authentication methods are used and persistently reviewed for non-user accounts and services.", + "attributes": { + "Theme": "KSI-IAM: Identity and Access Management", + "NISTControls": "AC-2, AC-2.2, AC-4, AC-6.5, IA-3, IA-5.2, RA-5.5", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "apigateway_restapi_authorizers_enabled", + "apigatewayv2_api_authorizers_enabled", + "appsync_graphql_api_no_api_key_authentication", + "bedrock_api_key_no_long_term_credentials", + "dms_endpoint_neptune_iam_authorization_enabled", + "ec2_instance_profile_attached", + "elasticache_redis_replication_group_auth_enabled", + "iam_rotate_access_key_90_days", + "iam_user_two_active_access_key", + "kafka_cluster_unrestricted_access_disabled", + "neptune_cluster_iam_authentication_enabled", + "rds_cluster_iam_authentication_enabled", + "rds_instance_iam_authentication_enabled" + ], + "azure": [ + "aks_cluster_local_accounts_disabled", + "app_function_identity_is_configured", + "app_register_with_identity", + "cosmosdb_account_use_aad_and_rbac", + "storage_account_key_access_disabled", + "storage_default_to_entra_authorization_enabled" + ], + "gcp": [ + "apikeys_api_restrictions_configured", + "apikeys_key_exists", + "apikeys_key_rotated_in_90_days", + "iam_sa_no_user_managed_keys", + "iam_sa_user_managed_key_rotate_90_days", + "iam_sa_user_managed_key_unused", + "iam_workload_identity_pool_provider_attribute_condition" + ], + "kubernetes": [ + "apiserver_kubelet_cert_auth", + "apiserver_kubelet_tls_auth", + "apiserver_no_token_auth_file", + "apiserver_service_account_key_file_set", + "apiserver_service_account_lookup_true", + "controllermanager_service_account_credentials", + "controllermanager_service_account_private_key_file", + "kubelet_client_ca_file_set" + ], + "m365": [ + "entra_app_registration_client_secret_unused", + "entra_default_app_management_policy_enabled", + "entra_service_principal_no_secrets_for_permanent_tier0_roles", + "exchange_shared_mailbox_sign_in_disabled" + ] + }, + "config_requirements": [ + { + "Check": "iam_sa_user_managed_key_unused", + "ConfigKey": "max_unused_account_days", + "Operator": "lte", + "Value": 180, + "Provider": "gcp" + } + ] + }, + { + "id": "KSI-IAM-SUS", + "name": "Responding to Suspicious Activity", + "description": "Accounts with privileged access are disabled or otherwise secured in response to suspicious activity.", + "attributes": { + "Theme": "KSI-IAM: Identity and Access Management", + "NISTControls": "AC-2, AC-2.1, AC-2.3, AC-2.13, AC-7, PS-4, PS-8", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "cognito_user_pool_advanced_security_enabled", + "cognito_user_pool_blocks_compromised_credentials_sign_in_attempts", + "cognito_user_pool_blocks_potential_malicious_sign_in_attempts" + ], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [ + "entra_conditional_access_policy_block_elevated_insider_risk", + "entra_conditional_access_policy_block_high_medium_sign_in_risk", + "entra_conditional_access_policy_block_o365_elevated_insider_risk", + "entra_identity_protection_sign_in_risk_enabled", + "entra_identity_protection_user_risk_enabled", + "entra_password_protection_lockout_duration_configured", + "entra_password_protection_lockout_threshold_limited" + ] + }, + "config_requirements": [] + }, + { + "id": "KSI-INR-AAR", + "name": "Generating After Action Reports", + "description": "Incident after action reports are generated and lessons learned are persistently incorporated.", + "attributes": { + "Theme": "KSI-INR: Incident Response", + "NISTControls": "IR-3, IR-4, IR-4.1, IR-8", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "KSI-INR-RIR", + "name": "Reviewing Incident Response Procedures", + "description": "The effectiveness of documented incident response procedures is persistently reviewed.", + "attributes": { + "Theme": "KSI-INR: Incident Response", + "NISTControls": "IR-4, IR-4.1, IR-6, IR-6.1, IR-6.3, IR-7, IR-7.1, IR-8, IR-8.1, SI-4.5", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + }, + "config_requirements": [] + }, + { + "id": "KSI-INR-RPI", + "name": "Reviewing Past Incidents", + "description": "Past incidents are persistently reviewed for patterns or vulnerabilities that were not previously apparent or identified.", + "attributes": { + "Theme": "KSI-INR: Incident Response", + "NISTControls": "IR-3, IR-4, IR-4.1, IR-5, IR-8", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "KSI-MLA-ALA", + "name": "Authorizing Log Access", + "description": "A least-privileged, role and attribute-based, and just-in-time access authorization model is used and persistently reviewed for access to log data based on organizationally defined data sensitivity.", + "attributes": { + "Theme": "KSI-MLA: Monitoring, Logging, and Auditing", + "NISTControls": "SI-11", + "ClassApplicability": "Optional for Class B, required for Class C" + }, + "checks": { + "aws": [ + "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", + "cloudwatch_cross_account_sharing_disabled", + "cloudwatch_log_group_not_publicly_accessible", + "iam_inline_policy_no_full_access_to_cloudtrail" + ], + "azure": [ + "monitor_storage_account_with_activity_logs_is_private" + ], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "KSI-MLA-EVC", + "name": "Evaluating Configurations", + "description": "The configuration of machine-based information resources, especially infrastructure as code, is persistently evaluated and tested.", + "attributes": { + "Theme": "KSI-MLA: Monitoring, Logging, and Auditing", + "NISTControls": "CA-7, CM-2, CM-6, SI-7.7", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "config_recorder_all_regions_enabled" + ], + "azure": [ + "sqlserver_va_periodic_recurring_scans_enabled", + "sqlserver_vulnerability_assessment_enabled" + ], + "gcp": [], + "kubernetes": [], + "m365": [] + }, + "config_requirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + } + ] + }, + { + "id": "KSI-MLA-LET", + "name": "Logging Event Types", + "description": "A list of information resources and event types that will be logged, monitored, and audited is maintained and persistently reviewed to ensure these activities occur.", + "attributes": { + "Theme": "KSI-MLA: Monitoring, Logging, and Auditing", + "NISTControls": "AC-2.4, AC-6.9, AC-17.1, AC-20.1, AU-2, AU-7.1, AU-12, SI-4.4, SI-4.5, SI-7.7", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "apigateway_restapi_logging_enabled", + "apigatewayv2_api_access_logging_enabled", + "appsync_field_level_logging_enabled", + "athena_workgroup_logging_enabled", + "awslambda_function_invoke_api_operations_cloudtrail_logging_enabled", + "bedrock_model_invocation_logging_enabled", + "cloudfront_distributions_logging_enabled", + "cloudtrail_bedrock_logging_enabled", + "cloudtrail_logs_s3_bucket_access_logging_enabled", + "cloudtrail_multi_region_enabled_logging_management_events", + "cloudtrail_s3_dataevents_read_enabled", + "cloudtrail_s3_dataevents_write_enabled", + "codebuild_project_logging_enabled", + "config_recorder_all_regions_enabled", + "datasync_task_logging_enabled", + "directoryservice_directory_log_forwarding_enabled", + "dms_replication_task_source_logging_enabled", + "dms_replication_task_target_logging_enabled", + "documentdb_cluster_cloudwatch_log_export", + "ec2_client_vpn_endpoint_connection_logging_enabled", + "ecs_cluster_container_insights_enabled", + "ecs_task_definitions_logging_enabled", + "eks_control_plane_logging_all_types_enabled", + "elasticbeanstalk_environment_cloudwatch_logging_enabled", + "elb_logging_enabled", + "elbv2_logging_enabled", + "glue_etl_jobs_logging_enabled", + "mq_broker_logging_enabled", + "neptune_cluster_integration_cloudwatch_logs", + "networkfirewall_logging_enabled", + "opensearch_service_domains_audit_logging_enabled", + "opensearch_service_domains_cloudwatch_logging_enabled", + "rds_cluster_integration_cloudwatch_logs", + "rds_instance_enhanced_monitoring_enabled", + "rds_instance_integration_cloudwatch_logs", + "redshift_cluster_audit_logging", + "route53_public_hosted_zones_cloudwatch_logging_enabled", + "s3_bucket_server_access_logging_enabled", + "stepfunctions_statemachine_logging_enabled", + "vpc_flow_logs_enabled", + "waf_global_webacl_logging_enabled", + "waf_regional_webacl_logging_enabled", + "wafv2_webacl_logging_enabled" + ], + "azure": [ + "aks_cluster_azure_monitor_enabled", + "app_function_application_insights_enabled", + "app_http_logs_enabled", + "appinsights_ensure_is_configured", + "defender_auto_provisioning_log_analytics_agent_vms_on", + "monitor_diagnostic_setting_with_appropriate_categories", + "monitor_diagnostic_settings_exists", + "mysql_flexible_server_audit_log_connection_activated", + "mysql_flexible_server_audit_log_enabled", + "network_flow_log_captured_sent", + "network_flow_log_more_than_90_days", + "network_watcher_enabled", + "postgresql_flexible_server_log_checkpoints_on", + "postgresql_flexible_server_log_connections_on", + "postgresql_flexible_server_log_disconnections_on", + "sqlserver_auditing_enabled", + "sqlserver_auditing_retention_90_days" + ], + "gcp": [ + "cloudsql_instance_postgres_enable_pgaudit_flag", + "cloudsql_instance_postgres_log_connections_flag", + "cloudsql_instance_postgres_log_disconnections_flag", + "cloudsql_instance_postgres_log_error_verbosity_flag", + "cloudsql_instance_postgres_log_min_duration_statement_flag", + "cloudsql_instance_postgres_log_min_error_statement_flag", + "cloudsql_instance_postgres_log_min_messages_flag", + "cloudsql_instance_postgres_log_statement_flag", + "cloudsql_instance_sqlserver_trace_flag", + "cloudstorage_audit_logs_enabled", + "cloudstorage_bucket_logging_enabled", + "compute_loadbalancer_logging_enabled", + "compute_network_dns_logging_enabled", + "compute_subnet_flow_logs_enabled", + "iam_audit_logs_enabled", + "logging_sink_created" + ], + "kubernetes": [ + "apiserver_audit_log_path_set" + ], + "m365": [ + "exchange_mailbox_audit_bypass_disabled", + "exchange_organization_mailbox_auditing_enabled", + "exchange_user_mailbox_auditing_enabled" + ] + }, + "config_requirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + }, + { + "Check": "eks_control_plane_logging_all_types_enabled", + "ConfigKey": "eks_required_log_types", + "Operator": "superset", + "Value": [ + "api", + "audit", + "authenticator", + "controllerManager", + "scheduler" + ], + "Provider": "aws" + }, + { + "Check": "exchange_user_mailbox_auditing_enabled", + "ConfigKey": "audit_log_age", + "Operator": "gte", + "Value": 90, + "Provider": "m365" + } + ] + }, + { + "id": "KSI-MLA-OSM", + "name": "Operating SIEM Capability", + "description": "A Security Information and Event Management (SIEM) or similar system(s) is used and persistently reviewed for centralized, tamper-resistant logging of events, activities, and changes.", + "attributes": { + "Theme": "KSI-MLA: Monitoring, Logging, and Auditing", + "NISTControls": "AC-17.1, AC-20.1, AU-2, AU-3, AU-3.1, AU-4, AU-5, AU-6.1, AU-6.3, AU-7, AU-7.1, AU-8, AU-9, AU-11, IR-4.1, SI-4.2, SI-4.4, SI-7.7", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "cloudtrail_bucket_requires_mfa_delete", + "cloudtrail_cloudwatch_logging_enabled", + "cloudtrail_kms_encryption_enabled", + "cloudtrail_log_file_validation_enabled", + "cloudtrail_multi_region_enabled", + "cloudwatch_log_group_kms_encryption_enabled", + "cloudwatch_log_group_retention_policy_specific_days_enabled" + ], + "azure": [ + "monitor_diagnostic_settings_exists" + ], + "gcp": [ + "cloudstorage_bucket_log_retention_policy_lock", + "iam_audit_logs_enabled", + "logging_sink_created" + ], + "kubernetes": [ + "apiserver_audit_log_maxage_set", + "apiserver_audit_log_maxbackup_set", + "apiserver_audit_log_maxsize_set", + "apiserver_audit_log_path_set" + ], + "m365": [ + "purview_audit_log_search_enabled" + ] + }, + "config_requirements": [ + { + "Check": "apiserver_audit_log_maxage_set", + "ConfigKey": "audit_log_maxage", + "Operator": "gte", + "Value": 30, + "Provider": "kubernetes" + }, + { + "Check": "apiserver_audit_log_maxbackup_set", + "ConfigKey": "audit_log_maxbackup", + "Operator": "gte", + "Value": 10, + "Provider": "kubernetes" + }, + { + "Check": "apiserver_audit_log_maxsize_set", + "ConfigKey": "audit_log_maxsize", + "Operator": "gte", + "Value": 100, + "Provider": "kubernetes" + }, + { + "Check": "cloudwatch_log_group_retention_policy_specific_days_enabled", + "ConfigKey": "log_group_retention_days", + "Operator": "gte", + "Value": 365, + "Provider": "aws" + } + ] + }, + { + "id": "KSI-MLA-RVL", + "name": "Reviewing Logs", + "description": "Logs are persistently reviewed and audited.", + "attributes": { + "Theme": "KSI-MLA: Monitoring, Logging, and Auditing", + "NISTControls": "AC-2.4, AC-6.9, AU-2, AU-6, AU-6.1, SI-4, SI-4.4", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "KSI-PIY-GIV", + "name": "Generating Inventories", + "description": "Authoritative sources are used to automatically generate real-time inventories of all information resources when needed.", + "attributes": { + "Theme": "KSI-PIY: Policy and Inventory", + "NISTControls": "CM-2.2, CM-7.5, CM-8, CM-8.1, CM-12, CM-12.1, CP-2.8", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "config_delegated_admin_and_org_aggregator_all_regions", + "config_recorder_all_regions_enabled", + "resourceexplorer2_indexes_found" + ], + "azure": [], + "gcp": [ + "iam_cloud_asset_inventory_enabled" + ], + "kubernetes": [], + "m365": [] + }, + "config_requirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + }, + { + "Check": "config_delegated_admin_and_org_aggregator_all_regions", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + } + ] + }, + { + "id": "KSI-PIY-RES", + "name": "Reviewing Executive Support", + "description": "Executive support for achieving the provider's security goals is persistently reviewed and demonstrated.", + "attributes": { + "Theme": "KSI-PIY: Policy and Inventory", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "KSI-PIY-RIS", + "name": "Reviewing Investments in Security", + "description": "The effectiveness of the provider's investments in achieving security goals is persistently reviewed.", + "attributes": { + "Theme": "KSI-PIY: Policy and Inventory", + "NISTControls": "AC-5, CA-2, CP-2.1, CP-4.1, IR-3.2, PM-3, SA-2, SA-3, SR-2.1", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "KSI-PIY-RSD", + "name": "Reviewing Security in the SDLC", + "description": "The effectiveness of building security and privacy considerations into the Software Development Lifecycle and aligning with CISA Secure By Design principles is persistently reviewed.", + "attributes": { + "Theme": "KSI-PIY: Policy and Inventory", + "NISTControls": "AC-5, AU-3.3, CM-3.4, PL-8, PM-7, SA-3, SA-8, SC-4, SC-18, SI-10, SI-11, SI-16", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "KSI-PIY-RVD", + "name": "Reviewing Vulnerability Disclosures", + "description": "The effectiveness of the provider's vulnerability disclosure program is persistently reviewed.", + "attributes": { + "Theme": "KSI-PIY: Policy and Inventory", + "NISTControls": "RA-5.11", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "KSI-RPL-ABO", + "name": "Aligning Backups with Objectives", + "description": "The alignment of machine-based information resource backups with defined recovery objectives is persistently reviewed.", + "attributes": { + "Theme": "KSI-RPL: Recovery Planning", + "NISTControls": "CM-2.3, CP-6, CP-9, CP-10, CP-10.2, SI-12", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "dlm_ebs_snapshot_lifecycle_policy_exists", + "documentdb_cluster_backup_enabled", + "dynamodb_table_protected_by_backup_plan", + "dynamodb_tables_pitr_enabled", + "ec2_ebs_volume_protected_by_backup_plan", + "ec2_ebs_volume_snapshots_exists", + "efs_have_backup_enabled", + "elasticache_redis_cluster_backup_enabled", + "lightsail_instance_automated_snapshots", + "neptune_cluster_backup_enabled", + "rds_cluster_backtrack_enabled", + "rds_cluster_protected_by_backup_plan", + "rds_instance_backup_enabled", + "rds_instance_protected_by_backup_plan", + "redshift_cluster_automated_snapshot", + "s3_bucket_cross_region_replication", + "s3_bucket_object_versioning" + ], + "azure": [ + "cosmosdb_account_backup_policy_continuous", + "keyvault_recoverable", + "mysql_flexible_server_geo_redundant_backup_enabled", + "postgresql_flexible_server_geo_redundant_backup_enabled", + "recovery_vault_backup_policy_retention_adequate", + "recovery_vault_has_protected_items", + "storage_blob_versioning_is_enabled", + "storage_ensure_file_shares_soft_delete_is_enabled", + "storage_ensure_soft_delete_is_enabled", + "storage_geo_redundant_enabled", + "vm_backup_enabled", + "vm_sufficient_daily_backup_retention_period" + ], + "gcp": [ + "cloudsql_instance_automated_backups", + "cloudstorage_bucket_soft_delete_enabled", + "cloudstorage_bucket_sufficient_retention_period", + "cloudstorage_bucket_versioning_enabled" + ], + "kubernetes": [], + "m365": [] + }, + "config_requirements": [ + { + "Check": "vm_sufficient_daily_backup_retention_period", + "ConfigKey": "vm_backup_min_daily_retention_days", + "Operator": "gte", + "Value": 7, + "Provider": "azure" + }, + { + "Check": "documentdb_cluster_backup_enabled", + "ConfigKey": "minimum_backup_retention_period", + "Operator": "gte", + "Value": 7, + "Provider": "aws" + }, + { + "Check": "neptune_cluster_backup_enabled", + "ConfigKey": "minimum_backup_retention_period", + "Operator": "gte", + "Value": 7, + "Provider": "aws" + }, + { + "Check": "elasticache_redis_cluster_backup_enabled", + "ConfigKey": "minimum_snapshot_retention_period", + "Operator": "gte", + "Value": 7, + "Provider": "aws" + } + ] + }, + { + "id": "KSI-RPL-ARP", + "name": "Aligning Recovery Plan", + "description": "The alignment of recovery plans with defined recovery objectives is persistently reviewed.", + "attributes": { + "Theme": "KSI-RPL: Recovery Planning", + "NISTControls": "CP-2, CP-2.1, CP-2.3, CP-4.1, CP-6, CP-6.1, CP-6.3, CP-7, CP-7.1, CP-7.2, CP-7.3, CP-8, CP-8.1, CP-8.2, CP-10, CP-10.2", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "backup_plans_exist", + "backup_reportplans_exist", + "backup_vaults_exist" + ], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + }, + "config_requirements": [] + }, + { + "id": "KSI-RPL-RRO", + "name": "Reviewing Recovery Objectives", + "description": "The desired Recovery Time Objectives (RTO) and Recovery Point Objectives (RPO) are defined and persistently reviewed for alignment with the provider's business needs and capabilities.", + "attributes": { + "Theme": "KSI-RPL: Recovery Planning", + "NISTControls": "CP-2.3, CP-10", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "KSI-RPL-TRC", + "name": "Testing Recovery Capabilities", + "description": "The capability to recover from incidents and contingencies aligned with defined recovery objectives is persistently tested.", + "attributes": { + "Theme": "KSI-RPL: Recovery Planning", + "NISTControls": "CP-2.1, CP-2.3, CP-4, CP-4.1, CP-6, CP-6.1, CP-9.1, CP-10, IR-3, IR-3.2", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "drs_job_exist" + ], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + }, + "config_requirements": [ + { + "Check": "drs_job_exist", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + } + ] + }, + { + "id": "KSI-SCR-MIT", + "name": "Mitigating Supply Chain Risk", + "description": "Persistently identify, review, and mitigate potential supply chain risks.", + "attributes": { + "Theme": "KSI-SCR: Supply Chain Risk", + "NISTControls": "AC-20, RA-3.1, SA-9, SA-10, SA-11, SA-15.3, SA-22, SI-7.1, SR-5, SR-6, CA-7.4, SC-18", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "awslambda_function_using_cross_account_layers", + "codeartifact_packages_external_public_publishing_disabled", + "codebuild_project_user_controlled_buildspec", + "codebuild_project_uses_allowed_github_organizations", + "ecr_registry_enhanced_scanning_enabled", + "ecr_registry_scan_images_on_push_enabled", + "ecr_repositories_not_publicly_accessible", + "ecr_repositories_scan_vulnerabilities_in_latest_image", + "ecr_repositories_tag_immutability", + "s3_bucket_shadow_resource_vulnerability" + ], + "azure": [ + "defender_container_images_resolved_vulnerabilities", + "defender_container_images_scan_enabled" + ], + "gcp": [ + "artifacts_container_analysis_enabled", + "gcr_container_scanning_enabled" + ], + "kubernetes": [ + "apiserver_always_pull_images_plugin" + ], + "m365": [] + }, + "config_requirements": [ + { + "Check": "ecr_repositories_scan_vulnerabilities_in_latest_image", + "ConfigKey": "ecr_repository_vulnerability_minimum_severity", + "Operator": "in", + "Value": [ + "MEDIUM" + ], + "Provider": "aws" + } + ] + }, + { + "id": "KSI-SCR-MON", + "name": "Monitoring Supply Chain Risk", + "description": "Third party software information resources are automatically monitored for upstream vulnerabilities using mechanisms that may include contractual notification requirements or active monitoring services.", + "attributes": { + "Theme": "KSI-SCR: Supply Chain Risk", + "NISTControls": "AC-20, CA-3, IR-6.3, PS-7, RA-5, SA-9, SI-5, SR-5, SR-6, SR-8", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "awslambda_function_using_supported_runtimes", + "ecr_registry_enhanced_scanning_enabled", + "inspector2_active_findings_exist", + "inspector2_is_enabled", + "ssm_managed_compliant_patching" + ], + "azure": [ + "app_ensure_java_version_is_latest", + "app_ensure_php_version_is_latest", + "app_ensure_python_version_is_latest", + "app_function_latest_runtime_version", + "defender_ensure_system_updates_are_applied" + ], + "gcp": [], + "kubernetes": [], + "m365": [] + }, + "config_requirements": [ + { + "Check": "awslambda_function_using_supported_runtimes", + "ConfigKey": "obsolete_lambda_runtimes", + "Operator": "superset", + "Value": [ + "java8", + "go1.x", + "provided", + "python3.6", + "python2.7", + "python3.7", + "python3.8", + "nodejs4.3", + "nodejs4.3-edge", + "nodejs6.10", + "nodejs", + "nodejs8.10", + "nodejs10.x", + "nodejs12.x", + "nodejs14.x", + "nodejs16.x", + "dotnet5.0", + "dotnet6", + "dotnet7", + "dotnetcore1.0", + "dotnetcore2.0", + "dotnetcore2.1", + "dotnetcore3.1", + "ruby2.5", + "ruby2.7" + ], + "Provider": "aws" + } + ] + }, + { + "id": "KSI-SVC-ACM", + "name": "Automating Configuration Management", + "description": "The configuration of machine-based information resources is managed using automation and persistently reviewed for drift.", + "attributes": { + "Theme": "KSI-SVC: Service Configuration", + "NISTControls": "AC-2.4, CM-2, CM-2.2, CM-2.3, CM-6, CM-7.1, PL-9, PL-10, SA-5, SI-5, SR-10", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "config_recorder_all_regions_enabled", + "ec2_instance_managed_by_ssm", + "ssm_managed_compliant_patching" + ], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + }, + "config_requirements": [ + { + "Check": "config_recorder_all_regions_enabled", + "ConfigKey": "mute_non_default_regions", + "Operator": "eq", + "Value": false, + "Provider": "aws" + } + ] + }, + { + "id": "KSI-SVC-ASM", + "name": "Automating Secret Management", + "description": "Management, protection, and regular rotation of digital keys, certificates, and other secrets is automated and persistently reviewed.", + "attributes": { + "Theme": "KSI-SVC: Service Configuration", + "NISTControls": "AC-17.2, IA-5.2, IA-5.6, SC-12, SC-17", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "acm_certificates_expiration_check", + "amplify_app_no_secrets_in_environment", + "apigateway_restapi_no_secrets_in_stage_variables", + "awslambda_function_no_secrets_in_code", + "awslambda_function_no_secrets_in_variables", + "awslambda_layer_no_secrets_in_content", + "batch_job_definition_no_secrets", + "cloudformation_stack_outputs_find_secrets", + "cloudwatch_log_group_no_secrets_in_logs", + "codebuild_project_no_secrets_in_variables", + "codebuild_project_source_repo_url_no_sensitive_credentials", + "codecommit_repository_no_secrets", + "datapipeline_pipeline_no_secrets_in_definition", + "directoryservice_ldap_certificate_expiration", + "ec2_instance_secrets_user_data", + "ec2_launch_template_no_secrets", + "ecr_repository_image_no_secrets", + "ecs_task_definitions_no_environment_secrets", + "elasticbeanstalk_environment_no_secrets_in_configuration", + "glue_catalog_connection_no_secrets", + "glue_etl_jobs_no_secrets_in_arguments", + "iam_no_expired_server_certificates_stored", + "kms_cmk_rotation_enabled", + "kms_key_not_publicly_accessible", + "rds_instance_certificate_expiration", + "sagemaker_notebook_instance_no_secrets", + "secretsmanager_automatic_rotation_enabled", + "secretsmanager_has_restrictive_resource_policy", + "secretsmanager_not_publicly_accessible", + "secretsmanager_secret_rotated_periodically", + "secretsmanager_secret_unused", + "ssm_document_secrets", + "stepfunctions_statemachine_no_secrets_in_definition" + ], + "azure": [ + "entra_app_registration_credential_not_expired", + "keyvault_key_expiration_set_in_non_rbac", + "keyvault_key_rotation_enabled", + "keyvault_non_rbac_secret_expiration_set", + "keyvault_rbac_key_expiration_set", + "keyvault_rbac_secret_expiration_set", + "storage_key_rotation_90_days" + ], + "gcp": [ + "kms_key_not_publicly_accessible", + "kms_key_rotation_enabled", + "kms_key_rotation_max_90_days", + "secretmanager_secret_not_publicly_accessible", + "secretmanager_secret_rotation_enabled" + ], + "kubernetes": [ + "apiserver_encryption_provider_config_set", + "apiserver_service_account_key_file_set", + "controllermanager_rotate_kubelet_server_cert", + "controllermanager_service_account_private_key_file", + "kubelet_rotate_certificates" + ], + "m365": [ + "entra_policy_default_user_cannot_read_bitlocker_keys" + ] + }, + "config_requirements": [ + { + "Check": "acm_certificates_expiration_check", + "ConfigKey": "days_to_expire_threshold", + "Operator": "gte", + "Value": 7, + "Provider": "aws" + }, + { + "Check": "secretsmanager_secret_unused", + "ConfigKey": "max_days_secret_unused", + "Operator": "lte", + "Value": 90, + "Provider": "aws" + }, + { + "Check": "secretsmanager_secret_rotated_periodically", + "ConfigKey": "max_days_secret_unrotated", + "Operator": "lte", + "Value": 90, + "Provider": "aws" + } + ] + }, + { + "id": "KSI-SVC-EIS", + "name": "Evaluating and Improving Security", + "description": "Information resources are persistently evaluated for opportunities to improve security and those improvements are persistently made.", + "attributes": { + "Theme": "KSI-SVC: Service Configuration", + "NISTControls": "CM-7.1, CM-12.1, MA-2, PL-8, SC-7, SC-39, SI-2.2, SI-4, SR-10", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "trustedadvisor_errors_and_warnings" + ], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + }, + "config_requirements": [] + }, + { + "id": "KSI-SVC-PRR", + "name": "Preventing Residual Risk", + "description": "Plans, procedures, and the state of information resources are persistently reviewed after making changes to limit and remove unwanted residual elements that would likely negatively affect the confidentiality, integrity, or availability of federal customer data.", + "attributes": { + "Theme": "KSI-SVC: Service Configuration", + "NISTControls": "SC-4", + "ClassApplicability": "Optional for Class B, required for Class C" + }, + "checks": { + "aws": [ + "cloudfront_distributions_s3_origin_non_existent_bucket", + "ec2_elastic_ip_unassigned", + "lightsail_static_ip_unused", + "route53_dangling_ip_subdomain_takeover" + ], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "KSI-SVC-RUD", + "name": "Removing Unwanted Data", + "description": "Unwanted federal customer data is removed promptly when requested by an agency in alignment with customer agreements, including from backups if appropriate; this typically applies when a customer spills information or when a customer seeks to remove information from a service due to a change in usage.", + "attributes": { + "Theme": "KSI-SVC: Service Configuration", + "NISTControls": "SI-12.3, SI-18.4", + "ClassApplicability": "Optional for Class B, required for Class C" + }, + "checks": { + "aws": [], + "azure": [], + "gcp": [], + "kubernetes": [], + "m365": [] + } + }, + { + "id": "KSI-SVC-SIN", + "name": "Securing Information", + "description": "Information is encrypted or otherwise secured from unwanted access or modification.", + "attributes": { + "Theme": "KSI-SVC: Service Configuration", + "NISTControls": "AC-1, AC-17.2, CP-9.8, SC-8, SC-8.1, SC-13, SC-20, SC-21, SC-22, SC-23, SC-28, SC-28.1", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "apigateway_restapi_cache_encrypted", + "athena_workgroup_encryption", + "awslambda_function_env_vars_not_encrypted_with_cmk", + "backup_recovery_point_encrypted", + "backup_vaults_encrypted", + "bedrock_custom_model_encrypted_with_cmk", + "bedrock_knowledge_base_encrypted_with_cmk", + "bedrock_prompt_encrypted_with_cmk", + "cloudfront_distributions_field_level_encryption_enabled", + "cloudfront_distributions_https_enabled", + "cloudfront_distributions_origin_traffic_encrypted", + "cloudfront_distributions_s3_origin_access_control", + "cloudfront_distributions_using_deprecated_ssl_protocols", + "codebuild_report_group_export_encrypted", + "dms_endpoint_redis_in_transit_encryption_enabled", + "dms_endpoint_ssl_enabled", + "documentdb_cluster_public_snapshot", + "documentdb_cluster_storage_encrypted", + "dynamodb_accelerator_cluster_encryption_enabled", + "dynamodb_accelerator_cluster_in_transit_encryption_enabled", + "dynamodb_table_cross_account_access", + "dynamodb_tables_kms_cmk_encryption_enabled", + "ec2_ami_account_block_public_access", + "ec2_ami_public", + "ec2_ebs_default_encryption", + "ec2_ebs_public_snapshot", + "ec2_ebs_snapshot_account_block_public_access", + "ec2_ebs_snapshots_encrypted", + "ec2_ebs_volume_encryption", + "efs_encryption_at_rest_enabled", + "efs_not_publicly_accessible", + "eks_cluster_kms_cmk_encryption_in_secrets_enabled", + "elasticache_redis_cluster_in_transit_encryption_enabled", + "elasticache_redis_cluster_rest_encryption_enabled", + "elb_insecure_ssl_ciphers", + "elb_ssl_listeners", + "elbv2_insecure_ssl_ciphers", + "elbv2_nlb_tls_termination_enabled", + "elbv2_ssl_listeners", + "eventbridge_bus_cross_account_access", + "eventbridge_bus_exposed", + "firehose_stream_encrypted_at_rest", + "glacier_vaults_policy_public_access", + "glue_data_catalogs_metadata_encryption_enabled", + "glue_data_catalogs_not_publicly_accessible", + "glue_database_connections_ssl_enabled", + "glue_development_endpoints_job_bookmark_encryption_enabled", + "glue_development_endpoints_s3_encryption_enabled", + "glue_etl_jobs_amazon_s3_encryption_enabled", + "glue_etl_jobs_job_bookmark_encryption_enabled", + "glue_ml_transform_encrypted_at_rest", + "kafka_cluster_encryption_at_rest_uses_cmk", + "kafka_cluster_in_transit_encryption_enabled", + "kafka_connector_in_transit_encryption_enabled", + "kinesis_stream_encrypted_at_rest", + "memorydb_cluster_in_transit_encryption_enabled", + "neptune_cluster_public_snapshot", + "neptune_cluster_snapshot_encrypted", + "neptune_cluster_storage_encrypted", + "opensearch_service_domains_encryption_at_rest_enabled", + "opensearch_service_domains_https_communications_enforced", + "opensearch_service_domains_node_to_node_encryption_enabled", + "rds_cluster_storage_encrypted", + "rds_instance_storage_encrypted", + "rds_instance_transport_encrypted", + "rds_snapshots_encrypted", + "rds_snapshots_public_access", + "redshift_cluster_encrypted_at_rest", + "redshift_cluster_in_transit_encryption_enabled", + "s3_access_point_public_access_block", + "s3_account_level_public_access_blocks", + "s3_bucket_acl_prohibited", + "s3_bucket_cross_account_access", + "s3_bucket_kms_encryption", + "s3_bucket_level_public_access_block", + "s3_bucket_no_mfa_delete", + "s3_bucket_object_lock", + "s3_bucket_object_public", + "s3_bucket_policy_public_write_access", + "s3_bucket_public_access", + "s3_bucket_public_list_acl", + "s3_bucket_public_write_acl", + "s3_bucket_secure_transport_policy", + "s3_multi_region_access_point_public_access_block", + "sagemaker_endpoint_config_kms_encryption_enabled", + "sagemaker_notebook_instance_encryption_enabled", + "sagemaker_training_jobs_intercontainer_encryption_enabled", + "sagemaker_training_jobs_volume_and_output_encryption_enabled", + "sns_subscription_not_using_http_endpoints", + "sns_topics_not_publicly_accessible", + "sqs_queues_not_publicly_accessible", + "sqs_queues_server_side_encryption_enabled", + "ssm_documents_set_as_public", + "stepfunctions_statemachine_encrypted_with_cmk", + "storagegateway_fileshare_encryption_enabled", + "transfer_server_in_transit_encryption_enabled", + "workspaces_volume_encryption_enabled" + ], + "azure": [ + "app_client_certificates_on", + "app_ensure_auth_is_set_up", + "app_ensure_http_is_redirected_to_https", + "app_ftp_deployment_disabled", + "app_function_ensure_http_is_redirected_to_https", + "app_minimum_tls_version_12", + "cosmosdb_account_minimum_tls_version", + "databricks_workspace_cmk_encryption_enabled", + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "mysql_flexible_server_minimum_tls_version_12", + "mysql_flexible_server_ssl_connection_enabled", + "postgresql_flexible_server_enforce_ssl_enabled", + "sqlserver_recommended_minimal_tls_version", + "sqlserver_tde_encrypted_with_cmk", + "sqlserver_tde_encryption_enabled", + "storage_blob_public_access_level_is_disabled", + "storage_ensure_encryption_with_customer_managed_keys", + "storage_ensure_minimum_tls_version_12", + "storage_infrastructure_encryption_is_enabled", + "storage_secure_transfer_required_is_enabled", + "storage_smb_channel_encryption_with_secure_algorithm", + "vm_ensure_attached_disks_encrypted_with_cmk", + "vm_ensure_unattached_disks_encrypted_with_cmk" + ], + "gcp": [ + "bigquery_dataset_cmk_encryption", + "bigquery_dataset_public_access", + "bigquery_table_cmk_encryption", + "cloudsql_instance_cmek_encryption_enabled", + "cloudsql_instance_ssl_connections", + "cloudstorage_bucket_public_access", + "compute_image_not_publicly_shared", + "compute_instance_confidential_computing_enabled", + "compute_instance_encryption_with_csek_enabled", + "dataproc_encrypted_with_cmks_disabled" + ], + "kubernetes": [ + "apiserver_encryption_provider_config_set", + "apiserver_etcd_tls_config", + "apiserver_tls_config", + "etcd_peer_tls_config", + "etcd_tls_encryption", + "kubelet_tls_cert_and_key", + "rbac_minimize_secret_access" + ], + "m365": [ + "exchange_organization_modern_authentication_enabled", + "exchange_transport_config_smtp_auth_disabled", + "sharepoint_modern_authentication_required" + ] + }, + "config_requirements": [ + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ], + "Provider": "azure" + }, + { + "Check": "storage_smb_channel_encryption_with_secure_algorithm", + "ConfigKey": "recommended_smb_channel_encryption_algorithms", + "Operator": "subset", + "Value": [ + "AES-256-GCM" + ], + "Provider": "azure" + } + ] + }, + { + "id": "KSI-SVC-VCM", + "name": "Validating Communications", + "description": "The authenticity and integrity of communications between machine-based information resources is persistently validated using automation.", + "attributes": { + "Theme": "KSI-SVC: Service Configuration", + "NISTControls": "SC-23, SI-7.1", + "ClassApplicability": "Optional for Class B, required for Class C" + }, + "checks": { + "aws": [ + "apigateway_restapi_client_certificate_enabled", + "kafka_cluster_mutual_tls_authentication_enabled", + "ses_identity_dkim_enabled" + ], + "azure": [ + "app_client_certificates_on" + ], + "gcp": [ + "dns_dnssec_disabled" + ], + "kubernetes": [ + "apiserver_client_ca_file_set", + "apiserver_etcd_tls_config", + "apiserver_kubelet_cert_auth", + "apiserver_kubelet_tls_auth", + "etcd_client_cert_auth", + "etcd_no_auto_tls", + "etcd_no_peer_auto_tls", + "etcd_peer_client_cert_auth", + "etcd_peer_tls_config", + "kubelet_client_ca_file_set" + ], + "m365": [ + "defender_domain_dkim_enabled", + "defender_domain_dmarc_records_published", + "exchange_organization_reject_direct_send_enabled" + ] + } + }, + { + "id": "KSI-SVC-VRI", + "name": "Validating Resource Integrity", + "description": "Use cryptographic methods to validate the integrity of machine-based information resources.", + "attributes": { + "Theme": "KSI-SVC: Service Configuration", + "NISTControls": "CM-2.2, CM-8.3, SC-13, SC-23, SI-7, SI-7.1, SR-10", + "ClassApplicability": "Required for Classes B and C" + }, + "checks": { + "aws": [ + "cloudtrail_log_file_validation_enabled", + "kms_key_enclave_attestation_bypassable_path", + "kms_key_enclave_attestation_no_deployment_binding", + "kms_key_enclave_attestation_not_enforced", + "kms_key_enclave_attestation_pcr_mismatch", + "kms_key_enclave_attestation_unknown_image", + "kms_key_enclave_debug_attestation_detected" + ], + "azure": [ + "vm_trusted_launch_enabled" + ], + "gcp": [ + "compute_instance_shielded_vm_enabled", + "dns_dnssec_disabled", + "dns_rsasha1_in_use_to_key_sign_in_dnssec", + "dns_rsasha1_in_use_to_zone_sign_in_dnssec" + ], + "kubernetes": [ + "apiserver_etcd_cafile_set", + "controllermanager_root_ca_file_set", + "etcd_client_cert_auth", + "etcd_peer_client_cert_auth" + ], + "m365": [] + }, + "config_requirements": [ + { + "Check": "kms_key_enclave_debug_attestation_detected", + "ConfigKey": "enclave_debug_lookback_window_hours", + "Operator": "gte", + "Value": 2160, + "Provider": "aws" + }, + { + "Check": "kms_key_enclave_debug_attestation_detected", + "ConfigKey": "enclave_debug_max_events", + "Operator": "gte", + "Value": 5000, + "Provider": "aws" + }, + { + "Check": "kms_key_enclave_attestation_unknown_image", + "ConfigKey": "enclave_unknown_image_lookback_window_hours", + "Operator": "gte", + "Value": 2160, + "Provider": "aws" + }, + { + "Check": "kms_key_enclave_attestation_unknown_image", + "ConfigKey": "enclave_unknown_image_max_events", + "Operator": "gte", + "Value": 5000, + "Provider": "aws" + } + ] + } + ] +} diff --git a/prowler/compliance/gcp/ens_rd2022_gcp.json b/prowler/compliance/gcp/ens_rd2022_gcp.json index 8315123f79..be64300eb7 100644 --- a/prowler/compliance/gcp/ens_rd2022_gcp.json +++ b/prowler/compliance/gcp/ens_rd2022_gcp.json @@ -1692,7 +1692,7 @@ ] }, { - "Id": "mp.com.4.gcp.vpc.1", + "Id": "mp.com.4.gcp.vpc.2", "Description": "Separación de flujos de información en la red", "Attributes": [ { diff --git a/prowler/compliance/gcp/fedramp_20x_ksi_low_gcp.json b/prowler/compliance/gcp/fedramp_20x_ksi_low_gcp.json deleted file mode 100644 index 5638b0c51e..0000000000 --- a/prowler/compliance/gcp/fedramp_20x_ksi_low_gcp.json +++ /dev/null @@ -1,294 +0,0 @@ -{ - "Framework": "FedRAMP-20x-KSI-Low", - "Name": "FedRAMP 20x Key Security Indicators (KSIs) - Low Impact Level v25.05C", - "Version": "25.05C", - "Provider": "GCP", - "Description": "FedRAMP 20x Key Security Indicators (KSIs) Low Impact Level represent core security indicators for cloud service providers, focusing on automation, continuous monitoring, and cloud-native security principles per FedRAMP 20x Phase One pilot requirements for Low impact systems.", - "Requirements": [ - { - "Id": "ksi-cmt", - "Name": "KSI-CMT: Change Management", - "Description": "A secure cloud service provider will ensure that all system changes are properly documented and configuration baselines are updated accordingly", - "Attributes": [ - { - "ItemId": "ksi-cmt", - "Section": "Change Management", - "Service": "gcp" - } - ], - "Checks": [ - "iam_audit_logs_enabled", - "iam_cloud_asset_inventory_enabled", - "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", - "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", - "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", - "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled", - "compute_instance_serial_ports_in_use", - "compute_project_os_login_enabled" - ] - }, - { - "Id": "ksi-cna", - "Name": "KSI-CNA: Cloud Native Architecture", - "Description": "A secure cloud service offering will use cloud native architecture and design principles to enforce and enhance the Confidentiality, Integrity and Availability of the system", - "Attributes": [ - { - "ItemId": "ksi-cna", - "Section": "Cloud Native Architecture", - "Service": "gcp" - } - ], - "Checks": [ - "cloudsql_instance_private_ip_assignment", - "cloudsql_instance_public_access", - "cloudsql_instance_public_ip", - "cloudstorage_bucket_uniform_bucket_level_access", - "compute_firewall_rdp_access_from_the_internet_allowed", - "compute_firewall_ssh_access_from_the_internet_allowed", - "compute_instance_block_project_wide_ssh_keys_disabled", - "compute_instance_confidential_computing_enabled", - "compute_instance_ip_forwarding_is_enabled", - "compute_instance_public_ip", - "compute_instance_shielded_vm_enabled", - "compute_loadbalancer_logging_enabled", - "compute_network_default_in_use", - "compute_network_dns_logging_enabled", - "compute_network_not_legacy", - "compute_subnet_flow_logs_enabled", - "gke_cluster_no_default_service_account" - ] - }, - { - "Id": "ksi-iam", - "Name": "KSI-IAM: Identity and Access Management", - "Description": "A secure cloud service offering will protect user data, control access, and apply zero trust principles", - "Attributes": [ - { - "ItemId": "ksi-iam", - "Section": "Identity and Access Management", - "Service": "gcp" - } - ], - "Checks": [ - "apikeys_api_restrictions_configured", - "apikeys_key_exists", - "apikeys_key_rotated_in_90_days", - "compute_instance_default_service_account_in_use", - "compute_instance_default_service_account_in_use_with_full_api_access", - "iam_no_service_roles_at_project_level", - "iam_role_kms_enforce_separation_of_duties", - "iam_role_sa_enforce_separation_of_duties", - "iam_sa_no_administrative_privileges", - "iam_sa_no_user_managed_keys", - "iam_sa_user_managed_key_rotate_90_days", - "iam_sa_user_managed_key_unused", - "iam_service_account_unused" - ] - }, - { - "Id": "ksi-inr", - "Name": "KSI-INR: Incident Response", - "Description": "A secure cloud service offering will respond to incidents according to FedRAMP requirements and cloud service provider policies", - "Attributes": [ - { - "ItemId": "ksi-inr", - "Section": "Incident Response", - "Service": "gcp" - } - ], - "Checks": [ - "iam_organization_essential_contacts_configured", - "iam_account_access_approval_enabled", - "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", - "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", - "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", - "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled" - ] - }, - { - "Id": "ksi-mla", - "Name": "KSI-MLA: Monitoring, Logging, and Auditing", - "Description": "A secure cloud service offering will monitor, log, and audit all important events, activity, and changes", - "Attributes": [ - { - "ItemId": "ksi-mla", - "Section": "Monitoring, Logging, and Auditing", - "Service": "gcp" - } - ], - "Checks": [ - "cloudsql_instance_postgres_enable_pgaudit_flag", - "cloudsql_instance_postgres_log_connections_flag", - "cloudsql_instance_postgres_log_disconnections_flag", - "cloudsql_instance_postgres_log_error_verbosity_flag", - "cloudsql_instance_postgres_log_min_duration_statement_flag", - "cloudsql_instance_postgres_log_min_error_statement_flag", - "cloudsql_instance_postgres_log_min_messages_flag", - "cloudsql_instance_postgres_log_statement_flag", - "cloudsql_instance_sqlserver_trace_flag", - "cloudstorage_bucket_log_retention_policy_lock", - "compute_loadbalancer_logging_enabled", - "compute_network_dns_logging_enabled", - "compute_subnet_flow_logs_enabled", - "iam_audit_logs_enabled", - "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", - "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", - "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", - "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", - "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled", - "logging_sink_created" - ] - }, - { - "Id": "ksi-piy", - "Name": "KSI-PIY: Policy and Inventory", - "Description": "A secure cloud service offering will have intentional, organized, universal guidance for how every information resource, including personnel, is secured", - "Attributes": [ - { - "ItemId": "ksi-piy", - "Section": "Policy and Inventory", - "Service": "gcp" - } - ], - "Checks": [ - "iam_cloud_asset_inventory_enabled", - "iam_organization_essential_contacts_configured", - "iam_audit_logs_enabled", - "compute_project_os_login_enabled", - "compute_instance_serial_ports_in_use", - "compute_instance_block_project_wide_ssh_keys_disabled", - "logging_sink_created" - ] - }, - { - "Id": "ksi-rpl", - "Name": "KSI-RPL: Recovery Planning", - "Description": "A secure cloud service offering will define, maintain, and test incident response plan(s) and recovery capabilities to ensure minimal service disruption and data loss", - "Attributes": [ - { - "ItemId": "ksi-rpl", - "Section": "Recovery Planning", - "Service": "gcp" - } - ], - "Checks": [ - "cloudsql_instance_automated_backups", - "cloudstorage_bucket_log_retention_policy_lock", - "cloudstorage_bucket_versioning_enabled", - "cloudstorage_bucket_lifecycle_management_enabled" - ] - }, - { - "Id": "ksi-svc", - "Name": "KSI-SVC: Service Configuration", - "Description": "A secure cloud service offering will follow FedRAMP encryption policies, continuously verify information resource integrity, and restrict access to third-party information resources", - "Attributes": [ - { - "ItemId": "ksi-svc", - "Section": "Service Configuration", - "Service": "gcp" - } - ], - "Checks": [ - "bigquery_dataset_cmk_encryption", - "bigquery_table_cmk_encryption", - "cloudsql_instance_mysql_local_infile_flag", - "cloudsql_instance_mysql_skip_show_database_flag", - "cloudsql_instance_postgres_enable_pgaudit_flag", - "cloudsql_instance_postgres_log_connections_flag", - "cloudsql_instance_postgres_log_disconnections_flag", - "cloudsql_instance_postgres_log_error_verbosity_flag", - "cloudsql_instance_postgres_log_min_duration_statement_flag", - "cloudsql_instance_postgres_log_min_error_statement_flag", - "cloudsql_instance_postgres_log_min_messages_flag", - "cloudsql_instance_postgres_log_statement_flag", - "cloudsql_instance_sqlserver_contained_database_authentication_flag", - "cloudsql_instance_sqlserver_cross_db_ownership_chaining_flag", - "cloudsql_instance_sqlserver_external_scripts_enabled_flag", - "cloudsql_instance_sqlserver_remote_access_flag", - "cloudsql_instance_sqlserver_trace_flag", - "cloudsql_instance_sqlserver_user_connections_flag", - "cloudsql_instance_sqlserver_user_options_flag", - "cloudsql_instance_ssl_connections", - "compute_instance_encryption_with_csek_enabled", - "compute_instance_shielded_vm_enabled", - "dataproc_encrypted_with_cmks_disabled", - "dns_dnssec_disabled", - "dns_rsasha1_in_use_to_key_sign_in_dnssec", - "dns_rsasha1_in_use_to_zone_sign_in_dnssec", - "kms_key_not_publicly_accessible", - "kms_key_rotation_enabled" - ] - }, - { - "Id": "ksi-tpr", - "Name": "KSI-TPR: Third-Party Information Resources", - "Description": "A secure cloud service offering will understand, monitor, and manage supply chain risks from third-party information resources", - "Attributes": [ - { - "ItemId": "ksi-tpr", - "Section": "Third-Party Information Resources", - "Service": "gcp" - } - ], - "Checks": [ - "artifacts_container_analysis_enabled", - "gcr_container_scanning_enabled", - "compute_public_address_shodan", - "cloudsql_instance_automated_backups", - "iam_sa_user_managed_key_rotate_90_days", - "iam_service_account_unused", - "gemini_api_disabled" - ] - }, - { - "Id": "ksi-iam-07", - "Name": "KSI-IAM-07: Account Lifecycle Management", - "Description": "Securely manage the lifecycle and privileges of all accounts, roles, and groups", - "Attributes": [ - { - "ItemId": "ksi-iam-07", - "Section": "Identity and Access Management", - "Service": "gcp" - } - ], - "Checks": [ - "apikeys_key_rotated_in_90_days", - "iam_sa_user_managed_key_rotate_90_days", - "iam_sa_user_managed_key_unused", - "iam_service_account_unused", - "compute_instance_default_service_account_in_use" - ] - }, - { - "Id": "ksi-mla-07", - "Name": "KSI-MLA-07: Monitoring and Logging Inventory", - "Description": "Maintain a list of information resources and event types that will be monitored, logged, and audited", - "Attributes": [ - { - "ItemId": "ksi-mla-07", - "Section": "Monitoring, Logging, and Auditing", - "Service": "gcp" - } - ], - "Checks": [ - "iam_audit_logs_enabled", - "iam_cloud_asset_inventory_enabled", - "logging_sink_created", - "compute_subnet_flow_logs_enabled", - "compute_network_dns_logging_enabled" - ] - } - ] -} diff --git a/prowler/compliance/gcp/hipaa_gcp.json b/prowler/compliance/gcp/hipaa_gcp.json index 37d9838e59..707834cfb1 100644 --- a/prowler/compliance/gcp/hipaa_gcp.json +++ b/prowler/compliance/gcp/hipaa_gcp.json @@ -18,8 +18,7 @@ ], "Checks": [ "iam_cloud_asset_inventory_enabled", - "securitycenter_security_health_analytics_enabled", - "essentialcontacts_security_contacts_configured" + "iam_organization_essential_contacts_configured" ] }, { @@ -34,23 +33,20 @@ } ], "Checks": [ - "cloudstorage_bucket_encryption", "cloudstorage_bucket_public_access", - "cloudstorage_bucket_uniform_access", - "cloudsql_instance_automatic_backups_enabled", - "cloudsql_instance_encryption_enabled", + "cloudstorage_bucket_uniform_bucket_level_access", + "cloudsql_instance_automated_backups", + "cloudsql_instance_cmek_encryption_enabled", "cloudsql_instance_public_access", "compute_instance_public_ip", - "compute_disk_encryption_enabled", - "compute_firewall_rdp_access_from_internet_restricted", - "compute_firewall_ssh_access_from_internet_restricted", - "compute_network_legacy_network_not_used", - "gke_cluster_master_authorized_networks_enabled", - "gke_cluster_private_cluster_enabled", + "compute_instance_encryption_with_csek_enabled", + "compute_firewall_rdp_access_from_the_internet_allowed", + "compute_firewall_ssh_access_from_the_internet_allowed", + "compute_network_not_legacy", "iam_sa_no_administrative_privileges", "iam_no_service_roles_at_project_level", "bigquery_dataset_public_access", - "bigquery_dataset_cmek_encryption", + "bigquery_dataset_cmk_encryption", "kms_key_rotation_enabled", "gemini_api_disabled" ] @@ -148,8 +144,7 @@ } ], "Checks": [ - "securitycenter_security_health_analytics_enabled", - "essentialcontacts_security_contacts_configured", + "iam_organization_essential_contacts_configured", "logging_sink_created" ] }, @@ -165,9 +160,7 @@ } ], "Checks": [ - "cloudsql_instance_automatic_backups_enabled", - "compute_disk_snapshot_encryption_enabled", - "gke_cluster_stackdriver_logging_enabled" + "cloudsql_instance_automated_backups" ] }, { @@ -182,9 +175,8 @@ } ], "Checks": [ - "cloudsql_instance_automatic_backups_enabled", - "cloudstorage_bucket_object_versioning", - "compute_disk_snapshot_encryption_enabled" + "cloudsql_instance_automated_backups", + "cloudstorage_bucket_versioning_enabled" ] }, { @@ -199,9 +191,8 @@ } ], "Checks": [ - "cloudsql_instance_automatic_backups_enabled", - "cloudsql_instance_point_in_time_recovery_enabled", - "cloudstorage_bucket_object_versioning" + "cloudsql_instance_automated_backups", + "cloudstorage_bucket_versioning_enabled" ] }, { @@ -247,9 +238,8 @@ ], "Checks": [ "compute_instance_public_ip", - "compute_firewall_rdp_access_from_internet_restricted", - "compute_firewall_ssh_access_from_internet_restricted", - "gke_cluster_private_cluster_enabled" + "compute_firewall_rdp_access_from_the_internet_allowed", + "compute_firewall_ssh_access_from_the_internet_allowed" ] }, { @@ -264,9 +254,7 @@ } ], "Checks": [ - "compute_disk_encryption_enabled", - "compute_disk_snapshot_encryption_enabled", - "cloudstorage_bucket_encryption" + "compute_instance_encryption_with_csek_enabled" ] }, { @@ -285,11 +273,10 @@ "iam_no_service_roles_at_project_level", "iam_account_access_approval_enabled", "cloudstorage_bucket_public_access", - "cloudstorage_bucket_uniform_access", + "cloudstorage_bucket_uniform_bucket_level_access", "cloudsql_instance_public_access", "bigquery_dataset_public_access", - "compute_instance_public_ip", - "gke_cluster_private_cluster_enabled" + "compute_instance_public_ip" ] }, { @@ -320,11 +307,9 @@ } ], "Checks": [ - "cloudstorage_bucket_encryption", - "cloudsql_instance_encryption_enabled", - "compute_disk_encryption_enabled", - "compute_disk_snapshot_encryption_enabled", - "bigquery_dataset_cmek_encryption", + "cloudsql_instance_cmek_encryption_enabled", + "compute_instance_encryption_with_csek_enabled", + "bigquery_dataset_cmk_encryption", "kms_key_rotation_enabled" ] }, @@ -348,8 +333,7 @@ "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled", "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", - "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", - "gke_cluster_stackdriver_logging_enabled" + "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled" ] }, { @@ -364,9 +348,8 @@ } ], "Checks": [ - "cloudstorage_bucket_object_versioning", - "cloudsql_instance_automatic_backups_enabled", - "cloudsql_instance_point_in_time_recovery_enabled", + "cloudstorage_bucket_versioning_enabled", + "cloudsql_instance_automated_backups", "kms_key_rotation_enabled" ] }, @@ -398,11 +381,9 @@ } ], "Checks": [ - "cloudstorage_bucket_encryption", - "compute_firewall_rdp_access_from_internet_restricted", - "compute_firewall_ssh_access_from_internet_restricted", - "cloudsql_instance_ssl_required", - "gke_cluster_master_authorized_networks_enabled" + "compute_firewall_rdp_access_from_the_internet_allowed", + "compute_firewall_ssh_access_from_the_internet_allowed", + "cloudsql_instance_ssl_connections" ] }, { @@ -417,8 +398,8 @@ } ], "Checks": [ - "cloudstorage_bucket_object_versioning", - "cloudsql_instance_automatic_backups_enabled", + "cloudstorage_bucket_versioning_enabled", + "cloudsql_instance_automated_backups", "logging_sink_created" ] }, @@ -434,12 +415,11 @@ } ], "Checks": [ - "cloudstorage_bucket_encryption", - "cloudsql_instance_encryption_enabled", - "compute_disk_encryption_enabled", - "bigquery_dataset_cmek_encryption", + "cloudsql_instance_cmek_encryption_enabled", + "compute_instance_encryption_with_csek_enabled", + "bigquery_dataset_cmk_encryption", "kms_key_rotation_enabled", - "cloudsql_instance_ssl_required" + "cloudsql_instance_ssl_connections" ] } ] diff --git a/prowler/compliance/gcp/iso27001_2022_gcp.json b/prowler/compliance/gcp/iso27001_2022_gcp.json index 9b814e7769..c43afcc810 100644 --- a/prowler/compliance/gcp/iso27001_2022_gcp.json +++ b/prowler/compliance/gcp/iso27001_2022_gcp.json @@ -247,8 +247,7 @@ "Checks": [ "iam_sa_user_managed_key_rotate_90_days", "kms_key_rotation_enabled", - "apikeys_key_rotated_in_90_days", - "kms_key_rotation_enabled" + "apikeys_key_rotated_in_90_days" ] }, { diff --git a/prowler/compliance/gcp/nis2_gcp.json b/prowler/compliance/gcp/nis2_gcp.json index 14d5477dc0..ba6fb849c3 100644 --- a/prowler/compliance/gcp/nis2_gcp.json +++ b/prowler/compliance/gcp/nis2_gcp.json @@ -889,7 +889,6 @@ "dns_dnssec_disabled", "dns_rsasha1_in_use_to_key_sign_in_dnssec", "dns_rsasha1_in_use_to_zone_sign_in_dnssec", - "bigquery_dataset_cmk_encryption", "bigquery_table_cmk_encryption", "compute_instance_encryption_with_csek_enabled", "dataproc_encrypted_with_cmks_disabled" diff --git a/prowler/compliance/gcp/prowler_threatscore_gcp.json b/prowler/compliance/gcp/prowler_threatscore_gcp.json index 923a1acfc2..5d8fb7de62 100644 --- a/prowler/compliance/gcp/prowler_threatscore_gcp.json +++ b/prowler/compliance/gcp/prowler_threatscore_gcp.json @@ -63,7 +63,7 @@ "Id": "1.2.1", "Description": "Ensure That There Are Only GCP-Managed Service Account Keys for Each Service Account", "Checks": [ - "iam_sa_no_user_managed_keysiam_sa_no_user_managed_keys" + "iam_sa_no_user_managed_keys" ], "Attributes": [ { diff --git a/prowler/compliance/gcp/soc2_gcp.json b/prowler/compliance/gcp/soc2_gcp.json index 8450967e29..4d093162f7 100644 --- a/prowler/compliance/gcp/soc2_gcp.json +++ b/prowler/compliance/gcp/soc2_gcp.json @@ -568,8 +568,7 @@ "cloudstorage_bucket_uniform_bucket_level_access", "bigquery_dataset_cmk_encryption", "bigquery_table_cmk_encryption", - "compute_instance_confidential_computing_enabled", - "pubsub_topic_encryption_with_cmk" + "compute_instance_confidential_computing_enabled" ] }, { diff --git a/prowler/compliance/github/cis_1.0_github.json b/prowler/compliance/github/cis_1.0_github.json index 2a60df6bcd..308a241bc6 100644 --- a/prowler/compliance/github/cis_1.0_github.json +++ b/prowler/compliance/github/cis_1.0_github.json @@ -1688,27 +1688,6 @@ } ] }, - { - "Id": "2.4.1", - "Description": "Sign all artifacts in all releases with user or organization keys.", - "Checks": [], - "Attributes": [ - { - "Section": "2 Build Pipelines", - "Subsection": "2.4 Pipeline Integrity", - "Profile": "Level 2", - "AssessmentStatus": "Manual", - "Description": "Sign all artifacts in all releases with user or organization keys.", - "RationaleStatement": "Signing artifacts is used to validate both their integrity and security. Organizations signal that artifacts may be trusted and they themselves produced them by ensuring that every artifact is properly signed. The presence of this signature also makes potentially malicious activity far more difficult.", - "ImpactStatement": "", - "RemediationProcedure": "For every artifact in every release, verify that all are properly signed.", - "AuditProcedure": "Ensure every artifact in every release is signed.", - "AdditionalInformation": "", - "References": "", - "DefaultValue": "" - } - ] - }, { "Id": "2.4.2", "Description": "External dependencies may be public packages needed in the pipeline, or perhaps the public image being used for the build worker. Lock these external dependencies in every build pipeline.", diff --git a/prowler/compliance/googleworkspace/cis_1.3_googleworkspace.json b/prowler/compliance/googleworkspace/cis_1.3_googleworkspace.json index 167842af4b..8da36da5e2 100644 --- a/prowler/compliance/googleworkspace/cis_1.3_googleworkspace.json +++ b/prowler/compliance/googleworkspace/cis_1.3_googleworkspace.json @@ -1498,9 +1498,7 @@ { "Id": "4.1.4.1", "Description": "Ensure login challenges are enforced", - "Checks": [ - "security_login_challenges_configured" - ], + "Checks": [], "Attributes": [ { "Section": "4 Security", diff --git a/prowler/compliance/googleworkspace/cis_1.4_googleworkspace.json b/prowler/compliance/googleworkspace/cis_1.4_googleworkspace.json new file mode 100644 index 0000000000..7958a7772c --- /dev/null +++ b/prowler/compliance/googleworkspace/cis_1.4_googleworkspace.json @@ -0,0 +1,1943 @@ +{ + "Framework": "CIS", + "Name": "CIS Google Workspace Foundations Benchmark v1.4.0", + "Version": "1.4", + "Provider": "GoogleWorkspace", + "Description": "The CIS Google Workspace Foundations Benchmark provides prescriptive guidance for establishing a secure configuration posture for Google Workspace. This benchmark covers Directory, Devices, Apps, Security, Reporting, and Rules configurations.", + "Requirements": [ + { + "Id": "1.1.1", + "Description": "Ensure that between two and four global admins are designated", + "Checks": [ + "directory_super_admin_count" + ], + "Attributes": [ + { + "Section": "1 Directory", + "SubSection": "1.1 Users", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Having more than one Super Admin account is needed primarily so that a single point of failure can be avoided. Also, for larger organizations, having multiple Super Admins can be useful for workload balancing purposes.", + "RationaleStatement": "From a security point of view, having only a single Super Admin Account can be problematic if this user were unavailable for an extended period of time. Also, Super Admin accounts should never be shared amongst multiple users. From a security point of view, having a large number of Super Admin accounts is a bad practice. In general, all users should be assigned the least privileges needed to do their job. This includes Administrators since not everyone that needs to \"Administer Something\" needs to be a Super Admin. Google Workspaces provides many predefined Administration Roles and also allows the creation of Custom Roles with very granular permission selection.", + "ImpactStatement": "There should be no user impact, but Administrators should have a normal (low privilege) and an Administrative (high privilege) account.", + "RemediationProcedure": "Create at least one additional account with a Super Admin role if there is only 1 Super Admin account. If more that 4 accounts with Super Admin access, reduce the number of accounts with a Super Admin role. NOTE: A new account should be created vs adding this role to an existing account since Administration tasks should be done through separate Admin accounts.", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Go to Directory and click on Users, this will show a list of all users 3. Click on + Add a filter, select Admin role, check the Super admin box, and then select Apply 4. The list of Users displayed will only be those with the Super Admin role 5. Make sure more than one (1) user is listed 6. Make sure no more than four (4) users are listed", + "AdditionalInformation": "", + "DefaultValue": "All Google Workspace tenants will have one Super Admin initially.", + "References": "" + } + ] + }, + { + "Id": "1.1.2", + "Description": "Ensure super admin accounts are used only for super admin activities", + "Checks": [ + "directory_super_admin_only_admin_roles" + ], + "Attributes": [ + { + "Section": "1 Directory", + "SubSection": "1.1 Users", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Super admin accounts have access to all features in the Google Admin console and Admin API and can manage every aspect of your organization's account. Super admins also have full access to all users' calendars and event details. It is recommended to give each super administrator two accounts. One for their super admin account and a second account for daily activities. Users should only sign in to a super admin account to perform super admin tasks, such as setting up 2-Step Verification (2SV), managing billing and user licenses, or helping another admin recover their account. Super administrators should use a separate, non-admin account for day-to-day activities. Super admins should sign in as needed to do specific tasks and then sign out. Leaving super admin accounts sign-in can increase exposure to phishing attacks.", + "RationaleStatement": "Use the super admin account only when needed. Delegate administrator tasks to user accounts with limited admin roles. Use the least privilege approach, where each user has access to the resources and tools needed for their typical tasks. For example, you could grant an admin permissions to create user accounts and reset passwords, but not let them delete user accounts.", + "ImpactStatement": "Super admin users will have to switch accounts as well as utilize login/logout functionality when performing administrative tasks.", + "RemediationProcedure": "For every Super admin that is also a Delegated admin account, either create a Delegated admin account for the user of elevate or their existing non-admin account to a Delegated admin account.", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Go to Directory and click on Users, this will show a list of all users 3. Click on + Add a filter, select Admin role, check the Super admin box, and then select Apply 4. The list of Users displayed will only be those with the Super Admin role 5. Click on + Add a filter, select Admin role, check the Delegated admin box, and then select Apply 6. Verify that there are no users in both the Super admin and Delegated admin roles", + "AdditionalInformation": "", + "DefaultValue": "N/A", + "References": "https://support.google.com/a/answer/179832?hl=en" + } + ] + }, + { + "Id": "1.2.1.1", + "Description": "Ensure directory data access is externally restricted", + "Checks": [], + "Attributes": [ + { + "Section": "1 Directory", + "SubSection": "1.2 Directory Settings", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Configure Google Workspace's external directory sharing to prevent unrestricted directory data access.", + "RationaleStatement": "If your organization uses third-party apps that integrate with your Google services, you control how much Directory information the external apps can access. If you allow directory access, your users have a better experience with external apps. For example, when they use a third-party mail app, they want to find domain contacts and have email addresses automatically complete. The app needs access to Directory data to make this happen. However, this has the ability to share ALL domain AND public data with the connected third-party app. Public data and authenticated user basic profile fields — Share publicly visible domain profile data with external apps and APIs. Also share the authenticated user's name, photo, and email address to enable Google Sign-In if the appropriate scopes are granted. Other non-public profile fields for the authenticated user aren't shared. All the non-public profile information of other users in the domain aren't shared. Domain and public data — (Default) Share all Directory information that’s shared with your domain and public data. This information includes profile information for users in your domain, shared external contacts, and Google+ profile names and photos.", + "ImpactStatement": "The External directory sharing setting applies only to the following APIs and the Apps Scripts or third-party Marketplace apps that use those APIs: Google People API Google CardDAV API Google Contacts API v3 The setting applies only to third-party apps, such as iOS Mail and iOS Contacts (when enrolled on an iOS device via Add Account and then Google), third-party Contacts apps (on Android). The setting doesn't apply to Google products, including mobile apps, such as the following Gmail, Contacts (on Android), Inbox, Meet, and other Google mobile apps iOS Mail and iOS Contacts using Google Sync (when enrolled on an iOS device through Add Account and then Exchange) Workspace Sync for Microsoft Outlook", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Open the collapsed menu via \"hamburger button \\ 3 horizontal lines\" 3. Under Directory, select Directory settings 4. Under Sharing settings, select External Directory sharing 5. Select Authenticated user basic profile fields", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Open the collapsed menu via \"hamburger button \\ 3 horizontal lines\" 3. Under Directory, select Directory settings 4. Under Sharing settings, select External Directory sharing 5. Ensure Authenticated user basic profile fields is set 6. Select Save", + "AdditionalInformation": "", + "DefaultValue": "• External Directory sharing = Domain and public data", + "References": "" + } + ] + }, + { + "Id": "3.1.1.1.1", + "Description": "Ensure external sharing options for primary calendars are configured", + "Checks": [ + "calendar_external_sharing_primary_calendar" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.1 Calendar", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Control how much calendar information users in your organization can share externally.", + "RationaleStatement": "Prevent data leakage by restricting the amount of information that is externally viewable when a user shares their calendar with someone external to your organization.", + "ImpactStatement": "Once you limit external sharing for your organization, users can't exceed these limits when sharing individual events. For example, if you limit your organization's external sharing to Free/Busy, events with Public visibility are only shared as Free/Busy. External mobile users who previously synced events may keep seeing restricted details. That access stops when their device is wiped and re-synced. If you lower the external sharing level, people outside your organization may lose access to calendars they could previously see.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Calendar 5. Under Sharing settings, select External sharing options for primary calendars 6. Select Only free/busy information (hide event details) 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Calendar 5. Under Sharing settings, select External sharing options for primary calendars 6. Ensure Only free/busy information (hide event details) is selected", + "AdditionalInformation": "", + "DefaultValue": "External sharing options for primary calendars is Only free/busy information (hide event details)", + "References": "" + } + ] + }, + { + "Id": "3.1.1.1.2", + "Description": "Ensure internal sharing options for primary calendars are configured", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.1 Calendar", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Control how much calendar information users in your organization can share internally.", + "RationaleStatement": "In general, not everyone in the organization needs to know the schedule details of everyone else (operational security). Free/busy indication is enough for most people.", + "ImpactStatement": "This will be the default for the user's primary calendar. The user can override this setting to allow other specific users greater visibility of their calendar.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Calendar 5. Under Sharing settings, select Internal sharing options for primary calendars 6. Select Only free/busy information (hide event details) 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Calendar 5. Under Sharing settings, select Internal sharing options for primary calendars 6. Ensure Only free/busy information (hide event details) is selected", + "AdditionalInformation": "", + "DefaultValue": "Internal sharing options for primary calendars is Share all information", + "References": "" + } + ] + }, + { + "Id": "3.1.1.1.3", + "Description": "Ensure external invitation warnings for Google Calendar are configured", + "Checks": [ + "calendar_external_invitations_warning" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.1 Calendar", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Configure Google Calendar to warn users when inviting guest outside your domain.", + "RationaleStatement": "When your users create a Google Calendar event that includes one or more guests from outside of your domain, they are prompted to confirm whether it’s OK to include external guests in the event invitation, assisting in the prevention of unintentional data leakage.", + "ImpactStatement": "Users will be prompted to allow the inclusion of external guests in an event invitation.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Calendar 5. Under Sharing settings, select External Invitations 6. Set Warn users when inviting guests outside of the domain to checked 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Calendar 5. Under Sharing settings, select External invitations 6. Ensure Warn users when inviting guests outside of the domain is checked", + "AdditionalInformation": "", + "DefaultValue": "Warn users when inviting guests outside of the domain is checked", + "References": "" + } + ] + }, + { + "Id": "3.1.1.2.1", + "Description": "Ensure external sharing options for secondary calendars are configured", + "Checks": [ + "calendar_external_sharing_secondary_calendar" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.1 Calendar", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Control how much calendar information users in your organization can share externally.", + "RationaleStatement": "Prevent data leakage by restricting the amount of information is externally viewable when a user shares their calendar with someone external to your organization.", + "ImpactStatement": "Once you limit external sharing for your organization, users can't exceed these limits when sharing individual events. For example, if you limit your organization's external sharing to Free/Busy, events with Public visibility are only shared as Free/Busy. External mobile users who previously synced events may keep seeing restricted details. That access stops when their device is wiped and re-synced. If you lower the external sharing level, people outside your organization may lose access to calendars they could previously see.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Calendar 5. Under General settings, select External sharing options for secondary calendars 6. Select Only free/busy information (hide event details) 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Calendar 5. Under General settings, select External sharing options for secondary calendars 6. Ensure Only free/busy information (hide event details) is selected", + "AdditionalInformation": "", + "DefaultValue": "External sharing options for secondary calendars is Share all information, but outsiders cannot change calendars", + "References": "" + } + ] + }, + { + "Id": "3.1.1.2.2", + "Description": "Ensure internal sharing options for secondary calendars are configured", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.1 Calendar", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Control how much calendar information users in your organization can share internally.", + "RationaleStatement": "In general, not everyone in the organization needs to know the schedule details of everyone else (operational security). Free/busy indication is enough for most people.", + "ImpactStatement": "This will be the default for the user's secondary calendars. The user can override this setting to allow other specific users greater visibility of their calendars.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Calendar 5. Under General settings, select Internal sharing options for secondary calendars 6. Select Only free/busy information (hide event details) 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Calendar 5. Under General settings, select Internal sharing options for secondary calendars 6. Ensure Only free/busy information (hide event details) is selected", + "AdditionalInformation": "", + "DefaultValue": "Internal sharing options for secondary calendars is Share all information", + "References": "" + } + ] + }, + { + "Id": "3.1.1.3.1", + "Description": "Ensure calendar web offline is disabled", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.1 Calendar", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Limit who is allowed offline calendar access.", + "RationaleStatement": "When enabled, users can turn on offline use for each computer they use. Data is stored on the computer until offline use is turned off by the user. In this case, the organization can lose control of where its data is stored (for this user). Care should be taken regarding which users and groups have this capability enabled.", + "ImpactStatement": "Users will not be able to access their calendars offline.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Calendar 5. Under Advanced settings, select Calendar web offline 6. Set Allow using Calendar on the web when offline to unchecked 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Calendar 5. Under Advanced settings, select Calendar web offline 6. Ensure Allow using Calendar on the web when offline is unchecked", + "AdditionalInformation": "", + "DefaultValue": "Allow using Calendar on the web when offline is checked", + "References": "" + } + ] + }, + { + "Id": "3.1.2.1.1.1", + "Description": "Ensure users are warned when they share a file outside their domain", + "Checks": [ + "drive_external_sharing_warn_users" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.2 Drive and Docs", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Warn the user when they try and share a file and/or shared drive externally.", + "RationaleStatement": "The user may not realize the potential account is external to the organization. Providing a warning allows the user an opportunity to know this and possibly reassess this sharing.", + "ImpactStatement": "None, except an additional warning. Sharing can still occur.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Drive and Docs 4. Select Sharing Settings 5. Select Sharing Options 6. Under Sharing outside of 7. Set ON - Files owned by users in can be shared outside of . This applies to files in all shared drives as well. to checked. Also, set the sub-setting For files owned by users in warn when sharing outside of to checked. 8. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Drive and Docs 4. Select Sharing Settings 5. Select Sharing Options 6. Under Sharing outside of 7. Ensure ON - Files owned by users in can be shared outside of . This applies to files in all shared drives as well. is checked. Also, ensure the sub-setting For files owned by users in warn when sharing outside of is checked.", + "AdditionalInformation": "", + "DefaultValue": "For files owned by users in warn when sharing outside of is checked", + "References": "" + } + ] + }, + { + "Id": "3.1.2.1.1.2", + "Description": "Ensure users cannot publish files to the web or make visible to the world as public or unlisted", + "Checks": [ + "drive_publishing_files_disabled" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.2 Drive and Docs", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "You should control the publishing of documents to the web or making them visable to the world as public or unlisted.", + "RationaleStatement": "Attackers will often attempt to expose sensitive information to external entities through sharing, and restricting the methods that your users can share documents with will reduce that surface area. This setting is only applicable if ON - Files owned by users in can be shared outside of . This applies to files in all shared drives as well is selected, but should be configured as described below to prevent unintentional document publishing.", + "ImpactStatement": "Enabling this feature will prevent users from publishing documents on the web or making them visible to the world as public or unlisted files.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Under Sharing settings, select Sharing options 6. Under Sharing outside of - ON - Files owned by users in can be shared outside of . This applies to files in all shared drives as well, set When sharing outside of is allowed, users in can make files and published web content visible to anyone with the link to unchecked 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Under Sharing settings, select Sharing options 6. Under Sharing outside of - ON - Files owned by users in can be shared outside of . This applies to files in all shared drives as well, ensure When sharing outside of is allowed, users in can make files and published web content visible to anyone with the link is unchecked", + "AdditionalInformation": "", + "DefaultValue": "When sharing outside of is allowed, users in can make files and published web content visible to anyone with the link is Checked", + "References": "" + } + ] + }, + { + "Id": "3.1.2.1.1.3", + "Description": "Ensure document sharing is being controlled by domain with allowlists", + "Checks": [ + "drive_sharing_allowlisted_domains" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.2 Drive and Docs", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "You should control sharing of documents to external domains by either blocking domains or only allowing sharing with specific named domains.", + "RationaleStatement": "Attackers will often attempt to expose sensitive information to external entities through sharing, and restricting the domains that your users can share documents with will reduce that surface area.", + "ImpactStatement": "Enabling this feature will prevent users from sharing documents with domains outside of the organization unless allowed.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Under Sharing settings, select Sharing options 6. Under Sharing outside of , select ALLOWLISTED DOMAINS - Files owned by users in can be shared with Google Accounts in compatible allowlisted domains. 7. Set Warn when files owned by users or shared drives in are shared with users in allowlisted domains to checked 8. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Under Sharing settings, select Sharing options 6. Under Sharing outside of , ensure ALLOWLISTED DOMAINS - Files owned by users in can be shared with Google Accounts in compatible allowlisted domains. is selected 7. Ensure Warn when files owned by users or shared drives in are shared with users in allowlisted domains is checked", + "AdditionalInformation": "", + "DefaultValue": "Sharing outside of is ON - Files owned by users in can be shared outside of . This applies to files in all shared drives as well.", + "References": "" + } + ] + }, + { + "Id": "3.1.2.1.1.4", + "Description": "Ensure users are warned when they share a file with users in an allowlisted domain", + "Checks": [ + "drive_warn_sharing_with_allowlisted_domains" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.2 Drive and Docs", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Warn the user when they try and share a file and/or shared drive with users in an allowlisted domain.", + "RationaleStatement": "The user may not realize the potential account is external to the organization. Providing a warning allows the user an opportunity to know this and possibly reassess this sharing.", + "ImpactStatement": "None, except an additional warning. Sharing can still occur.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Drive and Docs 4. Select Sharing Settings 5. Select Sharing Options 6. Under Sharing outside of 7. Set ALLOWLISTED DOMAINS - Files owned by users or shared drives in BMDT-Group can be shared with Google accounts in compatible allowlisted domains. to checked. Also, set the sub-setting Warn when files owned by users or shared drives in are shared with users in allowlisted domains to checked. 8. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Drive and Docs 4. Select Sharing Settings 5. Select Sharing Options 6. Under Sharing outside of 7. Ensure ALLOWLISTED DOMAINS - Files owned by users or shared drives in BMDT-Group can be shared with Google accounts in compatible allowlisted domains is checked. Also, ensure the sub-setting Warn when files owned by users or shared drives in are shared with users in allowlisted domains is checked.", + "AdditionalInformation": "", + "DefaultValue": "For files owned by users in warn when sharing outside of is checked", + "References": "" + } + ] + }, + { + "Id": "3.1.2.1.1.5", + "Description": "Ensure Access Checker is configured to limit file access", + "Checks": [ + "drive_access_checker_recipients_only" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.2 Drive and Docs", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "When a user shares a file via a Google product other than Docs or Drive (e.g. by pasting a link in Gmail), Google can check that the recipients have access. If not, when possible, Google will ask the user to pick how they want to share the file.", + "RationaleStatement": "In general, access should be restricted to the smallest group possible. In this case recipients only.", + "ImpactStatement": "Only recipients can access files. Recipients cannot share access with others by forwarding the email/link.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Drive and Docs 4. Select Sharing Settings 5. Select Sharing Options 6. Under Access Checker 7. Set Recipients only. to checked 8. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Drive and Docs 4. Select Sharing Settings 5. Select Sharing Options 6. Under Access Checker 7. Ensure Recipients only. is checked", + "AdditionalInformation": "", + "DefaultValue": "Recipients only, suggested target audience, or public (no Google account required). is checked", + "References": "" + } + ] + }, + { + "Id": "3.1.2.1.1.6", + "Description": "Ensure only users inside your organization can distribute content externally", + "Checks": [ + "drive_internal_users_distribute_content" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.2 Drive and Docs", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "You should control who is allowed to distribute organizational content to shared drives owned by another organization.", + "RationaleStatement": "Sharing and collaboration are key; however, only your users should have the authority over where company content is shared with to prevent unauthorized disclosures of information.", + "ImpactStatement": "Only people in your organization with Manager access to a shared drive can move files from that shared drive to a Drive location in a different organization. In addition, users in the selected organizational unit or group can copy content from their My Drive to a shared drive owned by a different organization.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Under Sharing settings, select Sharing options 6. Under Distributing content outside of , select - Only users in 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Under Sharing settings, select Sharing options 6. Under Distributing content outside of , ensure Only users in is selected", + "AdditionalInformation": "", + "DefaultValue": "Distributing content outside of is Anyone", + "References": "" + } + ] + }, + { + "Id": "3.1.2.1.2.1", + "Description": "Audit users ability to create new shared drives", + "Checks": [ + "drive_shared_drive_creation_allowed" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.2 Drive and Docs", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Organizations should determine if all users should have the ability to create new shared drives.", + "RationaleStatement": "Organizations should allow users to create shared drives only if it is allowed under your organization's DLP restrictions and meets your organization's requirements. By default, when a user account is deleted all the data in their personal drive is deleted as well. In this case, administrators should transfer ownership of a user's files to another user. Note: See additional information on transferring files.", + "ImpactStatement": "Disabling this feature will prevent users from creating new shared drives.", + "RemediationProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Under Sharing settings, select Shared drive creation 6. Set Prevent users in from creating new shared drives to unchecked 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Under Sharing settings, select Shared drive creation 6. Ensure Prevent users in from creating new shared drives is un-checked", + "AdditionalInformation": "To transfer ownership of a user's files via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Select Transfer ownership 6. Under From user, enter the user's organizational email address that needs to be transferred 7. Under To user, enter the user's organizational email address that is receiving ownership of the files Transfer Drive files to a new owner as an admin", + "DefaultValue": "Prevent users in from creating new shared drives is unchecked", + "References": "" + } + ] + }, + { + "Id": "3.1.2.1.2.2", + "Description": "Ensure manager access members cannot modify shared drive settings", + "Checks": [ + "drive_shared_drive_managers_cannot_override" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.2 Drive and Docs", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Only administrators should be able to modify shared drive settings.", + "RationaleStatement": "Allowing manager access members to override or modify shared drive settings can allow intentional and unintentional data access by unauthorized users.", + "ImpactStatement": "Disabling this feature will prevent manager access members from modifying shared drive settings, requiring administrators to perform settings modifications as required.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Select Sharing settings 6. Under Shared drive creation, set Allow members with manager access to override the settings below to unchecked 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Select Sharing settings 6. Under Shared drive creation, ensure Allow members with manager access to override the settings below is unchecked", + "AdditionalInformation": "", + "DefaultValue": "Allow members with manager access to override the settings below is checked", + "References": "" + } + ] + }, + { + "Id": "3.1.2.1.2.3", + "Description": "Ensure shared drive file access is restricted to members only", + "Checks": [ + "drive_shared_drive_members_only_access" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.2 Drive and Docs", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Shared drive file access should be restricted to that shared drive's members", + "RationaleStatement": "Preventing unauthorized users from access sensitive data is paramount in preventing unauthorized or unintentional information disclosures.", + "ImpactStatement": "Disabling this feature will prevent shared drive non-members from accessing content in shared drives where they are not a member.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Select Sharing settings 6. Under Shared drive creation, set Allow people who aren't shared drive members to be added to files to unchecked 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Select Sharing settings 6. Under Shared drive creation, ensure Allow people who aren't shared drive members to be added to files is unchecked", + "AdditionalInformation": "", + "DefaultValue": "Allow people who aren't shared drive members to be added to files is checked", + "References": "" + } + ] + }, + { + "Id": "3.1.2.1.2.4", + "Description": "Ensure 'Download, print, and copy is enabled for' is not set to 'Everyone (Managers, content managers, contributors, commenters and viewers)'", + "Checks": [ + "drive_shared_drive_disable_download_print_copy" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.2 Drive and Docs", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "limit what viewers/commenters on a shared document can do with it.", + "RationaleStatement": "In many cases when sharing a document it might be fine for the users to do what they want with the document on the shared drive (Download, Print, etc.). In more restricted environments these capabilities may need to be prevented (Protected Intellectual property, Personally Identifiable Information, etc.).", + "ImpactStatement": "Users of this shared drive will be restricted to only reading and commenting on the existing files.", + "RemediationProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Select Sharing settings 6. Under Shared drive creation, set Download, print, and copy is enabled for to not Everyone (Managers, content managers, contributors, commenters and viewers) 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Select Sharing settings 6. Under Shared drive creation, ensure Download, print, and copy is enabled for is not set to Everyone (Managers, content managers, contributors, commenters and viewers)", + "AdditionalInformation": "", + "DefaultValue": "Allow viewers and commenters to download, print, and copy files is unchecked", + "References": "" + } + ] + }, + { + "Id": "3.1.2.2.1", + "Description": "Ensure offline access to documents is disabled", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.2 Drive and Docs", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Prevent documents from being locally accessible on an unconnected device.", + "RationaleStatement": "This setting prevents an organization's files from being stored locally, thus limiting data loss issues if the device is lost or stolen.", + "ImpactStatement": "Copies of recent files are only synced and saved on devices if you've defined a managed policy to do so. NOTE: All users will lose access to offline documents on all devices if managed devices policies are not set. NOTE: Setting up policies to control offline access on individual devices is outside the scope of this Benchmark. Additional information om doing this for various device types can be found here.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Select Features and Applications 6. Select Offline 7. Set Control offline access using device policies. to checked 8. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Select Features and Applications 6. Select Offline 7. Ensure Control offline access using device policies is checked", + "AdditionalInformation": "", + "DefaultValue": "Control offline access using device policies is unchecked", + "References": "" + } + ] + }, + { + "Id": "3.1.2.3.1", + "Description": "Ensure desktop access to Drive is disabled", + "Checks": [ + "drive_desktop_access_disabled" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.2 Drive and Docs", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Prevent documents from being locally accessible on an unconnected device.", + "RationaleStatement": "This setting prevents an organization's files from being stored locally, thus limiting data loss issues if the device is lost or stolen. NOTE: The Google Drive desktop application has its own way of handling \"Offline\" files and does not obey the Drive and Doc > Offline > Control offline access using divide policies setting. Not allowing Google Drive for desktop on the device will prevent this channel.", + "ImpactStatement": "The end user will not be able to use Google Drive for desktop and its convenient integration into the Windows file explorer.", + "RemediationProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Select Google Drive for desktop 6. Set Allow Google Drive for desktop in your organization to unchecked 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Select Google Drive for desktop 6. Ensure Allow Google Drive for desktop in your organization is unchecked", + "AdditionalInformation": "", + "DefaultValue": "Allow Google Drive for desktop in your organization is checked", + "References": "" + } + ] + }, + { + "Id": "3.1.3.1.1", + "Description": "Ensure users cannot delegate access to their mailbox", + "Checks": [ + "gmail_mail_delegation_disabled" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Mail delegation allows the delegate to read, send, and delete messages on their behalf. For example, a manager can delegate Gmail access to another person in their organization, such as an administrative assistant.", + "RationaleStatement": "Only administrators should be able to delegate access to a user's mailboxes.", + "ImpactStatement": "Existing delegations will be hidden, when this feature is disabled.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under User Settings - Mail delegation, set Let users delegate access to their mailbox to other users in the domain to unchecked 6. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under User Settings - Mail delegation, ensure Let users delegate access to their mailbox to other users in the domain is unchecked", + "AdditionalInformation": "", + "DefaultValue": "Let users delegate access to their mailbox to other users in the domain is unchecked", + "References": "" + } + ] + }, + { + "Id": "3.1.3.1.2", + "Description": "Ensure offline access to Gmail is disabled", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Disables the user's ability to utilize various Gmail functions (read, write, search, delete, and label email messages) while not connected to the internet.", + "RationaleStatement": "Prevents the organization's data (user's email) from being copied to remote computers.", + "ImpactStatement": "Users will need internet access to use Gmail.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Gmail 4. Select User Settings 5. SelectGmail web offline 6. Set Enable Gmail web offline to unchecked 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Gmail 4. Select User Settings 5. Under Gmail web offline 6. Ensure Enable Gmail web offline is unchecked", + "AdditionalInformation": "", + "DefaultValue": "Enable Gmail web offline is unchecked", + "References": "" + } + ] + }, + { + "Id": "3.1.3.2.1", + "Description": "Ensure that DKIM is enabled for all mail enabled domains", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "DKIM adds an encrypted signature to the header of all outgoing messages. Email servers that get signed messages use DKIM to decrypt the message header, and verify the message was not changed after it was sent.", + "RationaleStatement": "Spoofing is a common unauthorized use of email, so some email servers require DKIM to prevent email spoofing.", + "ImpactStatement": "There should be no impact of setting up DKIM however, organizations should ensure appropriate setup to ensure continuous mail-flow.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Authenticate email, select - Generate new record 6. Under Select DKIM key bit length, select the appropriate key bit length 2048 is recommended if supported 7. Under Prefix selector (optional), enter the appropriate prefix selector 8. Use the text at TXT record value to update the DNS record at your domain host 9. Select Start Authentication", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Authenticate email, ensure a DKIM record exists for each mail enabled domain", + "AdditionalInformation": "", + "DefaultValue": "None", + "References": "" + } + ] + }, + { + "Id": "3.1.3.2.2", + "Description": "Ensure the SPF record is configured for all mail enabled domains", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "For all the email domains configured in Google Workspace, a corresponding Sender Policy Framework (SPF) record should be created. NOTE: There are a number of ways SPF can be configured, this document presents a most basic method. For more information on setting up SPF for Google Workspace please refer to the Google documentation. • How SPF protects against spoofing and spam • Define your SPF record—Basic setup", + "RationaleStatement": "SPF records allow Gmail and other mail systems to know where messages from your domains are allowed to originate. This information can be used by that system to determine how to treat the message based on if it is being spoofed or is valid.", + "ImpactStatement": "There should be minimal impact of setting up SPF records however, organizations should ensure proper SPF record setup as email could be flagged as spam if SPF is not set up appropriately.", + "RemediationProcedure": "Configure the DNS record for each domain. • If all email in your domain is sent from and received by Google Gmail, add the following TXT record for each domain: v=spf1 include:_spf.google.com ~all NOTE: This will likely need to be configured at your domain registrar (Godaddy, etc.).", + "AuditProcedure": "Check the DNS records for each domain. 1. Use a Domain Name System (DNS) lookup tool to review the current configuration for your domain (DNS Records). This information can be discovered in a variety of ways: o Reviewing the DNS Record information at your domain registrar (GoDaddy, etc.) o Using an OS based nslookup tool on your workstation OS o Using Google Dig tool available from the Google Admin Toolbox site (Link: Dig) 2. Using the chosen tool, enter your email domain name (ex. domain1.com) 3. In the results displayed, ensure that a TXT Record with the value of v=spf1 include:_spf.google.com ~all exists and designates Google Gmail as a authorized sender.", + "AdditionalInformation": "", + "DefaultValue": "None", + "References": "" + } + ] + }, + { + "Id": "3.1.3.2.3", + "Description": "Ensure the DMARC record is configured for all mail enabled domains", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "For all email domains configured in Google Workspace, a corresponding Domain-Based Message Authentication, Reporting and Conformance (DMARC) record should be created. NOTE: There are a number of ways DMARC can be configured, this document presents a most basic method. For more information on setting up DMARC for Google Workspace please refer to the Google documentation. • Help prevent spoofing and spam with DMARC • Tutorial: Recommended DMARC rollout", + "RationaleStatement": "DMARC works with Sender Policy Framework (SPF) and Domain Keys Identified Mail (DKIM) to authenticate mail senders and ensure that destination email systems trust messages sent from your domain. Spammers can spoof your domain or organization to send fake messages that impersonate your organization. DMARC tells receiving mail servers what to do when they get a message that appears to be from your organization, but doesn't pass authentication checks, or doesn’t meet the authentication requirements in your DMARC policy record. Messages that aren't authenticated might be impersonating your organization, or might be sent from unauthorized servers.", + "ImpactStatement": "There should be minimal impact of setting up DMARC records however, organizations should ensure proper DMARC record setup as email could be flagged as spam if DMARC is not set up appropriately.", + "RemediationProcedure": "Configure the DNS record for each domain. 1. If all email in your domain is sent from and received by Google Gmail, add the following TXT record for the domain: v=DMARC1; p=none; rua=mailto: NOTE: This will likely need to be configured at your domain registrar (Godaddy, etc.).", + "AuditProcedure": "Check the DNS records for each domain. 1. Use a Domain Name System (DNS) lookup tool to review the current configuration for your domain (DNS Records). This information can be discovered in a variety of ways: o Reviewing the DNS Record information at your domain registrar (GoDaddy, etc.) o Using an OS based nslookup tool on your workstation OS o Preferred: Using Google Dig tool available from the Google Admin Toolbox site (Link: Dig) 2. Using the chosen tool, enter your email domain name (ex. domain1.com) 3. In the results displayed, ensure that a TXT Record with the value of v=DMARC1; p=none; rua=mailto: exists. This designates Google Gmail as an authorized sender. NOTE: The p=none sets DMARC to non-enforcing. This is a relaxed DMARC policy that lets you start getting reports without risking messages from your domain being rejected or marked as spam by receiving servers. Start with a none policy that only monitors email flow, and then eventually change to a policy that rejects all unauthenticated messages (p=reject). NOTE: The rua=mailto:_report@domain1.com entry is optional but setting it to a valid email address is recommended. RUA reports provide a comprehensive view of all of a domain’s traffic. At a minimum, organizations should configure their DMARC record to receive RUA reports. The Difference in DMARC Reports: RUA and RUF", + "AdditionalInformation": "", + "DefaultValue": "None", + "References": "" + } + ] + }, + { + "Id": "3.1.3.3.1", + "Description": "Enable quarantine admin notifications for Gmail", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Quarantines can help prevent spam, minimize data loss, and protect confidential information. They can also help moderate message attachments so users don’t send, open, or click something they shouldn’t.", + "RationaleStatement": "Admins should be notified periodically when messages are quarantined so they can take the appropriate actions.", + "ImpactStatement": "Admins will begin receiving quarantine notifications as emails are quarantined.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Manage quarantines, set Notify periodically when messages are quarantined to checked As required, give appropriate users the Access Admin Quarantine and\\or Access restricted quarantine roles", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Manage quarantines, ensure each quarantine has Notify periodically when messages are quarantined is checked", + "AdditionalInformation": "", + "DefaultValue": "Notify periodically when messages are quarantined is unchecked", + "References": "" + } + ] + }, + { + "Id": "3.1.3.4.1.1", + "Description": "Ensure protection against encrypted attachments from untrusted senders is enabled", + "Checks": [ + "gmail_encrypted_attachment_protection_enabled" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "As a Google Workspace administrator, you can protect incoming mail against phishing and harmful software (malware). You can also choose what action to take based on the type of threat detected.", + "RationaleStatement": "You should protect your users from potentially malicious attachments.", + "ImpactStatement": "Users will be warned when they receive an encrypted attachment from an untrusted sender.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Attachments, set Protect against encrypted attachments from untrusted senders to checked 6. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Attachments, ensure Protect against encrypted attachments from untrusted senders is checked", + "AdditionalInformation": "", + "DefaultValue": "Protect against encrypted attachments from untrusted senders is checked", + "References": "" + } + ] + }, + { + "Id": "3.1.3.4.1.2", + "Description": "Ensure protection against attachments with scripts from untrusted senders is enabled", + "Checks": [ + "gmail_script_attachment_protection_enabled" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "As a Google Workspace administrator, you can protect incoming mail against phishing and harmful software (malware). You can also choose what action to take based on the type of threat detected.", + "RationaleStatement": "You should protect your users from potentially malicious attachments.", + "ImpactStatement": "Users will be warned when they receive an attachments with scripts from an untrusted sender.", + "RemediationProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Attachments, set Protect against attachments with scripts from untrusted senders to checked 6. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Attachments, ensure Protect against attachments with scripts from untrusted senders is checked", + "AdditionalInformation": "", + "DefaultValue": "Protect against attachments with scripts from untrusted senders is enabled is checked", + "References": "" + } + ] + }, + { + "Id": "3.1.3.4.1.3", + "Description": "Ensure protection against anomalous attachment types in emails is enabled", + "Checks": [ + "gmail_anomalous_attachment_protection_enabled" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "As a Google Workspace administrator, you can protect incoming mail against phishing and harmful software (malware). You can also choose what action to take based on the type of threat detected.", + "RationaleStatement": "You should protect your users from potentially malicious attachments.", + "ImpactStatement": "Users will be warned when they receive an anomalous attachment.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Attachments, set Protect against anomalous attachment types in emails to checked 6. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Attachments, ensure Protect against anomalous attachment types in emails is checked", + "AdditionalInformation": "", + "DefaultValue": "Protect against anomalous attachment types in emails is Unchecked", + "References": "" + } + ] + }, + { + "Id": "3.1.3.4.2.1", + "Description": "Ensure link identification behind shortened URLs is enabled", + "Checks": [ + "gmail_shortener_scanning_enabled" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Identify links behind short URLs, and display a warning when you click links to untrusted domains.", + "RationaleStatement": "You should protect your users from potentially malicious links.", + "ImpactStatement": "Users will be warned when they click links to untrusted domains.", + "RemediationProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Links and external images, set Identify links behind shortened URLs to checked 6. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Links and external images, ensure Identify links behind shortened URLs is checked", + "AdditionalInformation": "", + "DefaultValue": "Identify links behind shortened URLs is checked", + "References": "" + } + ] + }, + { + "Id": "3.1.3.4.2.2", + "Description": "Ensure scan linked images for malicious content is enabled", + "Checks": [ + "gmail_external_image_scanning_enabled" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Scan linked images for malicious content, and display a warning when you click links to untrusted domains.", + "RationaleStatement": "You should protect your users from potentially malicious links.", + "ImpactStatement": "Users will be warned when they click links to untrusted domains.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Links and external images, set Scan linked images to checked 6. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Links and external images, ensure Scan linked images is checked", + "AdditionalInformation": "", + "DefaultValue": "Scan linked images is checked", + "References": "" + } + ] + }, + { + "Id": "3.1.3.4.2.3", + "Description": "Ensure warning prompt is shown for any click on links to untrusted domains", + "Checks": [ + "gmail_untrusted_link_warnings_enabled" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Display a warning when you click links to untrusted domains.", + "RationaleStatement": "You should protect your users from potentially malicious links.", + "ImpactStatement": "Users will be warned when they click links to untrusted domains.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Links and external images, set Show warning prompt for any click on links to untrusted domains is checked 6. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Links and external images, ensure Show warning prompt for any click on links to untrusted domains is checked", + "AdditionalInformation": "", + "DefaultValue": "Show warning prompt for any click on links to untrusted domains is checked", + "References": "" + } + ] + }, + { + "Id": "3.1.3.4.3.1", + "Description": "Ensure protection against domain spoofing based on similar domain names is enabled", + "Checks": [ + "gmail_domain_spoofing_protection_enabled" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Moves domain spoofing emails to spam folder.", + "RationaleStatement": "You should protect your users from domain spoofing emails.", + "ImpactStatement": "Domain spoofed emails will be moved to a user's spam folder.", + "RemediationProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Spoofing and authentication, set Protect against domain spoofing based on similar domain names to checked 6. Set Action to Move email to spam 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Spoofing and authentication, ensure Protect against domain spoofing based on similar domain names is checked 6. Ensure Action is Move email to spam", + "AdditionalInformation": "", + "DefaultValue": "• Protect against domain spoofing based on similar domain names is checked • Action is Keep email in inbox and show warning (default)", + "References": "" + } + ] + }, + { + "Id": "3.1.3.4.3.2", + "Description": "Ensure protection against spoofing of employee names is enabled", + "Checks": [ + "gmail_employee_name_spoofing_protection_enabled" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Moves employee spoofing emails to spam folder.", + "RationaleStatement": "You should protect your users from employee spoofing emails.", + "ImpactStatement": "Employee spoofed emails will be moved to a user's spam folder.", + "RemediationProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Spoofing and authentication, set Protect against spoofing of employee names to checked 6. Set Action to Move email to spam 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Spoofing and authentication, ensure Protect against spoofing of employee names is checked 6. Ensure Action is Move email to spam", + "AdditionalInformation": "", + "DefaultValue": "• Protect against spoofing of employee names = checked • Action = Keep email in inbox and show warning (default)", + "References": "" + } + ] + }, + { + "Id": "3.1.3.4.3.3", + "Description": "Ensure protection against inbound emails spoofing your domain is enabled", + "Checks": [ + "gmail_inbound_domain_spoofing_protection_enabled" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Moves inbound emails spoofing your domain to spam folder.", + "RationaleStatement": "You should protect your users from inbound company domain spoofing emails.", + "ImpactStatement": "Inbound company domain spoofed emails will be moved to a user's spam folder.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Spoofing and authentication, set Protect against inbound emails spoofing your domain to checked 6. Set Action to Move email to spam 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Spoofing and authentication, ensure Protect against inbound emails spoofing your domain is checked 6. Ensure Action is Move email to spam", + "AdditionalInformation": "", + "DefaultValue": "• Protect against inbound emails spoofing your domain = checked • Action = Keep email in inbox and show warning (default)", + "References": "" + } + ] + }, + { + "Id": "3.1.3.4.3.4", + "Description": "Ensure protection against any unauthenticated emails is enabled", + "Checks": [ + "gmail_unauthenticated_email_protection_enabled" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Displays a warning when any message is not authenticated (SPF or DKIM).", + "RationaleStatement": "You should protect your users from any emails that aren't authenticated (SPF or DKIM)", + "ImpactStatement": "Emails that aren't authenticated (SPF or DKIM) display a warning message to the recipient.", + "RemediationProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Spoofing and authentication, set Protect against any unauthenticated emails to checked 6. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Spoofing and authentication, ensure Protect against any unauthenticated emails is checked", + "AdditionalInformation": "", + "DefaultValue": "Protect against any unauthenticated emails = unchecked", + "References": "" + } + ] + }, + { + "Id": "3.1.3.4.3.5", + "Description": "Ensure groups are protected from inbound emails spoofing your domain", + "Checks": [ + "gmail_groups_spoofing_protection_enabled" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "If a group receives an email that is spoofing your domain it is sent to the spam folder.", + "RationaleStatement": "You should protect your groups from any emails that spoofing your domain.", + "ImpactStatement": "Emails that are spoofing your domain and are received by a group are sent to the spam folder.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Spoofing and authentication, set Protect your Groups from inbound emails spoofing your domain to checked 6. Set Action to Move email to spam 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Spoofing and authentication, ensure Protect your Groups from inbound emails spoofing your domain is checked 6. Ensure Action is set to Move email to spam", + "AdditionalInformation": "", + "DefaultValue": "• Protect against any unauthenticated emails = unchecked • Action = Keep email in inbox and display warning (default)", + "References": "" + } + ] + }, + { + "Id": "3.1.3.5.1", + "Description": "Ensure POP and IMAP access is disabled for all users", + "Checks": [ + "gmail_pop_imap_access_disabled" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "POP and IMAP may allow users to access Gmail using legacy or unapproved email clients that do not support modern authentication mechanisms, such as multifactor authentication.", + "RationaleStatement": "Disabling POP and IMAP prevents use of legacy and unapproved email clients with weaker authentication mechanisms that would increase the risk of email account credential compromise.", + "ImpactStatement": "If you have Apple iOS or Android device users in your organization and you turn IMAP off, let them know that they’re no longer syncing Google Workspace mail to the iOS or Android Mail app. They might not get a notification on their device. Additionally, new users can’t manually add the Google Account they use for work or school to the device. If your Google Workspace users want to use desktop clients, such as Microsoft Outlook and Apple Mail, to access their Google Workspace mail, you need to enable POP or IMAP access in the Google Admin console. You can enable access for everyone in your organization or only for users in specific organizational units.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under End User Access - POP and IMAP Access 6. Set Enable IMAP access for all users to unchecked 7. Set Enable POP access for all users to unchecked 8. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under End User Access - POP and IMAP Access 6. Ensure Enable IMAP access for all users is unchecked 7. Ensure Enable POP access for all users is unchecked", + "AdditionalInformation": "", + "DefaultValue": "• Enable IMAP access for all users is checked • Enable POP access for all users is checked", + "References": "" + } + ] + }, + { + "Id": "3.1.3.5.2", + "Description": "Ensure automatic forwarding options are disabled", + "Checks": [ + "gmail_auto_forwarding_disabled" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "You should disable automatic forwarding to prevent users from auto-forwarding mail.", + "RationaleStatement": "In the event that an attacker gains control of an end-user account they could create rules to ex-filtrate data from your environment.", + "ImpactStatement": "Care should be taken before implementation to ensure there is no business need for case-by-case auto-forwarding. Disabling auto-forwarding to remote domains will affect all users and in an organization.", + "RemediationProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under End User Access - Automatic forwarding, set Allow users to automatically forward incoming email to another address to unchecked 6. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under End User Access - Automatic forwarding, ensure Allow users to automatically forward incoming email to another address is unchecked", + "AdditionalInformation": "", + "DefaultValue": "Allow users to automatically forward incoming email to another address is checked", + "References": "" + } + ] + }, + { + "Id": "3.1.3.5.3", + "Description": "Ensure per-user outbound gateways is disabled", + "Checks": [ + "gmail_per_user_outbound_gateway_disabled" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "A per-user outbound gateway is a mail server, other than the Google Workspace mail servers, that delivers outgoing mail for a user in your domain.", + "RationaleStatement": "Mail sent via external SMTP will circumvent your outbound gateway", + "ImpactStatement": "Care should be taken before implementation to ensure there is no business need for mail sent via external SMTP gateway.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under End User Access - Allow per-user outbound gateways, set Allow users to send mail through an external SMTP server when configuring a \"from\" address hosted outside your email domain to unchecked 6. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under End User Access - Allow per-user outbound gateways, ensure Allow users to send mail through an external SMTP server when configuring a \"from\" address hosted outside your email domain is unchecked", + "AdditionalInformation": "", + "DefaultValue": "Allow users to send mail through an external SMTP server when configuring a \"from\" address hosted outside your email domain is unchecked", + "References": "" + } + ] + }, + { + "Id": "3.1.3.5.4", + "Description": "Ensure external recipient warnings are enabled", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Gmail adds an image or colored border to external addresses.", + "RationaleStatement": "As an admin for your organization, you can turn alerts on or off for messages that include external recipients (people with email addresses outside of your organization). These alerts help people avoid unintentional replies, and remind them to treat external messages with caution.", + "ImpactStatement": "When this setting is on, Gmail shows warnings (colored boarder) when: • An email thread includes external recipients (not available on iOS). • Replying to a message from an external recipient. • Composing a new message to an external recipient (not available on iOS). Gmail doesn't show a warning if the external recipient is in your organization's Directory, personal Contacts, or other Contacts. Warnings aren't displayed for secondary domain or domain alias addresses.", + "RemediationProcedure": "To configure external recipient warnings are enabled, use the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Select End User Access 6. Select Warn for external recipients 7. Set Highlight any external recipients in a conversation. Warn users before they reply to email with external recipients who aren't in their contacts. to checked 8. Select Save", + "AuditProcedure": "To verify Ensure external recipient warnings are enabled, use the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Select End User Access 6. Under Warn for external recipients, ensure Highlight any external recipients in a conversation. Warn users before they reply to email with external recipients who aren't in their contacts. is ON", + "AdditionalInformation": "", + "DefaultValue": "Highlight any external recipients in a conversation. Warn users before they reply to email with external recipients who aren't in their contacts. is ON", + "References": "" + } + ] + }, + { + "Id": "3.1.3.6.1", + "Description": "Ensure enhanced pre-delivery message scanning is enabled", + "Checks": [ + "gmail_enhanced_pre_delivery_scanning_enabled" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Enables improved detection of suspicious content prior to delivery.", + "RationaleStatement": "As an administrator, you can increase Gmail's ability to identify suspicious content with enhanced pre-delivery message scanning. Typically, when Gmail identifies a possible phishing message, a warning is displayed and the message might be moved to spam.", + "ImpactStatement": "With the Enhanced pre-delivery message scanning option, when Gmail detects suspicious content, message delivery is slightly delayed so that Gmail can do additional security checks on the message.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Select Spam, phishing, and malware 6. Select Enhanced pre-delivery message scanning. 7. Set Enables improved detection of suspicious content prior to delivery to checked 8. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Select Spam, phishing, and malware 6. Ensure Enhanced pre-delivery message scanning. is ON", + "AdditionalInformation": "", + "DefaultValue": "Enhanced pre-delivery message scanning. is ON", + "References": "" + } + ] + }, + { + "Id": "3.1.3.6.2", + "Description": "Ensure spam filters are not bypased for internal senders", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "You can configure your advanced Gmail settings to bypass, or not bypass, spam filters for messages received from internal senders.", + "RationaleStatement": "Turning off this setting reduces the risk of spoofing and phishing/whaling.", + "ImpactStatement": "Your users will be better protected by filtering their email for spam and minimizing the chances for spoofing and phishing/whaling attacks.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Select Spam, phishing, and malware 6. Under Spam, select Configure 7. Set Bypass spam filters for messages received from internal senders. to unchecked 8. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Select Spam, phishing, and malware 6. Under Spam, select Configure 7. Ensure Bypass spam filters for messages received from internal senders. is unchecked", + "AdditionalInformation": "", + "DefaultValue": "Bypass spam filters for messages received from internal senders. is checked", + "References": "" + } + ] + }, + { + "Id": "3.1.3.7.1", + "Description": "Ensure comprehensive mail storage is enabled", + "Checks": [ + "gmail_comprehensive_mail_storage_enabled" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Comprehensive mail storage ensures messages sent by other core services appear in users' sent folders and are therefore accessible to Vault.", + "RationaleStatement": "As an administrator, you can ensure that a copy of all sent or received messages in your domain—including messages sent or received by non-Gmail mailboxes—is stored in the associated users' Gmail mailboxes.", + "ImpactStatement": "There are some important considerations to carefully review before enabling comprehensive mail storage: You should not enable comprehensive mail storage if you have compliance routing rules that change the recipient (and don’t want the original recipient to receive a copy of the email). When you have the SMTP Relay service enabled, user mailboxes will keep a copy of the message in the sent folder (for example, when sending mail from a scanner) if comprehensive mail storage is enabled. This might cause accounts to exceed storage limits if your account's edition has storage limits. Compare editions. You should enable comprehensive mail storage if you only use Gmail for the Vault feature and forward email to your on-premise mail server or other email provider.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Select Compliance 6. Select Comprehensive mail storage 7. Set Ensure that a copy of all sent and received mail is stored in associated users' mailboxes to checked 8. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Select Compliance 6. Under Comprehensive mail storage, ensure Ensure that a copy of all sent and received mail is stored in associated users' mailboxes is ON", + "AdditionalInformation": "", + "DefaultValue": "Copy of all sent and received mail is stored in associated users' mailboxes is OFF", + "References": "" + } + ] + }, + { + "Id": "3.1.3.7.2", + "Description": "Ensure 'Send email over a secure TLS connection' Is Enabled", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "The default is that Gmail always tries to send messages over a secure TLS connection. If the receiving server doesn't use TLS, Gmail still sends messages with TLS but the connection isn't secure. This setting allows the option to require a CA-signed certificate, verify the hostname associated with the certificate, and test the TLS connection. A padlock image will appear next to the recipient address if the message will be sent with TLS. The padlock shows only for accounts with a Google Workspace subscription that supports S/MIME encryption. Google Workspace supports TLS versions 1.0, 1.1, 1.2, and 1.3.", + "RationaleStatement": "Transport Layer Security (TLS) encrypts email messages for security and privacy and prevents unauthorized access of messages when they're sent over internet connections.", + "ImpactStatement": "This should not have an impact on the usage of Gmail.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Select Compliance 6. Select Secure transport (TLS) compliance 7. Select Configure 8. Set Inbound - all messages and Outbound - all messages to checked 9. Select Save Note: Enabling the Inbound - all messages and Outbound - all messages configurations will also, by default, enable Require CA-signed certificate when delivering outbound messages to the TLS-enabled domains specified above. This is not a required configuration, but it is recommended.", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Select Compliance 6. Under Secure transport (TLS) compliance, select Configure 7. Under Email messages to affect ensure Inbound - all messages and Outbound - all messages are ON", + "AdditionalInformation": "", + "DefaultValue": "", + "References": "https://support.google.com/a/answer/2520500" + } + ] + }, + { + "Id": "3.1.4.1.1", + "Description": "Ensure external filesharing in Google Chat and Hangouts is disabled", + "Checks": [ + "chat_external_file_sharing_disabled" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.4 Google Chat", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Control how files are shared externally in Google Chat and Hangouts.", + "RationaleStatement": "Files often contain confidential information, and some organizations, particularly in regulated industries, need to control the flow of this information within and outside of their organization.", + "ImpactStatement": "Users will not be able to share files via chat externally.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Chat and classic Hangouts 4. Select Chat File Sharing 5. Under Setting, set External filesharing to No files 6. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Chat and classic Hangouts 4. Select Chat File Sharing 5. Under Setting, verify External filesharing is set to No files", + "AdditionalInformation": "", + "DefaultValue": "External filesharing is Allow all files", + "References": "" + } + ] + }, + { + "Id": "3.1.4.1.2", + "Description": "Ensure internal filesharing in Google Chat and Hangouts is disabled", + "Checks": [ + "chat_internal_file_sharing_disabled" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.4 Google Chat", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Control how files are shared internally in Google Chat and Hangouts.", + "RationaleStatement": "Files often contain confidential information, and some organizations, particularly in regulated industries, need to control the flow of this information within and outside of their organization.", + "ImpactStatement": "Users will not be able to share files via chat internally.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Chat and classic Hangouts 4. Select Chat File Sharing 5. Under Setting, set Internal filesharing to No files 6. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Chat and classic Hangouts 4. Select Chat File Sharing 5. Under Setting, verify Internal filesharing is set to No files", + "AdditionalInformation": "", + "DefaultValue": "Internal filesharing is Allow all files", + "References": "" + } + ] + }, + { + "Id": "3.1.4.2.1", + "Description": "Ensure Google Chat externally is restricted to allowed domains", + "Checks": [ + "chat_external_messaging_restricted" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.4 Google Chat", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Control how users chat with people outside of your organization. If you allow your users to chat externally, you can also allow them to create and join spaces with people outside your organization.", + "RationaleStatement": "Restricting external chat to only approved domains potentially limits the spread of company information.", + "ImpactStatement": "Users will not be able to chat with users in any external domain, only approved domains. This will require some admin-level approval and allowlist maintenance.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Chat and classic Hangouts 4. Select External Chat Settings 5. Select Chat externally 6. Set Allow users to send messages outside to ON 7. Set Only allow this for allowlisted domains to checked 8. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Chat and classic Hangouts 4. Select External Chat Settings 5. Select Chat externally 6. Verify Allow users to send messages outside is ON 7. Verify Only allow this for allowlisted domains is checked", + "AdditionalInformation": "", + "DefaultValue": "• Allow users to send messages outside is set to ON • Only allow this for allowlisted domains is unchecked", + "References": "" + } + ] + }, + { + "Id": "3.1.4.3.1", + "Description": "Ensure external spaces in Google Chat and Hangouts are restricted", + "Checks": [ + "chat_external_spaces_restricted" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.4 Google Chat", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Control whether users can create or join spaces within your organization that include external people outside of your organization.", + "RationaleStatement": "Restricting external spaces to only approved domains potentially limits the spread of company information.", + "ImpactStatement": "Users with this setting turned off or who have editions that don't support external spaces can't create these spaces, but they can join existing spaces with external people", + "RemediationProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Chat and classic Hangouts 4. Select External Spaces 5. Under Setting, set Allow users at to create and join spaces with people outside their organization to ON 6. Set Only allow users to add people from allowlisted domains to checked 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Chat and classic Hangouts 4. Select External Spaces 5. Under Setting, verify Allow users at to create and join spaces with people outside their organization is ON 6. Verify Only allow users to add people from allowlisted domains is checked", + "AdditionalInformation": "", + "DefaultValue": "• Allow users at to create and join spaces with people outside their organization is ON • Only allow users to add people from allowlisted domains is unchecked", + "References": "" + } + ] + }, + { + "Id": "3.1.4.4.1", + "Description": "Ensure allow users to install Chat apps is disabled", + "Checks": [ + "chat_apps_installation_disabled" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.4 Google Chat", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Control the use of Chat apps in spaces or direct messages to connect to services in Google Chat and look up information, schedule meetings, or complete tasks. Apps are accounts created by Google, users in your organization, or third parties.", + "RationaleStatement": "When a user interacts with an app in Chat, the app can see the user's email address, avatar, other basic user information, user locale, timezone, and interaction information. The app can also see the basic user information of other people in the chat, but it can't see their email address or avatar unless they also interact directly with the app. Chat apps that you install from the Google Workspace Marketplace can be made by developers from outside of your organization. Using these Chat app need to be carefully controlled (vetted and approved) since a malicious Chat app could allow the exfiltration of company proprietary information.", + "ImpactStatement": "By default users will not be able to install Chat apps.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Chat and classic Hangouts 4. Select Chat apps 5. Under Chat apps access settings, set Allow users to install Chat apps to OFF 6. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Chat and classic Hangouts 4. Select Chat apps 5. Under Chat apps access settings, verify Allow users to install Chat apps is OFF", + "AdditionalInformation": "", + "DefaultValue": "Allow users to install Chat apps is ON", + "References": "https://developers.google.com/chat/concepts/apps" + } + ] + }, + { + "Id": "3.1.4.4.2", + "Description": "Ensure allow users to add and use incoming webhooks is disabled", + "Checks": [ + "chat_incoming_webhooks_disabled" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.4 Google Chat", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Allow users to configure incoming webhooks and developers to call incoming webhooks to post content. Incoming webhooks let you send asynchronous messages into Google Chat from applications that aren't Chat apps.", + "RationaleStatement": "Webhook usage should be carefully controlled (vetted and approved) since a malicious application could send bogus information to exposed webhooks and ultimately these users.", + "ImpactStatement": "By default users will have exposed webhooks.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Chat and classic Hangouts 4. Select Chat apps 5. Under Chat apps access settings, set Allow users to add and use incoming webhooks to OFF", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Chat and classic Hangouts 4. Select Chat apps 5. Under Chat apps access settings, verify Allow users to add and use incoming webhooks is OFF", + "AdditionalInformation": "", + "DefaultValue": "Allow users to add and use incoming webhooks is ON", + "References": "https://developers.google.com/chat/concepts/apps" + } + ] + }, + { + "Id": "3.1.6.1", + "Description": "Ensure accessing groups from outside this organization is set to private", + "Checks": [ + "groups_external_access_restricted" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.6 Groups for Business", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Choose whether people outside your organization can access your groups. Group owners can further restrict access as needed.", + "RationaleStatement": "Who can externally view groups internal to the organization should be carefully controlled and their access vetted as needed.", + "ImpactStatement": "No one outside your organization can view or search for your groups. External users can email the group if group settings allow.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Groups for Business 5. Select Sharing options 6. Set Accessing groups from outside this organization to Private 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Groups for Business 5. Select Sharing options 6. Verify Accessing groups from outside this organization is Private", + "AdditionalInformation": "", + "DefaultValue": "Accessing groups from outside this organization is Private", + "References": "https://support.google.com/a/answer/10308022?hl=en" + } + ] + }, + { + "Id": "3.1.6.2", + "Description": "Ensure creating groups is restricted", + "Checks": [ + "groups_creation_restricted" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.6 Groups for Business", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Control who is allowed to create Groups in your organization and if they can have external members.", + "RationaleStatement": "The organization should have some control over the organizational groups created and the purpose they are for.", + "ImpactStatement": "In a large organization, this may cause too much burden on administrators.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Groups for Business 5. Select Creating groups 6. Select Only organization admins can create groups 7. Set Group owners can allow external members Organization admins can always add external members to unchecked 8. Set Group owners can allow incoming email from outside the organization to unchecked 9. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Groups for Business 5. Select Creating groups 6. Verify Only organization admins can create groups is selected 7. Verify Group owners can allow external members Organization admins can always add external members is unchecked 8. Verify Group owners can allow incoming email from outside the organization is unchecked", + "AdditionalInformation": "", + "DefaultValue": "• Anyone in the organization can create groups is selected • Group owners can allow external members Organization admins can always add external members is unchecked • Group owners can allow incoming email from outside the organization is unchecked", + "References": "" + } + ] + }, + { + "Id": "3.1.6.3", + "Description": "Ensure default for permission to view conversations is restricted", + "Checks": [ + "groups_view_conversations_restricted" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.6 Groups for Business", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "By default, only allow group members to view group conversations.", + "RationaleStatement": "Conversation viewing can always be expanded by exception for certain groups as needed (Need to know), but by default be restricted.", + "ImpactStatement": "No practical impact, since Group members can view conversations in the Group.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Groups for Business 5. Select Sharing options 6. Set Default for permission to view conversations to All group members 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Groups for Business 5. Select Sharing options 6. Verify Default for permission to view conversations is All group members", + "AdditionalInformation": "", + "DefaultValue": "Default for permission to view conversations is All organization users", + "References": "" + } + ] + }, + { + "Id": "3.1.7.1", + "Description": "Ensure service status for Google Sites is set to off", + "Checks": [ + "sites_service_disabled" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.7 Sites", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "By default turn off Google Sites for all users.", + "RationaleStatement": "There is really no reason for every user within an organization to have access to Google Sites. If this capability is needed, it can be enabled and configured for those users and groups by exception as required by the organization to meet specific needs.", + "ImpactStatement": "Users will not be have access to Google Sites.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Sites 5. Select Service status 6. Set Service status to OFF for everyone 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Sites 5. Select Service status 6. Verify Service status is OFF for everyone", + "AdditionalInformation": "", + "DefaultValue": "Service status is ON for everyone", + "References": "" + } + ] + }, + { + "Id": "3.1.8.1", + "Description": "Ensure access to external Google Groups is OFF for Everyone", + "Checks": [ + "additionalservices_external_groups_disabled" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.8 Additional Google services", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Control whether users in your organization can access external groups from their Google Workspace account. External groups are created outside your organization and might include a public community group or a group for a club a user belongs to. Control access to external groups by turning on or off the Google Groups additional service — a legacy service in your Admin console that does only one thing: It allows or blocks users from accessing external groups from their Google Workspace account. NOTE: This service has no effect on your organization's internal groups.", + "RationaleStatement": "In general, most of the organization's personnel do not need to assess external groups. They can be allowed by exception as needed by the business.", + "ImpactStatement": "Users can't access external groups from their Google Workspace account. However, they do continue to receive email digests from groups they're already subscribed to when you turn off the service.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select `Additional Google services 5. Scroll down to Google Groups 6. Set it to OFF for everyone 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select `Additional Google services 5. Scroll down to Google Groups 6. Verify it is OFF for everyone", + "AdditionalInformation": "", + "DefaultValue": "Google Groups is ON for Everyone", + "References": "" + } + ] + }, + { + "Id": "3.1.9.1.1", + "Description": "Ensure users access to Google Workspace Marketplace apps is restricted", + "Checks": [ + "marketplace_apps_access_restricted" + ], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.9 Google Workspace Marketplace", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Restrict what Google Marketplace apps a user can install.", + "RationaleStatement": "Users should only be allowed to install approved and vetted apps. This will limit the overall attack surface for the organization.", + "ImpactStatement": "Users can only install approved Google Marketplace apps. This list will have to be created and maintained.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace Marketplace apps 4. Select Settings 5. Under Manage Google Workspace Marketplace allowlist access, set Settings to install third-party Google Workspace Marketplace apps: to Allow users to install and run only selected apps from the Marketplace 6. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace Marketplace apps 4. Select Settings 5. Under Manage Google Workspace Marketplace allowlist access, verify Settings to install third-party Google Workspace Marketplace apps: is set to Allow users to install and run only selected apps from the Marketplace", + "AdditionalInformation": "", + "DefaultValue": "Settings to install third-party Google Workspace Marketplace apps: is Allow users to install and run any app from the Marketplace", + "References": "" + } + ] + }, + { + "Id": "4.1.1.1", + "Description": "Ensure 2-Step Verification (Multi-Factor Authentication) is enforced for all users in administrative roles", + "Checks": [ + "security_2sv_enforced" + ], + "Attributes": [ + { + "Section": "4 Security", + "SubSection": "4.1 Authentication", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Enforce 2-Step Verification (Multi-Factor Authentication) for all users assigned administrative roles. These include roles such as: • Help Desk Admin • Groups Admin • Super Admin • Services Admin • User Management Admin • Mobile Admin • Android Admin • Custom Admin Roles", + "RationaleStatement": "Add an extra layer of security to users accounts by asking users to verify their identity when they enter a username and password. 2-Step Verification (Multi-factor authentication) requires an individual to present a minimum of two separate forms of authentication before access is granted. 2-Step Verification provides additional assurance that the individual attempting to gain access is who they claim to be. With 2-Step Verification, an attacker would need to compromise at least two different authentication mechanisms, increasing the difficulty of compromise and thus reducing the risk.", + "ImpactStatement": "Implementation of 2-Step Verification (multi-factor authentication) for all users in administrative roles will necessitate a change to user routine. All users in administrative roles will be required to enroll in 2-Step Verification using using phone, SMS, or an authentication application. After enrollment, use of 2-Step Verification will be required for future access to the environment.", + "RemediationProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Go to Security and click on 2-Step Verification 3. Select the appropriate group with ALL ADMIN ROLES -- Create this group if needed 4. Under Authentication, set Allow users to turn on 2-Step Verification to checked 5. Set Enforcement to On 6. Set New user enrollment period is set to 2 weeks 7. Under Frequency, set Allow user to trust device to unchecked 8. Under Methods, set Any except verification codes via text, phone call to selected 9. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Go to Security and click on 2-Step Verification 3. Select the appropriate group with ALL ADMIN ROLES -- Create this group if needed 4. Under Authentication, ensure Allow users to turn on 2-Step Verification is checked 5. Ensure Enforcement is set to On 6. Ensure New user enrollment period is set to 2 weeks 7. Under Frequency, ensure Allow user to trust device is unchecked 8. Under Methods, ensure Any except verification codes via text, phone call is selected", + "AdditionalInformation": "", + "DefaultValue": "• Allow users to turn on 2-Step Verification is checked • Enforcement is Off • New user enrollment period is None • Frequency - Allow user to trust device is checked • Methods is Any", + "References": "" + } + ] + }, + { + "Id": "4.1.1.2", + "Description": "Ensure hardware security keys are used for all users in administrative roles and other high-value accounts", + "Checks": [ + "security_2sv_hardware_keys_admins" + ], + "Attributes": [ + { + "Section": "4 Security", + "SubSection": "4.1 Authentication", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "A hardware security key connects to a user's device using USB (A & C), Lightning, NFC, or Bluetooth connection. Also, many Android phones and Apple iPhones have built-in security keys accessible via Bluetooth and that can be assigned to a Google Workspace account. The purpose of a physical security key is to provide an additional security layer to high value accounts; in the event of a compromise of a user's credentials (username and password) without the associated security key, the authentication process cannot be successfully completed.", + "RationaleStatement": "The purpose of a physical security key is to provide an additional security layer to high value accounts; in the event of a compromise of a user's credentials (username and password) without the associated security key, the authentication process cannot be successfully completed. Hardware security keys help to protect high value accounts from targeted attacks, including phishing attempts. Adding a hardware security key requirement to your Google privileged accounts adds another layer of depth of protection greater than any other form of two-factor authentication.", + "ImpactStatement": "Users with hardware security keys enabled will need to have physical access to the hardware key in order complete the authentication process and this will force users to adopt a practice of making sure that the physical key is available to them at any point in time that they need to be able to log in. If a hardware security key is lost or stolen, the impacted user can gain access to their Google account by using a backup MFA process and then remove the lost/stolen key and add another one. If a hardware security key is stolen, the user's account is not automatically compromised as the hardware key works in conjunction with the user's account credentials (username & password).", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Go to Security and click on Authentication 3. Under Authentication, select 2-Step Verification 4. Select the option to Allow users to turn on 2-Step Verification 5. Under Enforcement, enable either 'On' or else 'On from' and configure a valid date 6. Under Methods, select Only security key to force the use of a security key 7. Under 2-Step Verification policy suspension grace period, select 1 day 8. Under Security codes, select Don't allow users to generate security codes 9. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Go to Security and click on Authentication 3. Under Authentication, select 2-Step Verification 4. Ensure the option to Allow users to turn on 2-Step Verification is checked 5. Ensure that the Enforcement option is set to either 'On' or 'On from' with a valid date present 6. Under Methods ensure that Only security key is selected 7. Under 2-Step Verification policy suspension grace period ensure that 1 day is selected 8. Under Security codes ensure that Don't allow users to generate security codes is selected", + "AdditionalInformation": "", + "DefaultValue": "• Allow users to turn on 2-Step Verification is checked • Enforcement is Off • New user enrollment period is None • Frequency - Allow user to trust device is checked • Methods is Any", + "References": "https://support.google.com/accounts/answer/6103523?hl=En" + } + ] + }, + { + "Id": "4.1.1.3", + "Description": "Ensure 2-Step Verification (Multi-Factor Authentication) is enforced for all users", + "Checks": [ + "security_2sv_enforced" + ], + "Attributes": [ + { + "Section": "4 Security", + "SubSection": "4.1 Authentication", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Enforce 2-Step Verification (Multi-Factor Authentication) for all users.", + "RationaleStatement": "Add an extra layer of security to users accounts by asking users to verify their identity when they enter a username and password. 2-Step Verification (Multi-factor authentication) requires an individual to present a minimum of two separate forms of authentication before access is granted. 2-Step Verification provides additional assurance that the individual attempting to gain access is who they claim to be. With 2-Step Verification, an attacker would need to compromise at least two different authentication mechanisms, increasing the difficulty of compromise and thus reducing the risk.", + "ImpactStatement": "Implementation of 2-Step Verification (multi-factor authentication) for all users will necessitate a change to user routine. All users will be required to enroll in 2-Step Verification using using phone, SMS, or an authentication application. After enrollment, use of 2-Step Verification will be required for future access to the environment.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select 2-Step Verification 4. Under Authentication, check - Allow users to turn on 2-Step Verification 5. Set Enforcement to On 6. Set New user enrollment period to 2 weeks 7. Under Frequency, uncheck - Allow user to trust device 8. Under Methods, select - Any except verification codes via text, phone call 9. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select 2-Step Verification 4. Under Authentication, ensure Allow users to turn on 2-Step Verification is checked 5. Ensure Enforcement is set to On 6. Ensure New user enrollment period is set to 2 weeks 7. Under Frequency, ensure Allow user to trust device is not checked 8. Under Methods, ensure Any except verification codes via text, phone call is selected", + "AdditionalInformation": "", + "DefaultValue": "• Allow users to turn on 2-Step Verification is checked • Enforcement is Off • New user enrollment period is None • Frequency - Allow user to trust device is checked • Methods is Any", + "References": "" + } + ] + }, + { + "Id": "4.1.2.1", + "Description": "Ensure Super Admin account recovery is disabled", + "Checks": [ + "security_super_admin_recovery_disabled" + ], + "Attributes": [ + { + "Section": "4 Security", + "SubSection": "4.1 Authentication", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "This option allows Super Admin users to recover access to their accounts if their password has been forgotten. The option is not available if either Single Sign On or Password Sync is in use.", + "RationaleStatement": "While allowing Super Admins to recover access to their accounts when they have forgotten their passwords reduces the number of support tickets, it also significantly reduces the security of those Super Admin accounts. If a malicious actor has access to the Super Admin's standard user account, they may then have access to the Super Admin account as well.", + "ImpactStatement": "The impact to Super Admins not being allowed to recover their accounts is that there may be downtime while another Super Admin resets their password.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Security. 3. Select Authentication. 4. Under Account recovery select Super admin account recovery. 5. Set Allow super admins to recover their account to unchecked 6. Click Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Security. 3. Select Authentication. 4. Under Account recovery select Super admin account recovery. 5. Ensure Allow super admins to recover their account is unchecked.", + "AdditionalInformation": "", + "DefaultValue": "Allow super admins to recover their account is OFF", + "References": "" + } + ] + }, + { + "Id": "4.1.2.2", + "Description": "Ensure User account recovery is enabled", + "Checks": [ + "security_user_recovery_enabled" + ], + "Attributes": [ + { + "Section": "4 Security", + "SubSection": "4.1 Authentication", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "This option allows non-Super Admin users to recover access to their accounts if their password has been forgotten. The option is not available if either Single Sign On or Password Sync is in use.", + "RationaleStatement": "Allowing users to recover access to their accounts when they have forgotten their passwords reduces the number of support tickets generated by users, and reduces the amount of down time spent waiting on the account recovery process to initiate and complete.", + "ImpactStatement": "The potential impact to users being allowed to recover their accounts includes: 1. The user is now empowered to reset their passwords. 2. The user will no longer need to call a helpdesk or open a support ticket to regain access to their account. An organization that allows users to recover their account will realize less time spent by administrative staff working on these tasks.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Security. 3. Select User account recovery 4. Select either the pencil icon or the setting itself. 5. Set Allow users and non-super admins to recover their account to checked. 6. Select Save.", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Security. 3. Select User account recovery 4. Verify Allow users and non-super admins to recover their account is checked.", + "AdditionalInformation": "", + "DefaultValue": "Allow users and non-super admins to recover their account is OFF", + "References": "" + } + ] + }, + { + "Id": "4.1.3.1", + "Description": "Ensure Advanced Protection Program is configured", + "Checks": [ + "security_advanced_protection_configured" + ], + "Attributes": [ + { + "Section": "4 Security", + "SubSection": "4.1 Authentication", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Enable Google's Advanced Protection Platform for all users and prevent the use of security codes where applicable.", + "RationaleStatement": "Sophisticated phishing tactics can trick the most savvy users into giving their sign-in credentials to attackers. Advanced Protection requires you to use a security key, which is a hardware device or special software on your phone used to verify your identity, to sign in to your Google Account. Unauthorized users won’t be able to sign in without your security key, even if they have your username and password. The Advanced Protection Program includes a curated group of high-security policies that are applied to enrolled accounts. Additional policies may be added to the Advanced Protection Program to ensure the protections are current. Advanced Protection allows you to apply all of these protections at once, and override similar settings you may have configured manually. These policies include: Strong authentication with security keys Use of security codes with security keys (as needed) Restrictions on third-party access to account data Deep Gmail scans Google Safe Browsing protections in Chrome (when users are signed into Chrome using the same identity as their Advanced Protection Program identity) Account recovery through admin", + "ImpactStatement": "User Impact You need your security key when you sign in for the first time on a computer, browser, or device. If you stay signed in, you may not be asked to use your security key the next time you log in. Limits third-party app access to your data, puts stronger checks on suspicious downloads, and tightens account recovery security to help prevent unauthorized access. Security Keys - 2 Required Android: With an Android 7.0+ phone, you can enroll in a few taps by registering your phone’s built-in security key. iPhone: If you have an iPhone running iOS 10.0+, install the Google Smart Lock app to register your security key first, then enroll. Two security keys are required for added assurance. If one key is lost or damaged, users can use the second key to regain account access. Third-Party iDP You can use the Advanced Protection Program with accounts that federate from an IdP using SAML. When users with these accounts enroll in the Advanced Protection Program, we’ll require security key use after the user signs in on the IdP. Note that SAML users can select Remember the device to avoid challenges on a browser or device. Security Codes Before allowing users to generate security codes, carefully evaluate if your organization needs them. Using security keys with security codes increases the risk of phishing. However, if your organization has important workflows where security keys can’t be used directly, enabling security codes for those situations may help improve your security posture overall. Using 'Sign in with Google' with other apps and services You can still sign into apps and services with Google. If they request access to your Gmail or Drive data, access is denied.", + "RemediationProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Advanced Protection Program 4. Under Enrollment - Allow users to enroll in the Advanced Protection Program, set Enable user enrollment to selected for the desired organizational unit or group 5. Under Security Codes, set Do not allow users to generate security codes to selected for the desired organizational unit or group 6. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Advanced Protection Program 4. Under Enrollment - Allow users to enroll in the Advanced Protection Program, ensure Enable user enrollment is selected for the desired organizational unit or group 5. Under Security Codes, ensure Do not allow users to generate security codes is selected for the desired organizational unit or group", + "AdditionalInformation": "", + "DefaultValue": "• Allow users to enroll in the Advanced Protection Platform is selected • Security codes is Allow security codes without remote access", + "References": "" + } + ] + }, + { + "Id": "4.1.4.1", + "Description": "Ensure login challenges are enforced", + "Checks": [], + "Attributes": [ + { + "Section": "4 Security", + "SubSection": "4.1 Authentication", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Configure Google Workspace to verify a user's identity post-sso.", + "RationaleStatement": "Many organizations use third-party identity providers (IdPs) to authenticate users who use single sign on (SSO) through SAML. The third-party IdP authenticates users and no additional risk-based challenges are presented to them. Any Google 2-Step Verification (2SV) configuration is ignored. This is the default behavior. You can set a policy to allow additional risk-based authentication challenges and 2SV if it’s configured. If Google receives a valid SAML assertion (authentication information about the user) from the IdP during user sign-in, Google can present additional challenges to the user. Login challenges requires users have a recovery phone number or email account associated with their organizational account. If not previously configured, users will be prompted to enter this information periodically until provided. One login challenge option prompts users to enter their employee ID. This method is susceptible to information gathering attacks, should a list of employee IDs ever be leaked.", + "ImpactStatement": "The potential impact associated with implementation of this setting is dependent upon the existing 2-Step Verification (2SV) polices. • If you have existing 2SV policies, such as 2SV enforcement, those policies apply immediately. • Users affected by the new policy and who are enrolled in 2SV get a 2SV challenge at sign-in. • Based on Google sign-in risk analysis, users might see risk-based challenges at sign-in.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Authentication 4. Select Login Challenges 5. Depending on your organization's SSO configuration: o Under Settings for users signing in using the legacy SSO profile set Ask users for additional verifications from Google if a sign-in or session behavior looks suspicious, and always apply 2-Step Verification policies (if configured) to checked o Under Settings for users signing in using other SSO profiles set Ask users for additional verifications from Google if a sign-in or session behavior looks suspicious, and always apply 2-Step Verification policies (if configured) to checked 6. Select Save 7. Under Login challenges, set Use employee ID to keep my users more secure to unchecked 8. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Authentication 4. Select Login Challenges 5. Select Post-SSO verification 6. Depending on your organization's SSO configuration: o Under Settings for users signing in using the legacy SSO profile ensure Ask users for additional verifications from Google if a sign-in or session behavior looks suspicious, and always apply 2-Step Verification policies (if configured) is checked o Under Settings for users signing in using other SSO profiles ensure Ask users for additional verifications from Google if a sign-in or session behavior looks suspicious, and always apply 2-Step Verification policies (if configured) is checked 7. Under Login challenges, ensure Use employee ID to keep my users more secure is unchecked", + "AdditionalInformation": "This recommendation is giving guidance for using Google authentication as your primary SSO. If you are using a third-party SSO, you will need to configure that through either: To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Authentication 4. Depending on your organization's SSO and SAML configuration: o Select SSO with SAML applications o Select SSO with third-party IdP You will need to configure either option to your organization's third-party provider's requirements. Your provider should be able to assist you with that configuration, but it is outside the scope of the benchmark.", + "DefaultValue": "• Post-SSO verification is Logins using SSO bypass additional verifications • Use employee ID to keep my users more secure is unchecked", + "References": "" + } + ] + }, + { + "Id": "4.1.5.1", + "Description": "Ensure password policy is configured for enhanced security", + "Checks": [ + "security_password_policy_strong" + ], + "Attributes": [ + { + "Section": "4 Security", + "SubSection": "4.1 Authentication", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Configure Google Workspace Password Policy with a more secure length and is enforced upon next sign-in to protect against the use of common password attacks.", + "RationaleStatement": "Strong password policies protect an organization by prohibiting the use of weak passwords.", + "ImpactStatement": "The potential impact associated with implementation of this setting is dependent upon the existing password policies in place in the environment. For environments that have strong password policies in place, the impact will be minimal. For organizations that do not have strong password policies in place, enhancing the password policy may require users to change passwords, and adhere to more stringent requirements than they have been accustomed to. Configuring passwords to expire at a 1 year mark ensures that users are not forced to change passwords so often that easily discerned patterns are used in the creation of the passwords. The day-to-day impact on users will be that they have to manage fewer passwords changing on a frequent basis. NOTE: Password should be changed immediately on any indication of system compromise, when a user role changes, and when a user leaves the organization.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Password management 4. Under Strength, set Enforce strong passwords to checked 5. Under Length, set Minimum Length to 14 or greater 6. Under Strength and Length enforcement, set Enforce password policy at next sign-in is checked 7. Under Reuse, set Allow password reuse to unchecked 8. Under Expiration, set Password reset frequency to 365 Days 9. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Password management 4. Under Strength, ensure Enforce strong passwords is checked 5. Under Length, ensure Minimum Length is set to 14+ 6. Under Strength and Length enforcement, ensure Enforce password policy at next sign-in is set to checked 7. Under Reuse, ensure Allow password reuse is unchecked 8. Under Expiration, ensure Password reset frequency is set to 365 Days", + "AdditionalInformation": "", + "DefaultValue": "• Enforce strong password is checked • Minimum length is 8 • Maximum length is 100 • Enforce password policy at next sign-in is not checked • Allow password reuse is not checked • Expiration is Never expires", + "References": "" + } + ] + }, + { + "Id": "4.2.1.1", + "Description": "Ensure application access to Google services is restricted", + "Checks": [ + "security_app_access_restricted" + ], + "Attributes": [ + { + "Section": "4 Security", + "SubSection": "4.2 Access and Data Control", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Prevent unrestricted application access to Google services.", + "RationaleStatement": "You can restrict (or leave unrestricted) access to most Workspace services, including Google Cloud Platform services such as Machine Learning. For Gmail and Google Drive, you can specifically restrict access to high-risk scopes (for example, sending Gmail or deleting files in Drive). While users are prompted to consent to apps, if an app uses restricted scopes and you haven’t specifically trusted it, users can’t add it.", + "ImpactStatement": "The potential impact associated with implementation of this setting is that any previously installed apps that you haven’t trusted stop working and tokens are revoked. When a user tries to install an app that has a restricted scope, they’re notified that it’s blocked.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Access and Data Control 4. Select API Controls, then select App access control 5. Under Overview, select MANAGE GOOGLE SERVICES 6. Select ALL applicable Google Services 7. Click Change access 8. Select Restricted: Only trusted apps can access a service", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Access and Data Control 4. Select API Controls, then select App access control 5. Under Overview, select MANAGE GOOGLE SERVICES 6. Ensure ALL applicable Google Services have Restricted in the Access column", + "AdditionalInformation": "", + "DefaultValue": "Access is Unrestricted", + "References": "" + } + ] + }, + { + "Id": "4.2.1.2", + "Description": "Review third-party applications periodically", + "Checks": [], + "Attributes": [ + { + "Section": "4 Security", + "SubSection": "4.2 Access and Data Control", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Weekly review connected applications for potential malicious or unintended access or connections.", + "RationaleStatement": "Performing a periodic review of connected applications and their permission scopes ensures only permitted and required applications can access organizational data or resources. Attackers commonly attempt to persuade or trick users to grant their application access to organizational data resources by asking for their consent.", + "ImpactStatement": "Blocking or removing unauthorized third-party applications will immediately revoke their access to organizational data as well as Google Workspace APIs. This reduces the organizations risk exposure.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Access and Data Control 4. Select API Controls, then select App access control 5. Under Overview, select MANAGE THIRD-PARTY APP ACCESS 6. Select Change Access for the application you wish to remove 7. Select Blocked: Can't access any Google service 8. Log in to the Google Cloud Platform - Resource Manager https://console.cloud.google.com/cloud-resource-manager as an administrator 9. Now Delete the desired application", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Access and Data Control 4. Select API Controls, then select App access control 5. Under Overview, select MANAGE THIRD-PARTY APP ACCESS 6. Ensure all listed applications have been properly vetted and authorized by the appropriate personnel", + "AdditionalInformation": "", + "DefaultValue": "None", + "References": "" + } + ] + }, + { + "Id": "4.2.1.3", + "Description": "Ensure internal apps can access Google Workspace APIs", + "Checks": [ + "security_internal_apps_trusted" + ], + "Attributes": [ + { + "Section": "4 Security", + "SubSection": "4.2 Access and Data Control", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Enable access to Google Workspace APIs for customer-owned / developed applications.", + "RationaleStatement": "All organization-built internal apps (owned by your organization), can be trusted to access restricted Google Workspace APIs. That way, the organization does not have to trust them all individually.", + "ImpactStatement": "Configuring 'Trusted' status for internal applications allows apps to access Google Workspace APIs. This bypasses individual administrator approval for each usage of the application while maintaining domain-level permissions.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Access and Data Control 4. Select API Controls, then select App access control 5. Under Settings, select Trust internal, domain-owned apps 6. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Access and Data Control 4. Select API Controls, then select App access control 5. Under Settings, verify Trust internal, domain-owned apps is selected", + "AdditionalInformation": "", + "DefaultValue": "Trust internal, domain-owned apps is selected", + "References": "" + } + ] + }, + { + "Id": "4.2.1.4", + "Description": "Review domain-wide delegation for applications periodically", + "Checks": [], + "Attributes": [ + { + "Section": "4 Security", + "SubSection": "4.2 Access and Data Control", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Weekly review domain-wide delegations for applications for potentially malicious or unintended access or connections.", + "RationaleStatement": "Domain-wide delegation is a powerful feature that allows apps to access users' data across your organization's entire Workspace account. Performing a periodic review of domain-wide delegations for applications and their permission scopes ensures only permitted and required applications can access organizational data or resources.", + "ImpactStatement": "Removing or modifying domain-wide delegation for an application immediately affects its ability to access user data across the entire domain.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Access and Data Control 4. Select API Controls 5. Under Domain wide delegation, select MANAGE DOMAIN WIDE DELEGATION 6. Select Change Access for the application you wish to remove 7. Now Delete the desired application", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Access and Data Control 4. Select API Controls 5. Under Domain wide delegation, select MANAGE DOMAIN WIDE DELEGATION 6. Ensure all listed applications have been properly vetted and authorized by the appropriate personnel", + "AdditionalInformation": "", + "DefaultValue": "None", + "References": "" + } + ] + }, + { + "Id": "4.2.2.1", + "Description": "Ensure blocking access from unapproved geographic locations", + "Checks": [], + "Attributes": [ + { + "Section": "4 Security", + "SubSection": "4.2 Access and Data Control", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Restrict access to selected Google applications by geographic location. Note: This setting will not be displayed if your organization is using a third-party SSO, ie MS365.", + "RationaleStatement": "Restricting access to known/approved geographic locations is a simple way to limit where attacks can originate from. Especially for smaller organizations that do not need global access to applications.", + "ImpactStatement": "Valid/approved users traveling to a geographic region outside of those defined in the Access Level will not be able to access their applications.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: Create an appropriate Access Level 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Access and Data Control 4. Select Context-Aware Access 5. Select Access levels 6. Select Create Access Level 7. Under Details - Name the Access Level (Suggested using a clear name - ex. \"Restrict to USA\") 8. Under Conditions - Select Basic 9. Under Condition 1 - Select Meet attributes 10. Under Condition 1 - Select Add Attribute 11. Click on the Add Attribute drop-down box and select Geographic origin 12. Click on the far right drop-down box and select the region, or regions, to be allowed (ex. United States) 13. Click Save Assign the defined Access Level has been assigned to the application(s) that need the restriction 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Access and Data Control 4. Select Context-Aware Access 5. Select Assign access levels 6. For each application listed that needs this restriction, select Assign 7. Under, Access is granted when a user meets conditions in at least one of the selected access levels, ensure the previously named Access Level (ex. \"Restrict to USA\") is checked 8. Also, ensure Apply to Google desktop and mobile apps is checked NOTE: CIS recommends geographically restricting assess to the following Google applications at minimum: 1. Admin Console 2. Drives and Docs 3. Gmail 4. Google Vault", + "AuditProcedure": "To verify this setting via the Google Admin Console: Verify an appropriate Access Level has been defined 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Access and Data Control 4. Select Context-Aware Access 5. Select Access levels 6. Review the list of Access Levels displayed and determine if there is an appropriate restriction on geographic access Verify the appropriate Access Level has been assigned to the application(s) that need the restriction 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Access and Data Control 4. Select Context-Aware Access 5. Select Assign access levels 6. Review the list of Google Applications displayed and make sure the appropriate access level for geographic access is assigned to each NOTE: CIS recommends geographically restricting access to the following Google applications at minimum: 1. Admin Console 2. Drives and Docs 3. Gmail 4. Google Vault", + "AdditionalInformation": "", + "DefaultValue": "None", + "References": "" + } + ] + }, + { + "Id": "4.2.3.1", + "Description": "Ensure DLP policies for Google Drive are configured", + "Checks": [ + "security_dlp_drive_rules_configured" + ], + "Attributes": [ + { + "Section": "4 Security", + "SubSection": "4.2 Access and Data Control", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Enabling Data Loss Prevention (DLP) policies for Google Drive allows organizations to control the content that users can share in Google Drive files outside the organization.", + "RationaleStatement": "Enabling DLP policies alerts users and administrators that specific types of data should not be exposed, helping to protect the data from accidental exposure. DLP gives you control over what users can share, and prevents unintended exposure of sensitive information such as credit card numbers or identity numbers", + "ImpactStatement": "Configuring a DLP policy for Google Drive will detect or block sensitive information.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Access and Data Control 4. Select Data protection 5. Select Manage Rules 6. Select ADD RULE, then select either New rule or New rule from template New rule Examples can be found here. 1. Set the rule Name 2. Optionally - Set the rule Description 3. Set the Scope as appropriate 4. Select Continue 5. Set Triggers by checking - File modified under Google Drive 6. Select ADD CONDITION and configure values (Field, Comparison Operator, Content to match) - Repeat as appropriate 7. Select Continue 8. Under Actions, select the desired action to take for each incident 9. Under Alerting, select the desired severity level 10. Under Alerting, Select - Send to alert center 11. Select Continue 12. Select Create New rule from template 1. Select the desired rule template 2. Optionally set the Name as desired 3. Optionally set the `Description as desired 4. Set the Scope as appropriate 5. Select Continue 6. Modify preconfigured Conditions as desired, or add additional conditions 7. Select Continue 8. Under Alerting, Select - Send to alert center 9. Select Continue 10. Select Create", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Access and Data Control 4. Select Data protection 5. Select Manage Rules 6. Ensure data protection rules exist and are enabled", + "AdditionalInformation": "", + "DefaultValue": "No DLP policies for Google Drive are configured by default", + "References": "https://support.google.com/a/answer/10846568:https://workspaceupdates.googleblog.com/2020/10/data-protection-dlp-reports.html" + } + ] + }, + { + "Id": "4.2.4.1", + "Description": "Ensure Google session control is configured", + "Checks": [ + "security_session_duration_limited" + ], + "Attributes": [ + { + "Section": "4 Security", + "SubSection": "4.2 Access and Data Control", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Configure Google Workspace's session control to strengthen session expiration.", + "RationaleStatement": "As an administrator, you can control how long users can access Google services, such as Gmail on the web, without having to sign in again. For example, for users that work remotely or from untrusted locations, you might want to limit the time that they can access sensitive resources by applying a shorter web session length. If users want to continue accessing a resource when a session ends, they’re prompted to sign in again and start a new session. How the settings work on mobile devices varies by device and app.", + "ImpactStatement": "The potential impact associated with implementation of this setting are: When a web session expires for a user, they see the Verify it's you page and must sign in again. When you change the session length, users need to sign out and in again for settings to take effect. If you set the session to never expire, users never have to sign in again. If you need some users to sign in more frequently than others, place them in different organizational units. Then, apply different session lengths to them. That way, certain users won’t be interrupted to sign in when it isn’t necessary. If a Google Meet meeting starts within 2 hours of a session's scheduled expiration, the user is forced to sign in again before the start of the meeting. This helps avoid an interruption to the meeting while in-progress. If you’re using a third-party identity provider (IdP), such as Okta or Ping, and you set web session lengths for your users, you need to set the IdP session length parameter to expire before the Google session expires. That way, your users will be forced to sign in again. If the third-party IdP session is still valid when the Google session expires, the Google session might be renewed automatically without the user signing in again.", + "RemediationProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Access and Data Control 4. Select Google session control 5. Set Web session duration to 12 hours or less 6. Select Save", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Access and Data Control 4. Select Google session control 5. Verify Web session duration, is 12 hours or less", + "AdditionalInformation": "", + "DefaultValue": "Web session duration is 14 days", + "References": "" + } + ] + }, + { + "Id": "4.2.5.1", + "Description": "Ensure Google Cloud session control is configured", + "Checks": [], + "Attributes": [ + { + "Section": "4 Security", + "SubSection": "4.2 Access and Data Control", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Configure Google cloud session control to strengthen session expiration.", + "RationaleStatement": "As an administrator, you can control how long different users can access the Google Cloud console and Cloud SDK without having to re-authenticate. For example, you might want users with elevated privileges, like project owners, billing administrators, or others with administrator roles, to re-authenticate more frequently than regular users. If you set a session length, they’re prompted to sign in again to start a new session.", + "ImpactStatement": "The potential impact associated with implementation of this setting are: When a Google cloud session expires for a user, they see the Verify it's you page and must sign in again. If you require a security key, users who do not have one cannot use the GCP Console or Cloud SDK until they set it up. Once they have a security key, they can switch to using their password instead if they want. If you’re using a third-party identity provider (IdP): With the GCP Console—If you require a user to re-authenticate using their password, they’re redirected to the identity provider (IdP). The IdP might not require the user to re-enter their password to start another console session, if the user already has a session active with the IdP—because they are using another application that caused the session to remain active. If a user must re-authenticate by touching their security key, they can do this while using the console. They will not be redirected to the IdP. With the Cloud SDK—If a password is required for re-authentication, gcloud will require the user to execute the gcloud auth login command to renew the session. This will bring up a browser window, and the user will be taken to the IdP, where they may be prompted for credentials if there's no active session with the IdP. If a user must reauthenticate by touching their security key, they can do this on the Cloud SDK. They will not be redirected to the IdP.", + "RemediationProcedure": "To configure this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Access and Data Control 4. Select Google Cloud session control 5. Under Reauthentication policy, set Require reauthentication to selected and Exempt Trusted apps is unchecked 6. Set Reauthentication frequency to 16 hours (recommended) 7. Set Reauthentication method to Security key 8. Select Override", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Access and Data Control 4. Select Google Cloud session control 5. Under Reauthentication policy, ensure Require reauthentication is selected and Exempt Trusted apps is unchecked 6. Verify Reauthentication frequency, is16 hours (recommended) 7. Verify Reauthentication method is Security key", + "AdditionalInformation": "", + "DefaultValue": "Reauthentication policy is Never require reauthentication", + "References": "" + } + ] + }, + { + "Id": "4.3.1", + "Description": "Ensure the Dashboard is reviewed regularly for anomalies", + "Checks": [], + "Attributes": [ + { + "Section": "4 Security", + "SubSection": "4.3 Security Center", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "As an administrator, you can use the security dashboard to see an overview of different security reports. By default, each security report panel displays data from the last 7 days. You can customize the dashboard to view data from Today, Yesterday, This week, Last week, This month, Last month, or Days ago (up to 180 days). Charts/reports available (Minimum, but could be many more depending on account type): • DLP incidents • Top policy incidents • Failed device password attempts • Compromised device events • Suspicious device activities • OAuth scope grants by product (beta customers only) • OAuth grant activity • OAuth grants to new apps • User login attempts – Challenge method • User login attempts – Failed • User login attempts – Suspicious Details on what each of these charts/reports mean can be found here. This report should be reviewed weekly. NOTE: The availability of each individual report on the security dashboard depends on your Google Workspace edition. See Google documentation for more details. NOTE: In larger organizations reviewing this entire report weekly may not be possible. At a minimum, all Administrator and Super Administrator users should be reviewed, since they are a higher risk. These can be filtered from the overall user list.", + "RationaleStatement": "The Security report provides a comprehensive view of how people share and access data and whether they take appropriate security precautions. For example, you can review who installs external apps, shares numerous files, skips 2-Step Verification, and uses security keys.", + "ImpactStatement": "No user impact.", + "RemediationProcedure": "The remediation for any anomalies in the various fields varies widely (different sections of the Google Workspace Admin UI). Please refer to Google's documentation for specifics (here). NOTE: Many of these settings will be remedied by implementing other sections of this Benchmark. For example, an Admin not enrolled in 2-Step Verification can be remedied by implementing the Remediation procedure for the recommendation Ensure 2-Step Verification (Multi-Factor Authentication) is enforced for all users in administrative roles.", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Reporting 3. Select Reports 4. Select User Reports 5. Select Security, and a table of results will be displayed with the fields listed in the Recommendation description above. 6. Review the displayed users and values for anomalies", + "AdditionalInformation": "", + "DefaultValue": "The report will display all users and fields.", + "References": "https://support.google.com/a/answer/7492330" + } + ] + }, + { + "Id": "4.3.2", + "Description": "Ensure the Security health is reviewed regularly for anomalies", + "Checks": [], + "Attributes": [ + { + "Section": "4 Security", + "SubSection": "4.3 Security Center", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "As an administrator, the security health page enables you to monitor the configuration of your Admin console settings from one location. For example, you can check the status of settings like automatic email forwarding, device encryption, Drive sharing settings, and much more. Settings reported (Minimum, but could be many more depending on account type): • Blocking of compromised mobile devices • Mobile management • Mobile password requirements • Device encryption • Mobile inactivity reports • Auto account wipe • Application verification • Installation of mobile applications from unknown sources • External media storage • Two-step verification for users • Two-step verification for admins • Security key enforcement for admins Details on what each of these report entries mean can be found here. This report should be reviewed weekly. NOTE: The availability of each individual report on the security dashboard depends on your Google Workspace edition. See Google documentation for more details.", + "RationaleStatement": "The security health page provides visibility into your Admin console settings to help you better understand and manage security risks. If needed, you can make adjustments to your domain’s settings based on general security guidelines and best practices, while balancing these guidelines with your organization’s business needs and risk management policy.", + "ImpactStatement": "No user impact.", + "RemediationProcedure": "The remediation for any anomalies in the various settings varies widely (different sections of the Google Workspace Admin UI). Please refer to Google's documentation for specifics (here). NOTE: Many of these settings will be remedied by implementing other sections of this Benchmark. For example, an Admin not enrolled in 2-Step Verification can be remedied by implementing the Remediation procedure for the recommendation Ensure 2-Step Verification (Multi-Factor Authentication) is enforced for all users in administrative roles.", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Security 3. Select Security center 4. Select Security health, and a table of results will be displayed with the settings listed in the Recommendation description above. 5. Review the displayed values for anomalies", + "AdditionalInformation": "", + "DefaultValue": "The report will display the status of a predefined group of settings based on your Google Workspace license.", + "References": "https://support.google.com/a/answer/7491656" + } + ] + }, + { + "Id": "5.1.1.1", + "Description": "Ensure the App Usage Report is reviewed regularly for anomalies", + "Checks": [], + "Attributes": [ + { + "Section": "5 Reporting", + "SubSection": "5.1 Reports", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "As an administrator, you can use Apps usage reports to get an in-depth understanding of how your users use Google Workspace apps. Fields Available: • User • Gmail storage used (MB) • Drive storage used (MB) • Photos storage used (MB) • Total storage used (MB) • Storage used (%) • Classroom - last used time • Classes created • Posts created • Total emails • Emails sent • Emails received • Gmail (IMAP) - last used time • Gmail (POP) - last used time • Gmail (Web) - last used time • Files edited • Files viewed • Drive - last active time • Files added • Other types added • Google Docs added • Google Sheets added • Google Slides added • Google Forms added • Google Drawings added • Posts • +1s • +1s received • Comments • Comments received • Reshares • Reshares received • Search queries • Search queries from web • Search queries from Android • Search queries from iOS Details on what each of these fields mean can be found here. This report should be reviewed weekly. NOTE: In larger organizations reviewing this entire report weekly may not be possible. At a minimum, all Administrator and Super Administrator users should be reviewed, since they are a higher risk. These can be filtered from the overall user list.", + "RationaleStatement": "The App usage report can allow administrator to discover user that are potentially using application that they do not have access to and/or using in atypical ways.", + "ImpactStatement": "No user impact.", + "RemediationProcedure": "The remediation for any anomalies in the various fields varies widely (different sections of the Google Workspace Admin UI). Please refer to Google's documentation for specifics (here). NOTE: Many of these settings will be remedied by implementing other sections of this Benchmark. For example, an Admin showing recent Gmail (IMAP) - last used time and/or Gmail (POP) - last used time can be remedied by implementing the Remediation procedure for the recommendation Ensure POP and IMAP access is disabled for all users.", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Reporting 3. Select Reports 4. Select User Reports 5. Select App usage, and a table of results will be displayed with the fields listed in the Recommendation description above. 6. Review the displayed users and values for anomalies", + "AdditionalInformation": "", + "DefaultValue": "The report will display all users and fields.", + "References": "https://support.google.com/a/answer/4579578?hl=en" + } + ] + }, + { + "Id": "5.1.1.2", + "Description": "Ensure the Security Report is reviewed regularly for anomalies", + "Checks": [], + "Attributes": [ + { + "Section": "5 Reporting", + "SubSection": "5.1 Reports", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "As your organization's administrator, you can monitor your users' exposure to data compromise by reviewing the security report. Fields Available: • User • External apps • 2-Step verification enrollment • 2-Step verification enforcement • Password length compliance • Password strength • User account status • Admin status • Security keys enrolled • Less secure apps access • Gmail (IMAP) - last used time • Gmail (POP) - last used time • Gmail (Web) - last used time • External shares • Internal shares • Public • Anyone with link • Outside domain • Anyone in domain shares • Anyone in domain with link shares • Within domain shares • Private shares Details on what each of these fields mean can be found here. This report should be reviewed weekly. NOTE: In larger organizations reviewing this entire report weekly may not be possible. At a minimum, all Administrator and Super Administrator users should be reviewed, since they are a higher risk. These can be filtered from the overall user list.", + "RationaleStatement": "The Security report provides a comprehensive view of how people share and access data and whether they take appropriate security precautions. For example, you can review who installs external apps, shares numerous files, skips 2-Step Verification, and uses security keys.", + "ImpactStatement": "No user impact.", + "RemediationProcedure": "The remediation for any anomalies in the various fields varies widely (different sections of the Google Workspace Admin UI). Please refer to Google's documentation for specifics (here). NOTE: Many of these settings will be remedied by implementing other sections of this Benchmark. For example, an Admin not enrolled in 2-Step Verification can be remedied by implementing the Remediation procedure for the recommendation Ensure 2-Step Verification (Multi-Factor Authentication) is enforced for all users in administrative roles.", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Reporting 3. Select Reports 4. Select User Reports 5. Select Security, and a table of results will be displayed with the fields listed in the Recommendation description above. 6. Review the displayed users and values for anomalies", + "AdditionalInformation": "", + "DefaultValue": "The report will display all users and fields.", + "References": "https://support.google.com/a/answer/6000269?hl=en" + } + ] + }, + { + "Id": "6.1", + "Description": "Ensure User's password changed is configured", + "Checks": [ + "rules_password_changed_alert_configured" + ], + "Attributes": [ + { + "Section": "6 Rules", + "SubSection": "6.1", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Configuring and enabling the setting that an alert will be generated when a user's password has changed.", + "RationaleStatement": "Ensuring that administrators are alerted when user passwords are changed provides organizations with the ability to detect and halt potential attacks involving credential compromise and account takeover.", + "ImpactStatement": "This setting should have no impact on the end user but will send emails to super administrators when triggered.", + "RemediationProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Rules 3. Under Google protects you by default select View list. 4. Scroll to User's password changed and select it. 5. Within the Actions pane, click the edit pencil on the right side of the pane. 6. Select Send to alert center (This will result in the alert being set to On). 7. Set the alert severity to Medium 8. To enable emails when this alert condition is met, select Send email notifications. Once enabled, the All super administrators option is selected by default. 9. Click Review to confirm the values. 10. Click Update Rule. 11. Confirm that the User's password changed shows an Alert status of On in the list.", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Rules 3. Under Google protects you by default select View list. 4. Scroll to User's password changed and select it. 5. Ensure that Alerts is set to On. 6. Ensure the Severity is set to Medium 7. Ensure that Email Notifications is set to On 8. Ensure that Email notification recipients is set to All super administrators", + "AdditionalInformation": "", + "DefaultValue": "User's password changed is OFF", + "References": "" + } + ] + }, + { + "Id": "6.2", + "Description": "Ensure Government-backed attacks is configured", + "Checks": [ + "rules_government_backed_attacks_alert_configured" + ], + "Attributes": [ + { + "Section": "6 Rules", + "SubSection": "6.2", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Configuring and enabling the setting that an alert will be generated when Google believes your users are being targeted by a government-backed attack.", + "RationaleStatement": "Ensuring that administrators are alerted that they may be being targeted by a government-backed entity allows them time to check their defenses and potentially up their sensitivity for anomalies. NOTE: Google sends these out of an abundance of caution — the notice does not necessarily mean that the account has been compromised or that there is a widespread attack. Rather, the notice reflects Goggle's assessment that a government-backed attacker has likely attempted to access the user’s account or computer through phishing or malware, for example.", + "ImpactStatement": "This setting should have no impact on the end user but will send emails to super administrators when triggered.", + "RemediationProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Rules 3. Under Google protects you by default select View list. 4. Scroll to Government-backed attacks and select it. 5. Within the Actions pane, click the edit pencil on the right side of the pane. 6. Select Send to alert center (This will result in the alert being set to On). 7. Set the alert severity to High 8. To enable emails when this alert condition is met, select Send email notifications. Once enabled, the All super administrators option is selected by default. 9. Click Review to confirm the values. 10. Click Update Rule. 11. Confirm that the Government-backed attacks shows an Alert status of On in the list.", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Rules 3. Under Google protects you by default select View list. 4. Scroll to Government-backed attacks and select it. 5. Ensure that Alerts is set to On. 6. Ensure the Severity is set to High 7. Ensure that Email Notifications is set to On 8. Ensure that Email notification recipients is set to All super administrators", + "AdditionalInformation": "", + "DefaultValue": "Government-backed attacks is ON", + "References": "https://support.google.com/a/answer/3230421" + } + ] + }, + { + "Id": "6.3", + "Description": "Ensure User suspended due to suspicious activity is configured", + "Checks": [ + "rules_suspicious_activity_suspension_alert_configured" + ], + "Attributes": [ + { + "Section": "6 Rules", + "SubSection": "6.3", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Configuring and enabling the setting that an alert will be generated when Google suspended a user's account due to a potential compromise detected.", + "RationaleStatement": "Ensuring that administrators are alerted when the account was suspended by Google. The reason for this should be investigated ASAP, since it could be a possible indication of malicious activity. In any case, the user's account was suspended and something will need to be done to allow the user to resume work.", + "ImpactStatement": "Emails will be sent to all super administrators when triggered. Also, the user's account will be suspended and something will need to be done about that based on company policy (investigated, re-enabled, etc.).", + "RemediationProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Rules 3. Under Google protects you by default select View list. 4. Scroll to User suspended due to suspicious activity and select it. 5. Within the Actions pane, click the edit pencil on the right side of the pane. 6. Select Send to alert center (This will result in the alert being set to On). 7. Set the alert severity to High 8. To enable emails when this alert condition is met, select Send email notifications. Once enabled, the All super administrators option is selected by default. 9. Click Review to confirm the values. 10. Click Update Rule. 11. Confirm that the User suspended due to suspicious activity shows an Alert status of On in the list.", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Rules 3. Under Google protects you by default select View list. 4. Scroll to User suspended due to suspicious activity and select it. 5. Ensure that Alerts is set to On. 6. Ensure the Severity is set to High 7. Ensure that Email Notifications is set to On 8. Ensure that Email notification recipients is set to All super administrators", + "AdditionalInformation": "", + "DefaultValue": "User suspended due to suspicious activity is ON", + "References": "https://support.google.com/a/answer/3230421" + } + ] + }, + { + "Id": "6.4", + "Description": "Ensure User granted Admin privilege is configured", + "Checks": [ + "rules_admin_privilege_granted_alert_configured" + ], + "Attributes": [ + { + "Section": "6 Rules", + "SubSection": "6.4", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Configuring and enabling the setting that an alert will be generated when a user has been granted an admin privilege.", + "RationaleStatement": "Ensuring that administrators are alerted when a user is given increased privileges could be an indication of compromise unless this access has been approved.", + "ImpactStatement": "This setting should have no impact on the end user but will send emails to super administrators when triggered.", + "RemediationProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Rules 3. Under Google protects you by default select View list. 4. Scroll to User granted Admin privilege and select it. 5. Within the Actions pane, click the edit pencil on the right side of the pane. 6. Select Send to alert center (This will result in the alert being set to On). 7. Set the alert severity to Medium 8. To enable emails when this alert condition is met, select Send email notifications. Once enabled, the All super administrators option is selected by default. 9. Click Review to confirm the values. 10. Click Update Rule. 11. Confirm that the User granted Admin privilege shows an Alert status of On in the list.", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Rules 3. Under Google protects you by default select View list. 4. Scroll to User granted Admin privilege and select it. 5. Ensure that Alerts is set to On. 6. Ensure the Severity is set to Medium 7. Ensure that Email Notifications is set to On 8. Ensure that Email notification recipients is set to All super administrators", + "AdditionalInformation": "", + "DefaultValue": "User granted Admin privilege is OFF", + "References": "https://support.google.com/a/answer/3230421" + } + ] + }, + { + "Id": "6.5", + "Description": "Ensure Suspicious programmatic login is configured", + "Checks": [ + "rules_suspicious_programmatic_login_alert_configured" + ], + "Attributes": [ + { + "Section": "6 Rules", + "SubSection": "6.5", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Configuring and enabling the setting that an alert will be generated when Google detects suspicious login attempts from applications or computer programs.", + "RationaleStatement": "Ensuring that administrators are alerted when suspicious login attempts occur. This could be an indication of an active attack on the company by an adversary using previously obtained credentials.", + "ImpactStatement": "This setting should have no impact on the end user but will send emails to super administrators when triggered.", + "RemediationProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Rules 3. Under Google protects you by default select View list. 4. Scroll to Suspicious programmatic login and select it. 5. Within the Actions pane, click the edit pencil on the right side of the pane. 6. Select Send to alert center (This will result in the alert being set to On). 7. Set the alert severity to Low 8. To enable emails when this alert condition is met, select Send email notifications. Once enabled, the All super administrators option is selected by default. 9. Click Review to confirm the values. 10. Click Update Rule. 11. Confirm that the Suspicious programmatic login shows an Alert status of On in the list.", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Rules 3. Under Google protects you by default select View list. 4. Scroll to Suspicious programmatic login and select it. 5. Ensure that Alerts is set to On. 6. Ensure the Severity is set to Low 7. Ensure that Email Notifications is set to On 8. Ensure that Email notification recipients is set to All super administrators", + "AdditionalInformation": "", + "DefaultValue": "Suspicious programmatic login is ON", + "References": "https://support.google.com/a/answer/3230421" + } + ] + }, + { + "Id": "6.6", + "Description": "Ensure Suspicious login is configured", + "Checks": [ + "rules_suspicious_login_alert_configured" + ], + "Attributes": [ + { + "Section": "6 Rules", + "SubSection": "6.6", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Configuring and enabling the setting that an alert will be generated when Google detects a sign-in attempt that doesn't match a user's normal behavior, such as a sign-in from an unusual location.", + "RationaleStatement": "Ensuring that administrators are alerted when suspicious login attempts occur. This could be an indication of an active attack on the company by an adversary using previously obtained credentials.", + "ImpactStatement": "This setting should have no impact on the end user but will send emails to super administrators when triggered.", + "RemediationProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Rules 3. Under Google protects you by default select View list. 4. Scroll to Suspicious login and select it. 5. Within the Actions pane, click the edit pencil on the right side of the pane. 6. Select Send to alert center (This will result in the alert being set to On). 7. Set the alert severity to Low 8. To enable emails when this alert condition is met, select Send email notifications. Once enabled, the All super administrators option is selected by default. 9. Click Review to confirm the values. 10. Click Update Rule. 11. Confirm that the Suspicious login shows an Alert status of On in the list.", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Rules 3. Under Google protects you by default select View list. 4. Scroll to Suspicious login and select it. 5. Ensure that Alerts is set to On. 6. Ensure the Severity is set to Low 7. Ensure that Email Notifications is set to On 8. Ensure that Email notification recipients is set to All super administrators", + "AdditionalInformation": "", + "DefaultValue": "Suspicious login is ON", + "References": "https://support.google.com/a/answer/3230421" + } + ] + }, + { + "Id": "6.7", + "Description": "Ensure Leaked password is configured", + "Checks": [ + "rules_leaked_password_alert_configured" + ], + "Attributes": [ + { + "Section": "6 Rules", + "SubSection": "6.7", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Configuring and enabling the setting that an alert will be generated when Google detects compromised credentials requiring a reset of a user's password.", + "RationaleStatement": "Ensuring that administrators are alerted when Google detects that a user's credentials have been compromised due to a publicized breach. This is usually because the user has reused their credentials at another site that was breached.", + "ImpactStatement": "Emails will be sent to super administrators when triggered and in these cases, the user's password will need to be changed.", + "RemediationProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Rules 3. Under Google protects you by default select View list. 4. Scroll to Leaked password and select it. 5. Within the Actions pane, click the edit pencil on the right side of the pane. 6. Select Send to alert center (This will result in the alert being set to On). 7. Set the alert severity to High 8. To enable emails when this alert condition is met, select Send email notifications. Once enabled, the All super administrators option is selected by default. 9. Click Review to confirm the values. 10. Click Update Rule. 11. Confirm that the Leaked password shows an Alert status of On in the list.", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Rules 3. Under Google protects you by default select View list. 4. Scroll to Leaked password and select it. 5. Ensure that Alerts is set to On. 6. Ensure the Severity is set to Medium 7. Ensure that Email Notifications is set to On 8. Ensure that Email notification recipients is set to All super administrators", + "AdditionalInformation": "", + "DefaultValue": "Leaked password is ON", + "References": "https://support.google.com/a/answer/3230421" + } + ] + }, + { + "Id": "6.8", + "Description": "Ensure Gmail potential employee spoofing is configured", + "Checks": [ + "rules_gmail_employee_spoofing_alert_configured" + ], + "Attributes": [ + { + "Section": "6 Rules", + "SubSection": "6.8", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Configuring and enabling the setting that an alert will be generated when Google detects incoming messages are received where a sender’s name is in your Google Workspace directory, but the mail is not from your company’s domains or domain aliases.", + "RationaleStatement": "Ensuring that administrators are alerted when the email is being spoofed since this could be an indication of a phishing attempt.", + "ImpactStatement": "This setting should have no impact on the end user but will send emails to super administrators when triggered.", + "RemediationProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Rules 3. Under Google protects you by default select View list. 4. Scroll to Gmail potential employee spoofing and select it. 5. Within the Actions pane, click the edit pencil on the right side of the pane. 6. Select Send to alert center (This will result in the alert being set to On). 7. Set the alert severity to Medium 8. To enable emails when this alert condition is met, select Send email notifications. Once enabled, the All super administrators option is selected by default. 9. Click Review to confirm the values. 10. Click Update Rule. 11. Confirm that the Gmail potential employee spoofing shows an Alert status of On in the list.", + "AuditProcedure": "To verify this setting via the Google Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Rules 3. Under Google protects you by default select View list. 4. Scroll to Gmail potential employee spoofing and select it. 5. Ensure that Alerts is set to On. 6. Ensure the Severity is set to Medium 7. Ensure that Email Notifications is set to On 8. Ensure that Email notification recipients is set to All super administrators", + "AdditionalInformation": "", + "DefaultValue": "Gmail potential employee spoofing is ON", + "References": "https://support.google.com/a/answer/3230421" + } + ] + } + ] +} diff --git a/prowler/compliance/googleworkspace/cisa_scuba_0.6_googleworkspace.json b/prowler/compliance/googleworkspace/cisa_scuba_0.6_googleworkspace.json index 72ff97d97d..6bf49be05f 100644 --- a/prowler/compliance/googleworkspace/cisa_scuba_0.6_googleworkspace.json +++ b/prowler/compliance/googleworkspace/cisa_scuba_0.6_googleworkspace.json @@ -9,7 +9,6 @@ "Id": "GWS.COMMONCONTROLS.1.1", "Description": "Phishing-resistant MFA SHALL be required for all users", "Checks": [ - "security_2sv_enforced", "security_2sv_hardware_keys_admins" ], "Attributes": [ diff --git a/prowler/compliance/kubernetes/iso27001_2022_kubernetes.json b/prowler/compliance/kubernetes/iso27001_2022_kubernetes.json index 73f1b1266b..131fa9d683 100644 --- a/prowler/compliance/kubernetes/iso27001_2022_kubernetes.json +++ b/prowler/compliance/kubernetes/iso27001_2022_kubernetes.json @@ -1132,7 +1132,6 @@ "etcd_no_auto_tls", "etcd_no_peer_auto_tls", "etcd_peer_tls_config", - "etcd_tls_encryption", "kubelet_tls_cert_and_key" ] }, diff --git a/prowler/compliance/m365/cis_4.0_m365.json b/prowler/compliance/m365/cis_4.0_m365.json index 5ca427d0aa..7647bad3eb 100644 --- a/prowler/compliance/m365/cis_4.0_m365.json +++ b/prowler/compliance/m365/cis_4.0_m365.json @@ -9,7 +9,7 @@ "Id": "1.1.1", "Description": "Administrative accounts are special privileged accounts that could have varying levels of access to data, users, and settings. Regular user accounts should never be utilized for administrative tasks and care should be taken, in the case of a hybrid environment, to keep Administrative accounts separated from on-prem accounts. Administrative accounts should not have applications assigned so that they have no access to potentially vulnerable services (EX. email, Teams, SharePoint, etc.) and only access to perform tasks as needed for administrative purposes.Ensure administrative accounts are not `On-premises sync enabled`.", "Checks": [ - "entra_admin_account_cloud_only" + "entra_admin_users_cloud_only" ], "Attributes": [ { @@ -1357,7 +1357,7 @@ "Id": "5.2.2.8", "Description": "When a Conditional Access policy targets the Microsoft Admin Portals cloud app, the policy is enforced for tokens issued to application IDs of the following Microsoft administrative portals:- Azure portal- Exchange admin center- Microsoft 365 admin center- Microsoft 365 Defender portal- Microsoft Entra admin center- Microsoft Intune admin center- Microsoft Purview compliance portal- Power Platform admin center- SharePoint admin center- Microsoft Teams admin center`Microsoft Admin Portals` should be restricted to specific pre-determined administrative roles.", "Checks": [ - "entra_admin_portals_role_limited_access" + "entra_admin_portals_access_restriction" ], "Attributes": [ { diff --git a/prowler/compliance/m365/iso27001_2022_m365.json b/prowler/compliance/m365/iso27001_2022_m365.json index 238002d0b6..e7bf650ac7 100644 --- a/prowler/compliance/m365/iso27001_2022_m365.json +++ b/prowler/compliance/m365/iso27001_2022_m365.json @@ -51,13 +51,10 @@ "admincenter_users_between_two_and_four_global_admins", "defender_antispam_outbound_policy_configured", "entra_admin_consent_workflow_enabled", - "entra_admin_portals_access_restriction", "entra_admin_users_cloud_only", - "entra_admin_users_mfa_enabled", "entra_admin_users_phishing_resistant_mfa_enabled", "entra_admin_users_sign_in_frequency_enabled", - "entra_policy_ensure_default_user_cannot_create_tenants", - "entra_policy_guest_invite_only_for_admin_roles" + "entra_policy_ensure_default_user_cannot_create_tenants" ] }, { @@ -119,7 +116,7 @@ "defender_safelinks_policy_enabled", "defender_zap_for_teams_enabled", "defenderxdr_endpoint_privileged_user_exposed_credentials", - "defender_identity_health_issues_no_open", + "defenderidentity_health_issues_no_open", "entra_admin_users_phishing_resistant_mfa_enabled", "entra_conditional_access_policy_block_elevated_insider_risk", "entra_conditional_access_policy_block_o365_elevated_insider_risk", @@ -186,7 +183,6 @@ "sharepoint_guest_sharing_restricted", "sharepoint_modern_authentication_required", "sharepoint_onedrive_sync_restricted_unmanaged_devices", - "teams_external_file_sharing_restricted", "teams_external_file_sharing_restricted" ] }, @@ -780,7 +776,7 @@ "defender_malware_policy_comprehensive_attachments_filter_applied", "defender_malware_policy_notifications_internal_users_malware_enabled", "defenderxdr_endpoint_privileged_user_exposed_credentials", - "defender_identity_health_issues_no_open" + "defenderidentity_health_issues_no_open" ] }, { diff --git a/prowler/config/config.py b/prowler/config/config.py index 1c0ae406cc..21929b5099 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.41.0" +prowler_version = "5.43.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/config/config.yaml b/prowler/config/config.yaml index 7dd7c2bf3d..0c7551d634 100644 --- a/prowler/config/config.yaml +++ b/prowler/config/config.yaml @@ -119,6 +119,14 @@ aws: # aws.cloudwatch_log_group_retention_policy_specific_days_enabled --> by default is 365 days log_group_retention_days: 365 + # aws.cloudwatch_log_group_agentcore_data_protection_policy_enabled + # Log group name prefixes that hold Bedrock AgentCore agent telemetry. This list REPLACES + # the defaults rather than adding to them, so keep both entries below when adding your own + # or the log groups AgentCore creates itself stop being assessed. + agentcore_log_group_name_prefixes: + - "/aws/bedrock-agentcore/" + - "/aws/vendedlogs/bedrock-agentcore/" + # AWS CloudFormation Configuration # cloudformation_stack_cdktoolkit_bootstrap_version --> by default is 21 recommended_cdk_bootstrap_version: 21 @@ -182,6 +190,14 @@ aws: # MEDIUM ecr_repository_vulnerability_minimum_severity: "MEDIUM" + # AWS Inspector2 + # aws.inspector2_coverage_recently_scanned + # Maximum days since Inspector2 last scanned an actively covered resource + inspector2_max_days_since_last_scan: 3 + # aws.inspector2_active_findings_within_max_age + # Maximum days an Inspector2 finding can stay active since it was first observed + inspector2_active_finding_max_age_days: 192 + # AWS Trusted Advisor # aws.trustedadvisor_premium_support_plan_subscribed verify_premium_support_plans: True diff --git a/prowler/config/schema/aws.py b/prowler/config/schema/aws.py index c0ecf9a21c..c46d848650 100644 --- a/prowler/config/schema/aws.py +++ b/prowler/config/schema/aws.py @@ -267,6 +267,15 @@ class AWSProviderConfig(ProviderConfigBase): f"values accepted by the AWS API: {list(_CLOUDWATCH_RETENTION_DAYS)}." ), ) + agentcore_log_group_name_prefixes: Optional[list[str]] = Field( + default=None, + description=( + "Log group name prefixes that identify Bedrock AgentCore agent " + "telemetry. Set this when AgentCore log delivery is pointed at log " + "groups outside the service defaults; the value replaces the " + "defaults, so list them alongside any prefix of your own." + ), + ) recommended_cdk_bootstrap_version: Optional[int] = Field( default=None, ge=1, @@ -324,6 +333,20 @@ class AWSProviderConfig(ProviderConfigBase): description="Highest severity tolerated for ECR images.", ) + # --- Inspector2 ------------------------------------------------------- + inspector2_max_days_since_last_scan: Optional[int] = Field( + default=None, + ge=1, + le=90, + description="Days since Inspector2 last scanned a covered resource. Range: 1..90.", + ) + inspector2_active_finding_max_age_days: Optional[int] = Field( + default=None, + ge=1, + le=365, + description="Days an Inspector2 finding can stay active since first observed. Range: 1..365.", + ) + # --- Trusted Advisor -------------------------------------------------- verify_premium_support_plans: Optional[bool] = None diff --git a/prowler/lib/outputs/jira/jira.py b/prowler/lib/outputs/jira/jira.py index 1ddeb8cf3f..a6100b54e1 100644 --- a/prowler/lib/outputs/jira/jira.py +++ b/prowler/lib/outputs/jira/jira.py @@ -3,8 +3,10 @@ import hashlib import os import re from collections.abc import Mapping +from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass from datetime import datetime, timedelta +from threading import Lock from typing import Dict, List, Optional import requests @@ -416,6 +418,7 @@ class Jira: api_token: str = None, domain: str = None, ): + self._token_lock = Lock() self._redirect_uri = redirect_uri self._client_id = client_id self._client_secret = client_secret @@ -1017,11 +1020,15 @@ class Jira: if self._using_basic_auth: return self._access_token - if self.auth_expiration and datetime.now() < datetime.fromisoformat( - self.auth_expiration - ): + if self._access_token_is_valid(): return self._access_token - else: + + # Atlassian rotates refresh tokens, so two concurrent refreshes with + # the same one would invalidate each other. Re-check under the lock + # in case another thread refreshed while we waited for it. + with self._token_lock: + if self._access_token_is_valid(): + return self._access_token return self.refresh_access_token() except JiraRefreshTokenError as refresh_error: raise refresh_error @@ -1034,6 +1041,12 @@ class Jira: file=os.path.basename(__file__), ) + def _access_token_is_valid(self) -> bool: + """Return whether the current OAuth access token has not expired.""" + return bool(self.auth_expiration) and datetime.now() < datetime.fromisoformat( + self.auth_expiration + ) + def refresh_access_token(self) -> str: """Refresh the access token @@ -1128,15 +1141,21 @@ class Jira: projects = jira.get_projects() issue_types = {} - for project_key in projects: - try: - issue_types[project_key] = jira.get_available_issue_types( - project_key - ) - except Exception as e: - logger.warning( - f"Failed to get issue types for project {project_key}: {e}" - ) + with ThreadPoolExecutor(max_workers=10) as executor: + future_to_project = { + executor.submit( + jira.get_available_issue_types, project_key + ): project_key + for project_key in projects + } + for future in as_completed(future_to_project): + project_key = future_to_project[future] + try: + issue_types[project_key] = future.result() + except Exception as e: + logger.warning( + f"Failed to get issue types for project {project_key}: {e}" + ) return JiraConnection( is_connected=True, projects=projects, issue_types=issue_types @@ -1296,7 +1315,10 @@ class Jira: if response.status_code == 200: if len(response.json()["projects"]) == 0: - logger.error("No projects found") + # Expected per-project condition (e.g. the integration user lacks + # "create issue" rights on this specific project) — the caller in + # test_connection() already treats this as non-fatal, so this isn't + # an error worth alerting on. raise JiraNoProjectsError( message="No projects found in Jira", file=os.path.basename(__file__), @@ -1316,6 +1338,13 @@ class Jira: raise refresh_error except JiraRefreshTokenResponseError as response_error: raise response_error + except JiraNoProjectsError as no_projects_error: + # Expected per-project condition; the caller decides whether to log it. + raise JiraGetAvailableIssueTypesError( + message="Failed to get available issue types", + file=os.path.basename(__file__), + original_exception=no_projects_error, + ) except Exception as e: logger.error(f"Failed to get available issue types: {e}") raise JiraGetAvailableIssueTypesError( diff --git a/prowler/providers/aws/aws_provider.py b/prowler/providers/aws/aws_provider.py index b4c9ed3771..784daf0caa 100644 --- a/prowler/providers/aws/aws_provider.py +++ b/prowler/providers/aws/aws_provider.py @@ -1,13 +1,21 @@ import os import pathlib from datetime import datetime +from functools import lru_cache from re import fullmatch -from typing import Optional +from typing import Any, Callable, Optional from boto3.session import Session from botocore.config import Config from botocore.credentials import RefreshableCredentials -from botocore.exceptions import ClientError, NoCredentialsError, ProfileNotFound +from botocore.exceptions import ( + ClientError, + ConnectTimeoutError, + EndpointConnectionError, + NoCredentialsError, + ProfileNotFound, + ReadTimeoutError, +) from botocore.session import Session as BotocoreSession from colorama import Fore, Style from pytz import utc @@ -125,6 +133,8 @@ class AwsProvider(Provider): aws_access_key_id: str = None, aws_secret_access_key: str = None, aws_session_token: Optional[str] = None, + connect_timeout: Optional[int] = None, + read_timeout: Optional[int] = None, ): """ Initializes the AWS provider. @@ -154,6 +164,8 @@ class AwsProvider(Provider): - aws_access_key_id: The AWS access key ID. - aws_secret_access_key: The AWS secret access key. - aws_session_token: The AWS session token, optional. + - connect_timeout: Seconds to wait to establish a connection to an AWS endpoint. + - read_timeout: Seconds to wait for a response from an AWS endpoint. Raises: - ArgumentTypeError: If the input MFA ARN is invalid. @@ -228,7 +240,9 @@ class AwsProvider(Provider): # TODO: Use AwsSetUpSession ????? # Configure the initial AWS Session using the local credentials: profile or environment variables - session_config = self.set_session_config(retries_max_attempts) + session_config = self.set_session_config( + retries_max_attempts, connect_timeout, read_timeout + ) aws_session = self.setup_session( mfa=mfa, profile=profile, @@ -256,7 +270,10 @@ class AwsProvider(Provider): caller_identity = self.validate_credentials( session=self.session.current_session, aws_region=sts_region, + excluded_regions=excluded_regions, ) + # Later STS calls go where validation got an answer, not where it timed out + sts_region = caller_identity.region logger.info("Credentials validated") ######## @@ -575,8 +592,15 @@ class AwsProvider(Provider): ) -> str: excluded_regions = set(excluded_regions or ()) session_region = session.region_name + env_partition_regions = get_env_partition_regions(session_region) if session_region and session_region not in excluded_regions: - return session_region + if not env_partition_regions or session_region in env_partition_regions: + return session_region + if env_partition_regions: + for region in env_partition_regions: + if region not in excluded_regions: + return region + return env_partition_regions[0] for region in AwsProvider.get_bootstrap_region_candidates(session_region): if region not in excluded_regions: @@ -671,8 +695,11 @@ class AwsProvider(Provider): if mfa: session = Session(**session_arguments) session._session.set_default_client_config(session_config) - sts_client = session.client("sts") - + sts_region = ( + get_env_partition_bootstrap_region(session.region_name) + or session.region_name + or AWS_STS_GLOBAL_ENDPOINT_REGION + ) # TODO: pass values from the input mfa_info = AwsProvider.input_role_mfa_token_and_code() # TODO: validate MFA ARN here @@ -680,8 +707,12 @@ class AwsProvider(Provider): "SerialNumber": mfa_info.arn, "TokenCode": mfa_info.totp, } - session_credentials = sts_client.get_session_token( - **get_session_token_arguments + _, session_credentials = AwsProvider.sts_call_with_partition_failover( + session, + sts_region, + lambda sts_client: sts_client.get_session_token( + **get_session_token_arguments + ), ) mfa_session = Session( aws_access_key_id=session_credentials["Credentials"]["AccessKeyId"], @@ -902,6 +933,9 @@ class AwsProvider(Provider): logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + # Return an empty dict, as promised by the signature, so the service + # is simply not scanned instead of the callers failing later on a None + return {} @staticmethod def get_available_aws_service_regions( @@ -917,9 +951,13 @@ class AwsProvider(Provider): Returns: - A set of strings representing the available regions for the given service and partition. + A service or a partition not present in the regions file yields an empty set, the same + outcome as a service explicitly recorded as unavailable in the partition. """ data = read_aws_regions_file() - json_regions = set(data["services"][service]["regions"][partition]) + json_regions = set( + data["services"].get(service, {}).get("regions", {}).get(partition, []) + ) if audited_regions: # Get common regions between input and json regions = json_regions.intersection(audited_regions) @@ -1126,16 +1164,14 @@ class AwsProvider(Provider): Example: global_region = get_global_region()a """ - global_region = "us-east-1" - if self._identity.partition == "aws-cn": - global_region = "cn-north-1" - elif self._identity.partition == "aws-eusc": - global_region = "eusc-de-east-1" - elif self._identity.partition == "aws-us-gov": - global_region = "us-gov-east-1" - elif "aws-iso" in self._identity.partition: - global_region = "aws-iso-global" - return global_region + # The first region of the partition is the one of its global STS endpoint, + # which is always a real region, never a pseudo endpoint like "aws-iso-global" + partition_regions = get_botocore_partition_regions().get( + self._identity.partition + ) + if partition_regions: + return partition_regions[0] + return "us-east-1" @staticmethod def input_role_mfa_token_and_code() -> AWSMFAInfo: @@ -1152,26 +1188,35 @@ class AwsProvider(Provider): return AWSMFAInfo(arn=mfa_ARN, totp=mfa_TOTP) @staticmethod - def set_session_config(retries_max_attempts: int) -> Config: + def set_session_config( + retries_max_attempts: int, + connect_timeout: Optional[int] = None, + read_timeout: Optional[int] = None, + ) -> Config: """ - set_session_config returns a botocore Config object with the Prowler user agent and the default retrier configuration if nothing is passed as argument + set_session_config returns a botocore Config object with the Prowler user agent and the default retrier and timeout configuration if nothing is passed as argument Args: - retries_max_attempts: The maximum number of retries for the standard retrier config + - connect_timeout: Seconds to wait to establish a connection to an AWS endpoint + - read_timeout: Seconds to wait for a response from an AWS endpoint Returns: - Config: The botocore Config object """ default_session_config = get_default_session_config() - if retries_max_attempts: - default_session_config = default_session_config.merge( - Config( - retries={ - "max_attempts": retries_max_attempts, - "mode": "standard", - }, - ) - ) + overrides = {} + if retries_max_attempts is not None: + overrides["retries"] = { + "max_attempts": retries_max_attempts, + "mode": "standard", + } + if connect_timeout: + overrides["connect_timeout"] = connect_timeout + if read_timeout: + overrides["read_timeout"] = read_timeout + if overrides: + default_session_config = default_session_config.merge(Config(**overrides)) return default_session_config @@ -1211,10 +1256,11 @@ class AwsProvider(Provider): mfa_info = AwsProvider.input_role_mfa_token_and_code() assume_role_arguments["SerialNumber"] = mfa_info.arn assume_role_arguments["TokenCode"] = mfa_info.totp - sts_client = AwsProvider.create_sts_session( - session, assumed_role_info.sts_region + _, assumed_credentials = AwsProvider.sts_call_with_partition_failover( + session, + assumed_role_info.sts_region, + lambda sts_client: sts_client.assume_role(**assume_role_arguments), ) - assumed_credentials = sts_client.assume_role(**assume_role_arguments) # Convert the UTC datetime object to your local timezone credentials_expiration_local_time = ( assumed_credentials["Credentials"]["Expiration"] @@ -1293,30 +1339,98 @@ class AwsProvider(Provider): ) raise error + @staticmethod + def sts_call_with_partition_failover( + session: Session, + aws_region: str, + operation: Callable[[Any], Any], + excluded_regions: set[str] | None = None, + ) -> tuple[str, Any]: + """ + Run a bootstrap STS call, moving on when a region cannot be reached. + + Bootstrap calls happen before anything is known about the credentials, so + the region they go to is a guess whenever none was configured. On a network + that routes to only one region of its partition that guess is fatal, and the + remaining regions of the partition declared in PROWLER_AWS_PARTITION are the + ones worth trying. + + Args: + session (Session): The AWS session object. + aws_region (str): The region to try first. + operation (Callable[[Any], Any]): Receives an STS client and performs + the call. + excluded_regions (set[str] | None): Regions excluded from the scan, + tried after the rest of the partition. + + Returns: + tuple[str, Any]: The region that answered and whatever the operation + returned. + + Raises: + Exception: Whatever the operation raises, or the last connection error + when no region could be reached. + """ + *fallback_regions, last_region = get_partition_bootstrap_candidates( + aws_region, session.region_name, excluded_regions + ) + + for candidate_region in fallback_regions: + try: + sts_client = AwsProvider.create_sts_session(session, candidate_region) + return candidate_region, operation(sts_client) + # The credentials are not at fault, so the next region is worth trying + except ( + EndpointConnectionError, + ConnectTimeoutError, + ReadTimeoutError, + ) as unreachable: + logger.warning( + f"{unreachable.__class__.__name__}[{unreachable.__traceback__.tb_lineno}]: {unreachable}" + ) + + # Nothing is left to try after the last region, so its error is the answer + sts_client = AwsProvider.create_sts_session(session, last_region) + return last_region, operation(sts_client) + @staticmethod def validate_credentials( session: Session, aws_region: str, + excluded_regions: set[str] | None = None, ) -> AWSCallerIdentity: """ Validates the AWS credentials using the provided session and AWS region. + + When the region cannot be reached, the remaining regions of the partition + declared in PROWLER_AWS_PARTITION are tried before giving up. A credential + error is returned from the first region instead, since it would be the same + everywhere. + Args: session (Session): The AWS session object. aws_region (str): The AWS region to validate the credentials. + excluded_regions (set[str] | None): Regions excluded from the scan, + tried after the rest of the partition. Returns: - AWSCallerIdentity: An object containing the caller identity information. + AWSCallerIdentity: An object containing the caller identity information, + including the region that answered. Raises: Exception: If an error occurs during the validation process. """ try: - sts_client = AwsProvider.create_sts_session(session, aws_region) - caller_identity = sts_client.get_caller_identity() + sts_region, caller_identity = AwsProvider.sts_call_with_partition_failover( + session, + aws_region, + lambda sts_client: sts_client.get_caller_identity(), + excluded_regions, + ) # Include the region where the caller_identity has validated the credentials return AWSCallerIdentity( user_id=caller_identity.get("UserId"), account=caller_identity.get("Account"), arn=ARN(caller_identity.get("Arn")), - region=aws_region, + region=sts_region, ) except ClientError as client_error: logger.error( @@ -1352,7 +1466,7 @@ class AwsProvider(Provider): @staticmethod def test_connection( profile: str = None, - aws_region: str = AWS_STS_GLOBAL_ENDPOINT_REGION, + aws_region: str = None, role_arn: str = None, role_session_name: str = ROLE_SESSION_NAME, session_duration: int = 3600, @@ -1369,7 +1483,9 @@ class AwsProvider(Provider): Args: profile (str): The AWS profile to use for the session. - aws_region (str): The AWS region to validate the credentials in. + aws_region (str): The AWS region to validate the credentials in. When not + provided, it defaults to the bootstrap region of the partition set in + the PROWLER_AWS_PARTITION environment variable or, if unset, to us-east-1. role_arn (str): The ARN of the IAM role to assume. role_session_name (str): The name of the role session. session_duration (int): The duration of the assumed role session in seconds. @@ -1420,6 +1536,12 @@ class AwsProvider(Provider): aws_session_token=aws_session_token, ) + if aws_region is None: + aws_region = ( + get_env_partition_bootstrap_region(session.region_name) + or AWS_STS_GLOBAL_ENDPOINT_REGION + ) + if role_arn: session_duration = validate_session_duration(session_duration) role_session_name = validate_role_session_name(role_session_name) @@ -1430,6 +1552,7 @@ class AwsProvider(Provider): external_id=external_id, mfa_enabled=mfa_enabled, role_session_name=role_session_name, + sts_region=aws_region, ) assumed_role_credentials = AwsProvider.assume_role( session, @@ -1451,6 +1574,13 @@ class AwsProvider(Provider): if provider_id and caller_identity.account != provider_id: raise AWSInvalidProviderIdError(file=pathlib.Path(__file__).name) + # Validate that the account belongs to the configured partition, if any + env_partition = os.environ.get("PROWLER_AWS_PARTITION", "").strip() + if env_partition and caller_identity.arn.partition != env_partition: + raise AWSInvalidPartitionError( + message=f"The AWS account is in the {caller_identity.arn.partition} partition, but this deployment is configured for the {env_partition} partition via PROWLER_AWS_PARTITION" + ) + return Connection( is_connected=True, ) @@ -1591,6 +1721,14 @@ class AwsProvider(Provider): raise session_token_expired return Connection(error=session_token_expired) + except AWSInvalidPartitionError as invalid_partition_error: + logger.error( + f"{invalid_partition_error.__class__.__name__}[{invalid_partition_error.__traceback__.tb_lineno}]: {invalid_partition_error}" + ) + if raise_on_exception: + raise invalid_partition_error + return Connection(error=invalid_partition_error) + except Exception as error: logger.critical( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" @@ -1618,14 +1756,9 @@ class AwsProvider(Provider): sts_client = create_sts_session(session, 'us-west-2') """ try: - if os.environ.get("AWS_ENDPOINT_URL"): - sts_endpoint_url = os.environ["AWS_ENDPOINT_URL"] - elif aws_region.startswith("cn-"): - sts_endpoint_url = f"https://sts.{aws_region}.amazonaws.com.cn" - elif aws_region.startswith("eusc-"): - sts_endpoint_url = f"https://sts.{aws_region}.amazonaws.eu" - else: - sts_endpoint_url = f"https://sts.{aws_region}.amazonaws.com" + # Botocore resolves the regional STS endpoint for every partition + # (China, EUSC, GovCloud, ISO); AWS_ENDPOINT_URL overrides it + sts_endpoint_url = os.environ.get("AWS_ENDPOINT_URL") or None return session.client("sts", aws_region, endpoint_url=sts_endpoint_url) except Exception as error: logger.critical( @@ -1702,6 +1835,141 @@ def read_aws_regions_file() -> dict: return data +@lru_cache(maxsize=1) +def get_botocore_partition_regions() -> dict: + """ + Get the AWS partitions and their bootstrap region candidates from the + botocore endpoints data. + + The region of the partition's global STS endpoint, when declared, is moved + to the front since it is never an opt-in region; the rest are sorted + alphabetically. + + Returns: + dict: A dictionary mapping each partition name to its list of regions. + """ + endpoints_data = BotocoreSession().get_data("endpoints") + partition_regions = {} + for partition in endpoints_data["partitions"]: + regions = sorted(partition.get("regions", {})) + sts_service = partition.get("services", {}).get("sts", {}) + global_endpoint = sts_service.get("partitionEndpoint") + global_region = ( + sts_service.get("endpoints", {}) + .get(global_endpoint, {}) + .get("credentialScope", {}) + .get("region") + ) + if global_region in regions: + regions.remove(global_region) + regions.insert(0, global_region) + partition_regions[partition["partition"]] = regions + return partition_regions + + +def get_env_partition_regions( + session_region: Optional[str] = None, +) -> Optional[list]: + """ + Get the bootstrap region candidates for the partition set in the + PROWLER_AWS_PARTITION environment variable. + + Args: + session_region (Optional[str]): The region of the AWS session. It leads + the candidates when it belongs to the partition and is ignored + otherwise. + + Returns: + Optional[list]: The regions of the configured partition, preferred + bootstrap region first, or None when the environment variable is + not set. + + Raises: + AWSInvalidPartitionError: If the value is not a partition known to botocore. + """ + raw_partition = os.environ.get("PROWLER_AWS_PARTITION", "").strip() + if not raw_partition: + return None + + partition_regions = get_botocore_partition_regions() + regions = partition_regions.get(raw_partition) + if not regions: + raise AWSInvalidPartitionError( + message=f"Invalid partition: {raw_partition} set in PROWLER_AWS_PARTITION. Valid partitions: {', '.join(sorted(partition_regions))}" + ) + + # A deployment reached only through its own region's endpoints has no route + # to the partition's global STS region, so the session region goes first + if session_region in regions: + regions = [session_region] + [r for r in regions if r != session_region] + return regions + + +def get_env_partition_bootstrap_region( + session_region: Optional[str] = None, +) -> Optional[str]: + """ + Get the STS bootstrap region for the partition set in the + PROWLER_AWS_PARTITION environment variable. + + Args: + session_region (Optional[str]): The region of the AWS session, preferred + when it belongs to the partition. + + Returns: + Optional[str]: The preferred bootstrap region of the configured + partition, or None when the environment variable is not set. + + Raises: + AWSInvalidPartitionError: If the value is not a partition known to botocore. + """ + regions = get_env_partition_regions(session_region) + return regions[0] if regions else None + + +# An unreachable endpoint costs a connection timeout, so a partition with many +# regions is not walked in full +MAX_STS_BOOTSTRAP_ATTEMPTS = 3 + + +def get_partition_bootstrap_candidates( + aws_region: str, + session_region: Optional[str] = None, + excluded_regions: set[str] | None = None, +) -> list: + """ + Get the STS bootstrap regions to try, in order, starting with the chosen one. + + A deployment reached only through its own region's endpoints has no route to + the rest of its partition, and which region that is cannot be known from the + environment alone: a container may carry a region belonging to no partition + it scans. Offering the remaining regions of the declared partition lets the + bootstrap succeed without anything having to declare the right one. + + Args: + aws_region (str): The region already chosen for the bootstrap call. + session_region (Optional[str]): The region of the AWS session. + excluded_regions (set[str] | None): Regions excluded from the scan. They + go after the rest of the partition, so the bootstrap avoids them + whenever another region answers and still has them as a last resort. + + Returns: + list: The regions to try, preferred first, capped at + MAX_STS_BOOTSTRAP_ATTEMPTS. + """ + excluded_regions = set(excluded_regions or ()) + partition_regions = get_env_partition_regions(session_region) or [] + # sorted() is stable, so the partition order survives on each side of the split + ordered_regions = sorted( + partition_regions, key=lambda region: region in excluded_regions + ) + candidates = [aws_region] + for region in ordered_regions: + if region not in candidates: + candidates.append(region) + return candidates[:MAX_STS_BOOTSTRAP_ATTEMPTS] + + # TODO: This can be moved to another class since it doesn't need self def get_aws_region_for_sts( session_region: str, @@ -1711,6 +1979,10 @@ def get_aws_region_for_sts( """ Get the AWS region for the STS Assume Role operation. + The precedence is: explicit regions, the partition set in the + PROWLER_AWS_PARTITION environment variable, the session region and, + finally, the bootstrap region candidates. + Args: - session_region (str): The region configured in the AWS session. - regions (set[str]): The regions passed with the -f/--region/--filter-region option. @@ -1730,6 +2002,15 @@ def get_aws_region_for_sts( if region not in excluded_regions: return region + env_partition_regions = get_env_partition_regions(session_region) + if env_partition_regions: + # The configured partition constrains the whole fallback chain: prefer + # a non-excluded region, but never leave the partition + for region in env_partition_regions: + if region not in excluded_regions: + return region + return env_partition_regions[0] + if session_region and session_region not in excluded_regions: return session_region diff --git a/prowler/providers/aws/aws_regions_by_service.json b/prowler/providers/aws/aws_regions_by_service.json index bd66643d27..bb51556b78 100644 --- a/prowler/providers/aws/aws_regions_by_service.json +++ b/prowler/providers/aws/aws_regions_by_service.json @@ -10,6 +10,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -58,6 +62,15 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -102,6 +115,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -150,6 +167,15 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -201,6 +227,15 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -221,6 +256,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -238,6 +277,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -268,6 +311,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -292,6 +339,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-west-1" ] @@ -323,6 +374,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -351,6 +406,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -379,6 +438,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -427,6 +490,17 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -461,6 +535,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -510,6 +588,17 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -561,6 +650,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -612,6 +716,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -640,6 +759,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -659,6 +782,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -693,6 +820,21 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -737,6 +879,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -750,6 +896,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -787,6 +937,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -807,6 +961,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -834,6 +992,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -880,6 +1042,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -923,6 +1089,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -974,6 +1144,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -1022,6 +1207,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -1073,6 +1262,19 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1" + ], + "aws-iso-b": [ + "us-isob-east-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -1097,6 +1299,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1118,6 +1324,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1166,6 +1376,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -1217,6 +1431,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -1232,6 +1461,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1250,6 +1483,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1298,6 +1535,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -1319,6 +1560,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1337,6 +1582,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1385,6 +1634,18 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1" + ], + "aws-iso-e": [], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -1418,6 +1679,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -1469,6 +1734,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -1484,6 +1764,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1496,6 +1780,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1506,6 +1794,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1550,6 +1842,12 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1" + ], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -1576,6 +1874,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-west-1" ] @@ -1606,6 +1908,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1626,6 +1932,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-west-1" ] @@ -1657,6 +1967,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -1670,6 +1984,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1680,6 +1998,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1694,6 +2016,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1711,6 +2037,18 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1" + ], + "aws-iso-b": [ + "us-isob-east-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-south-1" + ], "aws-us-gov": [] } }, @@ -1754,6 +2092,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1781,6 +2123,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -1812,6 +2158,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -1826,6 +2176,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1845,6 +2199,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1866,6 +2224,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -1880,6 +2242,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1899,6 +2265,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1919,6 +2289,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1939,6 +2313,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1970,6 +2348,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -2018,6 +2400,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -2039,6 +2436,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-west-1" ] @@ -2071,6 +2472,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -2104,6 +2509,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -2155,6 +2564,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -2204,6 +2628,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -2245,6 +2673,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -2267,6 +2699,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -2307,6 +2743,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -2358,6 +2798,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -2385,6 +2840,15 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [] } }, @@ -2433,6 +2897,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -2458,6 +2937,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -2501,6 +2984,18 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1" + ], + "aws-iso-e": [], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -2515,6 +3010,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -2552,6 +3051,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -2581,6 +3084,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1" ] @@ -2631,6 +3138,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -2653,6 +3175,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -2672,6 +3198,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -2714,6 +3244,13 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -2743,6 +3280,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1" ] @@ -2772,6 +3313,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -2782,6 +3327,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -2827,6 +3376,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -2877,6 +3430,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -2925,6 +3482,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -2948,6 +3509,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -2969,6 +3534,15 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [ + "us-iso-east-1" + ], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-west-1" ] @@ -2987,6 +3561,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-west-1" ] @@ -3031,6 +3609,15 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -3060,6 +3647,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -3108,6 +3699,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -3130,6 +3736,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-west-1" ] @@ -3151,6 +3761,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -3170,6 +3784,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -3189,6 +3807,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -3201,6 +3823,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -3220,6 +3846,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-west-1" ] @@ -3267,6 +3897,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -3315,6 +3949,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -3330,6 +3968,14 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-south-1" + ], "aws-us-gov": [] } }, @@ -3344,6 +3990,16 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1" + ], + "aws-iso-b": [ + "us-isob-east-1" + ], + "aws-iso-e": [], + "aws-iso-f": [ + "us-isof-south-1" + ], "aws-us-gov": [] } }, @@ -3363,6 +4019,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -3394,6 +4054,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-west-1" ] @@ -3416,6 +4080,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -3430,6 +4098,12 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [ + "us-iso-east-1" + ], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -3478,6 +4152,15 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1" + ], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -3509,6 +4192,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -3536,6 +4223,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -3555,6 +4246,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -3585,6 +4280,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -3598,6 +4297,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -3623,6 +4326,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -3671,6 +4378,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -3690,6 +4412,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -3738,6 +4464,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -3789,6 +4530,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -3836,6 +4592,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -3884,6 +4655,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -3935,6 +4710,20 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -3967,6 +4756,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -4015,6 +4808,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -4066,6 +4874,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -4117,6 +4940,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -4168,6 +5006,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -4219,6 +5072,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -4233,6 +5101,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -4281,6 +5153,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -4332,6 +5219,20 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -4383,6 +5284,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -4429,6 +5345,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -4480,6 +5400,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -4527,6 +5462,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -4578,6 +5517,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -4629,6 +5583,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -4646,6 +5615,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -4694,6 +5667,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -4736,6 +5724,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -4787,6 +5779,12 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -4811,6 +5809,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -4859,6 +5861,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -4910,6 +5927,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -4961,6 +5982,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -4995,6 +6031,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -5043,6 +6083,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -5065,6 +6109,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -5084,6 +6132,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -5098,6 +6150,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -5146,6 +6202,20 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -5182,6 +6252,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -5231,6 +6305,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -5253,6 +6331,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -5272,6 +6354,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -5287,6 +6373,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -5317,6 +6407,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -5365,6 +6459,18 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1" + ], + "aws-iso-e": [], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -5416,6 +6522,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -5467,6 +6577,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -5518,6 +6632,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -5569,6 +6687,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -5607,6 +6729,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -5641,6 +6767,15 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1" + ], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -5687,6 +6822,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -5735,6 +6874,19 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1" + ], + "aws-iso-b": [ + "us-isob-east-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -5757,6 +6909,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -5785,6 +6941,10 @@ "cn-north-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -5809,6 +6969,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -5857,6 +7021,17 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1" + ], + "aws-iso-b": [ + "us-isob-east-1" + ], + "aws-iso-e": [], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -5876,6 +7051,14 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1" + ], + "aws-iso-b": [ + "us-isob-east-1" + ], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -5927,6 +7110,19 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -5978,6 +7174,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -6029,6 +7229,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -6080,6 +7284,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -6104,6 +7312,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -6152,6 +7364,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -6200,6 +7416,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -6239,6 +7459,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -6249,6 +7473,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -6284,6 +7512,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -6322,6 +7554,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -6358,6 +7594,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -6373,6 +7613,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -6386,6 +7630,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -6418,6 +7666,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -6456,6 +7708,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -6471,6 +7727,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -6506,6 +7766,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -6531,6 +7795,10 @@ "cn-north-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-west-1" ] @@ -6541,6 +7809,10 @@ "aws": [], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -6561,6 +7833,10 @@ "cn-north-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-west-1" ] @@ -6579,6 +7855,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -6595,6 +7875,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -6611,6 +7895,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -6627,6 +7915,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -6675,6 +7967,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -6724,6 +8020,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -6746,6 +8046,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-west-1" ] @@ -6766,6 +8070,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -6814,6 +8122,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -6865,6 +8188,14 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1" + ], + "aws-iso-b": [ + "us-isob-east-1" + ], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -6899,6 +8230,12 @@ "cn-north-1" ], "aws-eusc": [], + "aws-iso": [ + "us-iso-east-1" + ], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -6950,6 +8287,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -7001,6 +8353,19 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1" + ], + "aws-iso-b": [ + "us-isob-east-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -7052,6 +8417,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -7100,6 +8480,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -7142,6 +8526,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -7165,6 +8553,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-west-1" ] @@ -7187,6 +8579,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-west-1" ] @@ -7237,6 +8633,20 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -7285,6 +8695,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -7330,6 +8744,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -7361,6 +8779,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -7409,6 +8831,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -7424,6 +8861,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -7451,6 +8892,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -7481,6 +8926,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -7495,6 +8944,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -7526,6 +8979,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -7557,6 +9014,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -7572,6 +9033,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-west-1" ] @@ -7584,6 +9049,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -7615,6 +9084,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -7666,6 +9139,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -7681,6 +9158,12 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1" + ], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -7691,6 +9174,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -7705,6 +9192,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -7715,6 +9206,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -7750,6 +9245,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -7785,6 +9284,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -7817,6 +9320,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-west-1" ] @@ -7848,6 +9355,14 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [ + "us-iso-east-1" + ], + "aws-iso-b": [ + "us-isob-east-1" + ], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -7875,6 +9390,14 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [ + "us-iso-east-1" + ], + "aws-iso-b": [ + "us-isob-east-1" + ], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -7904,6 +9427,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -7934,6 +9461,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -7952,6 +9483,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -7970,6 +9505,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -7999,6 +9538,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -8013,6 +9556,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -8044,6 +9591,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -8088,6 +9639,12 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [ + "us-isob-east-1" + ], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -8107,6 +9664,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -8150,6 +9711,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -8179,6 +9744,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -8195,6 +9764,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -8211,6 +9784,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -8223,6 +9800,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -8271,6 +9852,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -8284,6 +9869,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -8327,6 +9916,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -8368,6 +9961,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -8411,6 +10008,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -8448,6 +10060,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -8496,6 +10112,14 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1" + ], + "aws-iso-b": [ + "us-isob-east-1" + ], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -8525,6 +10149,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -8568,6 +10196,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -8608,6 +10240,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -8651,6 +10287,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -8663,6 +10303,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -8673,6 +10317,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -8721,6 +10369,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -8765,6 +10428,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -8795,6 +10462,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -8814,6 +10485,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -8862,6 +10537,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -8896,6 +10586,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -8917,6 +10611,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -8935,6 +10633,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -8983,6 +10685,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -9012,6 +10729,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -9050,6 +10771,14 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [ + "us-iso-east-1" + ], + "aws-iso-b": [ + "us-isob-east-1" + ], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -9063,6 +10792,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -9073,6 +10806,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -9083,6 +10820,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -9108,6 +10849,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -9153,6 +10898,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -9197,6 +10946,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -9225,6 +10978,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -9248,6 +11005,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -9296,6 +11057,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -9320,6 +11096,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-west-1" ] @@ -9337,6 +11117,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -9352,6 +11136,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -9394,6 +11182,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -9435,6 +11227,15 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [] } }, @@ -9472,6 +11273,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-west-1" ] @@ -9490,6 +11295,18 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1" + ], + "aws-iso-b": [ + "us-isob-east-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-south-1" + ], "aws-us-gov": [] } }, @@ -9538,6 +11355,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -9561,6 +11382,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -9572,6 +11397,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -9585,6 +11414,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -9596,6 +11429,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -9633,6 +11470,13 @@ "cn-north-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -9684,6 +11528,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -9735,6 +11594,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -9786,6 +11660,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -9812,6 +11701,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -9822,6 +11715,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -9870,6 +11767,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -9921,6 +11833,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -9971,6 +11887,15 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -10001,6 +11926,13 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-west-1" ] @@ -10019,6 +11951,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -10048,6 +11984,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -10097,6 +12037,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -10148,6 +12092,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -10199,6 +12158,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -10250,6 +12224,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -10290,6 +12268,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -10341,6 +12323,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -10392,6 +12389,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -10431,6 +12432,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -10441,6 +12446,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -10451,6 +12460,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -10497,6 +12510,15 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -10548,6 +12570,20 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -10566,6 +12602,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -10609,6 +12649,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -10660,6 +12704,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -10706,6 +12765,15 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1" + ], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -10741,6 +12809,14 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [ + "us-iso-east-1" + ], + "aws-iso-b": [ + "us-isob-east-1" + ], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -10789,6 +12865,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -10840,6 +12920,17 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1" + ], + "aws-iso-b": [ + "us-isob-east-1" + ], + "aws-iso-e": [], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -10880,6 +12971,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -10890,6 +12985,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -10931,6 +13030,17 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1" + ], + "aws-iso-b": [ + "us-isob-east-1" + ], + "aws-iso-e": [], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [] } }, @@ -10979,6 +13089,17 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1" + ], + "aws-iso-b": [ + "us-isob-east-1" + ], + "aws-iso-e": [], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -10990,6 +13111,10 @@ "aws": [], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -11035,6 +13160,15 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -11086,6 +13220,20 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -11126,6 +13274,15 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -11146,6 +13303,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -11194,6 +13355,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -11231,6 +13407,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -11279,6 +13459,15 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1" + ], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -11308,6 +13497,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -11341,6 +13534,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -11392,6 +13589,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -11437,6 +13649,12 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -11478,6 +13696,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -11529,6 +13751,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -11570,6 +13807,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -11611,6 +13852,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -11655,6 +13900,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -11689,6 +13938,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -11740,6 +13993,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -11755,6 +14012,10 @@ "cn-north-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-west-1" ] @@ -11772,6 +14033,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -11798,6 +14063,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -11830,6 +14099,15 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1" + ], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -11857,6 +14135,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -11905,6 +14187,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -11951,6 +14248,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -11999,6 +14300,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -12050,6 +14366,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -12078,6 +14409,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -12103,6 +14438,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -12128,6 +14467,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -12165,6 +14508,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -12213,6 +14560,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -12264,6 +14615,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -12315,6 +14681,15 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1" + ], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -12366,6 +14741,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -12417,6 +14807,16 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -12432,6 +14832,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -12443,6 +14847,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -12491,6 +14899,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -12542,6 +14965,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -12557,6 +14995,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -12580,6 +15022,15 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [ + "us-iso-east-1" + ], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -12599,6 +15050,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -12616,6 +15071,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-west-1" ] @@ -12650,6 +15109,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -12667,6 +15130,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-west-1" ] @@ -12686,6 +15153,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-west-1" ] @@ -12707,6 +15178,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -12739,6 +15214,15 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [ + "us-iso-east-1" + ], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -12790,6 +15274,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -12841,6 +15329,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -12869,6 +15361,15 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [ + "us-iso-east-1" + ], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-west-1" ] @@ -12919,6 +15420,14 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -12932,6 +15441,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -12959,6 +15472,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -13008,6 +15525,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -13044,6 +15565,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -13064,6 +15589,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -13112,6 +15641,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -13154,6 +15687,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -13199,6 +15736,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -13250,6 +15791,14 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1" + ], + "aws-iso-b": [ + "us-isob-east-1" + ], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -13293,6 +15842,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -13344,6 +15897,14 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1" + ], + "aws-iso-b": [ + "us-isob-east-1" + ], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -13374,6 +15935,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -13404,6 +15969,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -13426,6 +15995,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-west-1" ] @@ -13447,6 +16020,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -13462,6 +16039,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -13474,6 +16055,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -13486,6 +16071,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -13514,6 +16103,15 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1" + ], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -13532,6 +16130,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -13551,6 +16153,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -13599,6 +16205,21 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [ + "us-iso-east-1", + "us-iso-west-1" + ], + "aws-iso-b": [ + "us-isob-east-1", + "us-isob-west-1" + ], + "aws-iso-e": [ + "eu-isoe-west-1" + ], + "aws-iso-f": [ + "us-isof-east-1", + "us-isof-south-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" diff --git a/prowler/providers/aws/config.py b/prowler/providers/aws/config.py index ea55d1a314..ed2ca503d0 100644 --- a/prowler/providers/aws/config.py +++ b/prowler/providers/aws/config.py @@ -2,14 +2,39 @@ import os from botocore.config import Config +from prowler.providers.aws.exceptions.exceptions import AWSInvalidBoto3TimeoutError + AWS_STS_GLOBAL_ENDPOINT_REGION = "us-east-1" AWS_REGION_US_EAST_1 = "us-east-1" BOTO3_USER_AGENT_EXTRA = os.getenv("PROWLER_AWS_BOTO3_USER_AGENT_EXTRA", "APN_1826889") +BOTO3_RETRIES_MAX_ATTEMPTS = 3 +# botocore defaults both to 60s +BOTO3_CONNECT_TIMEOUT = 10 +BOTO3_READ_TIMEOUT = 60 ROLE_SESSION_NAME = "ProwlerAssessmentSession" +def get_boto3_timeout_from_env(name: str, default: int) -> int: + """Positive integer seconds read from the environment, or default when unset.""" + raw = os.getenv(name, "").strip() + if not raw: + return default + if not raw.isdecimal() or int(raw) == 0: + raise AWSInvalidBoto3TimeoutError( + file=os.path.basename(__file__), + message=f"{name} must be a positive integer number of seconds, got {raw!r}", + ) + return int(raw) + + def get_default_session_config() -> Config: return Config( user_agent_extra=BOTO3_USER_AGENT_EXTRA, - retries={"max_attempts": 3, "mode": "standard"}, + retries={"max_attempts": BOTO3_RETRIES_MAX_ATTEMPTS, "mode": "standard"}, + connect_timeout=get_boto3_timeout_from_env( + "PROWLER_AWS_BOTO3_CONNECT_TIMEOUT", BOTO3_CONNECT_TIMEOUT + ), + read_timeout=get_boto3_timeout_from_env( + "PROWLER_AWS_BOTO3_READ_TIMEOUT", BOTO3_READ_TIMEOUT + ), ) diff --git a/prowler/providers/aws/exceptions/exceptions.py b/prowler/providers/aws/exceptions/exceptions.py index 4ea3d5e177..089e0d99c7 100644 --- a/prowler/providers/aws/exceptions/exceptions.py +++ b/prowler/providers/aws/exceptions/exceptions.py @@ -78,6 +78,10 @@ class AWSBaseException(ProwlerException): "message": "The provided AWS partition is invalid", "remediation": "Check the provided AWS partition and ensure it is valid.", }, + (1918, "AWSInvalidBoto3TimeoutError"): { + "message": "The Boto3 timeout configured through the environment is invalid", + "remediation": "Set PROWLER_AWS_BOTO3_CONNECT_TIMEOUT and PROWLER_AWS_BOTO3_READ_TIMEOUT to a positive integer number of seconds.", + }, } def __init__(self, code, file=None, original_exception=None, message=None): @@ -231,3 +235,12 @@ class AWSInvalidPartitionError(AWSBaseException): super().__init__( 1917, file=file, original_exception=original_exception, message=message ) + + +class AWSInvalidBoto3TimeoutError(AWSBaseException): + """Boto3 timeout configured through the environment is not a positive integer.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 1918, file=file, original_exception=original_exception, message=message + ) diff --git a/prowler/providers/aws/lib/arguments/arguments.py b/prowler/providers/aws/lib/arguments/arguments.py index 2d1632422b..84f3b4adfa 100644 --- a/prowler/providers/aws/lib/arguments/arguments.py +++ b/prowler/providers/aws/lib/arguments/arguments.py @@ -156,7 +156,21 @@ def init_parser(self): nargs="?", default=None, type=int, - help="Set the maximum attemps for the Boto3 standard retrier config (Default: 3)", + help="Set the maximum retries for the Boto3 standard retrier config, 0 disables retries (Default: 3)", + ) + boto3_config_subparser.add_argument( + "--aws-connect-timeout", + nargs="?", + default=None, + type=validate_timeout, + help="Seconds to wait to establish a connection (TCP, proxy tunnel and TLS) to an AWS endpoint before retrying (Default: 10)", + ) + boto3_config_subparser.add_argument( + "--aws-read-timeout", + nargs="?", + default=None, + type=validate_timeout, + help="Seconds to wait for a response from an AWS endpoint before retrying (Default: 60)", ) # Scan Unused Services @@ -190,6 +204,13 @@ def validate_session_duration(session_duration: int) -> int: return duration +def validate_timeout(value: str) -> int: + """validate_timeout validates that the input is a whole number of seconds greater than zero""" + if not value.isdecimal() or int(value) == 0: + raise ArgumentTypeError(f"{value} is not a positive integer") + return int(value) + + def validate_role_session_name(session_name) -> str: """ Validates that the role session name is valid. diff --git a/prowler/providers/aws/lib/session/aws_set_up_session.py b/prowler/providers/aws/lib/session/aws_set_up_session.py index 3189400040..8f0b4130ca 100644 --- a/prowler/providers/aws/lib/session/aws_set_up_session.py +++ b/prowler/providers/aws/lib/session/aws_set_up_session.py @@ -42,6 +42,8 @@ class AwsSetUpSession: aws_session_token: Optional[str] = None, retries_max_attempts: int = 3, regions: set = set(), + connect_timeout: Optional[int] = None, + read_timeout: Optional[int] = None, ) -> None: """ The constructor for the AwsSetUpSession class. @@ -58,6 +60,8 @@ class AwsSetUpSession: - aws_session_token: The AWS session token, optional. - retries_max_attempts: The maximum number of retries for the AWS client. - regions: A set of regions to audit. + - connect_timeout: Seconds to wait to establish a connection to an AWS endpoint. + - read_timeout: Seconds to wait for a response from an AWS endpoint. Returns: @@ -73,7 +77,9 @@ class AwsSetUpSession: aws_access_key_id=aws_access_key_id, aws_secret_access_key=aws_secret_access_key, ) - session_config = AwsProvider.set_session_config(retries_max_attempts) + session_config = AwsProvider.set_session_config( + retries_max_attempts, connect_timeout, read_timeout + ) aws_session = AwsProvider.setup_session( mfa=mfa, profile=profile, diff --git a/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege.py b/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege.py index e53183d90f..0d9b84bf14 100644 --- a/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege.py +++ b/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege.py @@ -24,10 +24,25 @@ class bedrock_agent_role_least_privilege(Check): Returns: A list of ``Check_Report_AWS`` with one entry per agent. The - status is ``FAIL`` when any of the criteria above is violated, - or when the execution role cannot be resolved in IAM. + status is ``FAIL`` when any of the criteria above is violated and + ``MANUAL`` when the execution role cannot be resolved in IAM. When + the IAM role inventory itself could not be listed, a single + account-level ``MANUAL`` report is returned instead. """ findings = [] + + if iam_client.roles is None and bedrock_agent_client.agents: + # iam:ListRoles was denied: this is an account-wide condition, so + # emit one account-level MANUAL instead of one per agent. + report = Check_Report_AWS(metadata=self.metadata(), resource={}) + report.region = iam_client.region + report.resource_id = iam_client.audited_account + report.resource_arn = iam_client.audited_account_arn + report.status = "MANUAL" + report.status_extended = "Cannot evaluate Bedrock Agent execution roles: the IAM roles could not be listed. Verify that the scanning credentials are allowed to call iam:ListRoles." + findings.append(report) + return findings + roles_by_arn = {role.arn: role for role in (iam_client.roles or [])} for agent in bedrock_agent_client.agents.values(): @@ -39,10 +54,11 @@ class bedrock_agent_role_least_privilege(Check): role = roles_by_arn.get(agent.role_arn) if agent.role_arn else None if role is None: - report.status = "FAIL" + report.status = "MANUAL" report.status_extended = ( f"Bedrock Agent {agent.name} execution role could not be " - f"resolved in IAM and cannot be evaluated for least privilege." + f"resolved in IAM and cannot be evaluated for least privilege; " + f"verify the role manually." ) findings.append(report) continue diff --git a/prowler/providers/aws/services/cloudtrail/cloudtrail_service.py b/prowler/providers/aws/services/cloudtrail/cloudtrail_service.py index 92caa6fb9b..d597e362d6 100644 --- a/prowler/providers/aws/services/cloudtrail/cloudtrail_service.py +++ b/prowler/providers/aws/services/cloudtrail/cloudtrail_service.py @@ -15,6 +15,9 @@ class Cloudtrail(AWSService): super().__init__(__class__.__name__, provider) self.trail_arn_template = f"arn:{self.audited_partition}:cloudtrail:{self.region}:{self.audited_account}:trail" self.trails = {} + # True when DescribeTrails was denied in at least one audited region, + # so the trail inventory may be incomplete. + self.trails_unavailable = False self.__threading_call__(self._get_trails) if self.trails: self._get_trail_status() @@ -79,13 +82,16 @@ class Cloudtrail(AWSService): logger.error( f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + self.trails_unavailable = True if not self.trails: self.trails = None else: + self.trails_unavailable = True logger.error( f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) except Exception as error: + self.trails_unavailable = True logger.error( f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.py index 68f45a8d27..911c50ecf7 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.py @@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_changes_to_network_acls_alarm_configured(Check): - def execute(self): + """CloudWatch log metric filter and alarm exist for Network ACL (NACL) change events. + + Looks for a CloudWatch Logs metric filter matching the expected pattern on a + log group used by a CloudTrail trail, with at least one alarm on its metric. + + - PASS: A matching metric filter with an associated alarm exists. + - FAIL: No matching metric filter, or a filter without an alarm, was found. + - MANUAL: CloudTrail trails, log groups, metric filters or alarms could + not be listed in at least one region, so the absence of a filter/alarm + cannot be asserted. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Evaluate the metric filter and alarm coverage for the account. + + Returns: + list[Check_Report_AWS]: A single report for the account. + """ pattern = build_metric_filter_pattern( event_names=[ "CreateNetworkAcl", @@ -34,16 +51,29 @@ class cloudwatch_changes_to_network_acls_alarm_configured(Check): self.metadata(), ) - if cloudtrail_client.trails is not None: - if report is None: - report = Check_Report_AWS(metadata=self.metadata(), resource={}) - report.status = "FAIL" - report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated." - report.region = logs_client.region - report.resource_id = logs_client.audited_account - report.resource_arn = logs_client.log_group_arn_template - report.resource_tags = [] + inventory_unavailable = ( + cloudtrail_client.trails_unavailable + or logs_client.log_groups_unavailable + or logs_client.metric_filters_unavailable + or cloudwatch_client.metric_alarms_unavailable + ) - findings.append(report) + if report is None: + report = Check_Report_AWS(metadata=self.metadata(), resource={}) + report.status = "FAIL" + report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated." + report.region = logs_client.region + report.resource_id = logs_client.audited_account + report.resource_arn = logs_client.log_group_arn_template + report.resource_tags = [] + + # A denied listing in any region means the inventory is incomplete: a + # PASS is still backed by a real filter and alarm, but a FAIL (nothing + # found, or a filter found without its alarm) cannot be trusted. + if report.status == "FAIL" and inventory_unavailable: + report.status = "MANUAL" + report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms." + + findings.append(report) return findings diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured.py index f7bf8e1d22..cce7b092da 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured.py @@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_changes_to_network_gateways_alarm_configured(Check): - def execute(self): + """CloudWatch Logs metric filter and alarm exist for changes to network gateways. + + Looks for a CloudWatch Logs metric filter matching the expected pattern on a + log group used by a CloudTrail trail, with at least one alarm on its metric. + + - PASS: A matching metric filter with an associated alarm exists. + - FAIL: No matching metric filter, or a filter without an alarm, was found. + - MANUAL: CloudTrail trails, log groups, metric filters or alarms could + not be listed in at least one region, so the absence of a filter/alarm + cannot be asserted. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Evaluate the metric filter and alarm coverage for the account. + + Returns: + list[Check_Report_AWS]: A single report for the account. + """ pattern = build_metric_filter_pattern( event_names=[ "CreateCustomerGateway", @@ -34,16 +51,29 @@ class cloudwatch_changes_to_network_gateways_alarm_configured(Check): self.metadata(), ) - if cloudtrail_client.trails is not None: - if report is None: - report = Check_Report_AWS(metadata=self.metadata(), resource={}) - report.status = "FAIL" - report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated." - report.region = logs_client.region - report.resource_id = logs_client.audited_account - report.resource_arn = logs_client.log_group_arn_template - report.resource_tags = [] + inventory_unavailable = ( + cloudtrail_client.trails_unavailable + or logs_client.log_groups_unavailable + or logs_client.metric_filters_unavailable + or cloudwatch_client.metric_alarms_unavailable + ) - findings.append(report) + if report is None: + report = Check_Report_AWS(metadata=self.metadata(), resource={}) + report.status = "FAIL" + report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated." + report.region = logs_client.region + report.resource_id = logs_client.audited_account + report.resource_arn = logs_client.log_group_arn_template + report.resource_tags = [] + + # A denied listing in any region means the inventory is incomplete: a + # PASS is still backed by a real filter and alarm, but a FAIL (nothing + # found, or a filter found without its alarm) cannot be trusted. + if report.status == "FAIL" and inventory_unavailable: + report.status = "MANUAL" + report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms." + + findings.append(report) return findings diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.py index 460765cb2f..f000c5b83e 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.py @@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_changes_to_network_route_tables_alarm_configured(Check): - def execute(self): + """Account monitors VPC route table changes with a CloudWatch Logs metric filter and alarm. + + Looks for a CloudWatch Logs metric filter matching the expected pattern on a + log group used by a CloudTrail trail, with at least one alarm on its metric. + + - PASS: A matching metric filter with an associated alarm exists. + - FAIL: No matching metric filter, or a filter without an alarm, was found. + - MANUAL: CloudTrail trails, log groups, metric filters or alarms could + not be listed in at least one region, so the absence of a filter/alarm + cannot be asserted. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Evaluate the metric filter and alarm coverage for the account. + + Returns: + list[Check_Report_AWS]: A single report for the account. + """ pattern = build_metric_filter_pattern( event_source="ec2.amazonaws.com", event_names=[ @@ -36,16 +53,29 @@ class cloudwatch_changes_to_network_route_tables_alarm_configured(Check): self.metadata(), ) - if cloudtrail_client.trails is not None: - if report is None: - report = Check_Report_AWS(metadata=self.metadata(), resource={}) - report.status = "FAIL" - report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated." - report.region = logs_client.region - report.resource_id = logs_client.audited_account - report.resource_arn = logs_client.log_group_arn_template - report.resource_tags = [] + inventory_unavailable = ( + cloudtrail_client.trails_unavailable + or logs_client.log_groups_unavailable + or logs_client.metric_filters_unavailable + or cloudwatch_client.metric_alarms_unavailable + ) - findings.append(report) + if report is None: + report = Check_Report_AWS(metadata=self.metadata(), resource={}) + report.status = "FAIL" + report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated." + report.region = logs_client.region + report.resource_id = logs_client.audited_account + report.resource_arn = logs_client.log_group_arn_template + report.resource_tags = [] + + # A denied listing in any region means the inventory is incomplete: a + # PASS is still backed by a real filter and alarm, but a FAIL (nothing + # found, or a filter found without its alarm) cannot be trusted. + if report.status == "FAIL" and inventory_unavailable: + report.status = "MANUAL" + report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms." + + findings.append(report) return findings diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured.py index be4fb0859d..35488dd8fa 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured.py @@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_changes_to_vpcs_alarm_configured(Check): - def execute(self): + """AWS account has a CloudWatch Logs metric filter and alarm for VPC changes. + + Looks for a CloudWatch Logs metric filter matching the expected pattern on a + log group used by a CloudTrail trail, with at least one alarm on its metric. + + - PASS: A matching metric filter with an associated alarm exists. + - FAIL: No matching metric filter, or a filter without an alarm, was found. + - MANUAL: CloudTrail trails, log groups, metric filters or alarms could + not be listed in at least one region, so the absence of a filter/alarm + cannot be asserted. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Evaluate the metric filter and alarm coverage for the account. + + Returns: + list[Check_Report_AWS]: A single report for the account. + """ pattern = build_metric_filter_pattern( event_names=[ "CreateVpc", @@ -39,16 +56,29 @@ class cloudwatch_changes_to_vpcs_alarm_configured(Check): self.metadata(), ) - if cloudtrail_client.trails is not None: - if report is None: - report = Check_Report_AWS(metadata=self.metadata(), resource={}) - report.status = "FAIL" - report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated." - report.region = logs_client.region - report.resource_id = logs_client.audited_account - report.resource_arn = logs_client.log_group_arn_template - report.resource_tags = [] + inventory_unavailable = ( + cloudtrail_client.trails_unavailable + or logs_client.log_groups_unavailable + or logs_client.metric_filters_unavailable + or cloudwatch_client.metric_alarms_unavailable + ) - findings.append(report) + if report is None: + report = Check_Report_AWS(metadata=self.metadata(), resource={}) + report.status = "FAIL" + report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated." + report.region = logs_client.region + report.resource_id = logs_client.audited_account + report.resource_arn = logs_client.log_group_arn_template + report.resource_tags = [] + + # A denied listing in any region means the inventory is incomplete: a + # PASS is still backed by a real filter and alarm, but a FAIL (nothing + # found, or a filter found without its alarm) cannot be trusted. + if report.status == "FAIL" and inventory_unavailable: + report.status = "MANUAL" + report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms." + + findings.append(report) return findings diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_group_agentcore_data_protection_policy_enabled/__init__.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_group_agentcore_data_protection_policy_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_group_agentcore_data_protection_policy_enabled/cloudwatch_log_group_agentcore_data_protection_policy_enabled.metadata.json b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_group_agentcore_data_protection_policy_enabled/cloudwatch_log_group_agentcore_data_protection_policy_enabled.metadata.json new file mode 100644 index 0000000000..063474adbf --- /dev/null +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_group_agentcore_data_protection_policy_enabled/cloudwatch_log_group_agentcore_data_protection_policy_enabled.metadata.json @@ -0,0 +1,45 @@ +{ + "Provider": "aws", + "CheckID": "cloudwatch_log_group_agentcore_data_protection_policy_enabled", + "CheckTitle": "Bedrock AgentCore log groups have a CloudWatch Logs data protection policy activated", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Effects/Data Exposure", + "Sensitive Data Identifications/PII" + ], + "ServiceName": "cloudwatch", + "SubServiceName": "logs", + "ResourceIdTemplate": "arn:partition:logs:region:account-id:log-group:log-group-name", + "Severity": "medium", + "ResourceType": "Other", + "ResourceGroup": "monitoring", + "Description": "Log groups holding **Bedrock AgentCore** agent telemetry have an active CloudWatch Logs **data protection policy**, so sensitive data an agent writes to its own logs is masked at ingestion. A policy attached to the log group or inherited from an account-level policy both satisfy this.", + "Risk": "Agents log prompts, tool arguments and retrieved context. Without masking, **PII** and secrets are stored in clear text and readable by every principal holding `logs:GetLogEvents`, widening the audience for regulated data far beyond the agent and defeating filtering applied only at the model boundary.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/mask-sensitive-log-data.html", + "https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/mask-sensitive-log-data-start.html", + "https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/mask-sensitive-log-data-start-account.html", + "https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/protect-sensitive-log-data-types.html", + "https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/observability-configure.html" + ], + "Remediation": { + "Code": { + "CLI": "aws logs put-data-protection-policy --log-group-identifier --policy-document '{\"Name\":\"agentcore-data-protection-policy\",\"Version\":\"2021-06-01\",\"Statement\":[{\"Sid\":\"audit\",\"DataIdentifier\":[\"arn:aws:dataprotection::aws:data-identifier/EmailAddress\"],\"Operation\":{\"Audit\":{\"FindingsDestination\":{}}}},{\"Sid\":\"redact\",\"DataIdentifier\":[\"arn:aws:dataprotection::aws:data-identifier/EmailAddress\"],\"Operation\":{\"Deidentify\":{\"MaskConfig\":{}}}}]}'", + "NativeIaC": "```yaml\n# CloudFormation: data protection policy on a log group you manage in the template.\n# AgentCore runtime log groups are created by the service, not by CloudFormation --\n# for those, use the CLI command or an AWS::Logs::AccountPolicy instead.\nResources:\n AgentCoreLogGroup:\n Type: AWS::Logs::LogGroup\n Properties:\n LogGroupName: \"\"\n DataProtectionPolicy:\n Name: agentcore-data-protection-policy\n Version: '2021-06-01'\n Statement:\n - Sid: audit # CRITICAL: required block, finds the sensitive terms\n DataIdentifier:\n - arn:aws:dataprotection::aws:data-identifier/EmailAddress\n Operation:\n Audit:\n FindingsDestination: {}\n - Sid: redact # CRITICAL: the Deidentify block is what actually masks the data\n DataIdentifier:\n - arn:aws:dataprotection::aws:data-identifier/EmailAddress\n Operation:\n Deidentify:\n MaskConfig: {}\n```", + "Other": "1. In the AWS console, open CloudWatch > Logs > Log groups\n2. Select the AgentCore log group (/aws/bedrock-agentcore/... or /aws/vendedlogs/bedrock-agentcore/...)\n3. Choose Actions > Create data protection policy\n4. Under Managed data identifiers, select the data types your agent can log, for example Address, EmailAddress and PhoneNumber\n5. (Optional) Choose a destination for the audit findings report\n6. Choose Activate data protection\n7. To cover every log group at once, including service-created ones, open Settings > Data protection instead and create an account-level policy", + "Terraform": "```hcl\n# Attaches a data protection policy to an existing AgentCore log group\nresource \"aws_cloudwatch_log_data_protection_policy\" \"\" {\n log_group_name = \"\"\n\n policy_document = jsonencode({\n Name = \"agentcore-data-protection-policy\"\n Version = \"2021-06-01\"\n Statement = [\n {\n Sid = \"audit\" # CRITICAL: required block, finds the sensitive terms\n DataIdentifier = [\"arn:aws:dataprotection::aws:data-identifier/EmailAddress\"]\n Operation = {\n Audit = {\n FindingsDestination = {}\n }\n }\n },\n {\n Sid = \"redact\" # CRITICAL: the Deidentify block is what actually masks the data\n DataIdentifier = [\"arn:aws:dataprotection::aws:data-identifier/EmailAddress\"]\n Operation = {\n Deidentify = {\n MaskConfig = {}\n }\n }\n }\n ]\n })\n}\n```" + }, + "Recommendation": { + "Text": "Attach a data protection policy to every AgentCore log group, choosing the managed data identifiers your agents can plausibly log, or create one account-level policy so service-created log groups are covered as they appear.\n\nMasking is *defense in depth* behind the model-boundary filters: keep restricting `logs:Unmask` so only incident responders can read the original values, and keep the log groups encrypted with a **customer-managed key**.", + "Url": "https://hub.prowler.com/check/cloudwatch_log_group_agentcore_data_protection_policy_enabled" + } + }, + "Categories": [ + "gen-ai", + "logging" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Scoped to log groups whose name starts with an AgentCore prefix (configurable via `agentcore_log_group_name_prefixes`, whose value REPLACES the defaults rather than adding to them). AgentCore log delivery can be pointed at an arbitrarily named log group; add those prefixes to the configuration so they are assessed. Not covered by default: gateway, built-in tool and identity spans, and runtime spans for agents created before a Region supported the per-agent span destination, are delivered to the shared `aws/spans` log group, which is account-wide rather than AgentCore-specific. Add `aws/spans` to the configuration to assess it." +} diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_group_agentcore_data_protection_policy_enabled/cloudwatch_log_group_agentcore_data_protection_policy_enabled.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_group_agentcore_data_protection_policy_enabled/cloudwatch_log_group_agentcore_data_protection_policy_enabled.py new file mode 100644 index 0000000000..339f8dad4f --- /dev/null +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_group_agentcore_data_protection_policy_enabled/cloudwatch_log_group_agentcore_data_protection_policy_enabled.py @@ -0,0 +1,98 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.cloudwatch.logs_client import logs_client + +# Log group name prefixes AgentCore uses for agent telemetry. The runtime creates +# /aws/bedrock-agentcore/runtimes/... itself. Log delivery for memory, gateway and built-in tool +# resources is CONFIGURED by the operator rather than defaulted -- the AgentCore devguide's +# observability-configure page is a put_delivery_source / put_delivery_destination / +# create_delivery procedure -- and it targets /aws/vendedlogs/bedrock-agentcore/... because for +# same-account delivery to a /aws/vendedlogs/ log group the log-delivery service-linked role +# grants write access implicitly, while any other destination needs an explicit resource policy +# or the delivery silently fails. So the prefix is the convention that makes delivery work, which +# is why these two and not others. +DEFAULT_AGENTCORE_LOG_GROUP_PREFIXES = [ + "/aws/bedrock-agentcore/", + "/aws/vendedlogs/bedrock-agentcore/", +] + +ACTIVATED = "ACTIVATED" +ACCOUNT_DATA_PROTECTION = "ACCOUNT_DATA_PROTECTION" + + +class cloudwatch_log_group_agentcore_data_protection_policy_enabled(Check): + """Ensure AgentCore log groups mask sensitive data with a data protection policy. + + Agents write prompts, tool arguments and retrieved context to their own log groups. A data + protection policy masks matched data at ingestion, so without one the values are stored in + clear text and readable by every principal holding logs:GetLogEvents. + + Scope: log groups whose name starts with an AgentCore prefix. AgentCore log delivery can be + pointed at an arbitrarily named log group, so the prefix list is configurable through + agentcore_log_group_name_prefixes; a configured list REPLACES the defaults rather than + extending them, and an explicitly null value falls back to the defaults. + + PASS when the log group has an ACTIVATED policy of its own, or inherits the account-level one. + FAIL when dataProtectionStatus is DELETED, ARCHIVED or DISABLED, or was never reported: all + four mean nothing is being masked at ingestion today. + MANUAL when the log group inventory could not be read, because nothing is then known about any + log group's masking. Only a denied DescribeLogGroups leaves the inventory unknown -- every + other collector failure leaves a readable, possibly partial, inventory. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the AgentCore log group data protection policy check. + + Returns: + A list of reports containing the result of the check: one per in-scope + AgentCore log group, or a single account-level report when the log group + inventory could not be read. + """ + findings = [] + + # An ABSENT or explicitly null value falls back to the defaults; an empty LIST does not. + # `is None` covers both a missing key and a bare `agentcore_log_group_name_prefixes:` in the + # YAML, which parses as None, and both mean "not configured". `or` additionally swallowed an + # explicitly empty list, which IS a configured value and the one way an operator can say "no + # log group is in scope" -- so the fallback overrode the operator and contradicted the + # REPLACES-the-defaults behaviour promised above. An empty tuple is meaningful downstream + # rather than degenerate: str.startswith(()) is False for every name, so nothing is selected, + # which is exactly the request. + configured_prefixes = logs_client.audit_config.get( + "agentcore_log_group_name_prefixes" + ) + prefixes = tuple( + DEFAULT_AGENTCORE_LOG_GROUP_PREFIXES + if configured_prefixes is None + else configured_prefixes + ) + + # An unreadable log group inventory must not read as compliant: without the + # inventory there is no way to tell an AgentCore log group that masks + # sensitive data from one that does not. + if logs_client.log_groups is None: + report = Check_Report_AWS(metadata=self.metadata(), resource={}) + report.status = "MANUAL" + report.status_extended = "Log groups could not be retrieved, so data protection policies for AgentCore log groups could not be verified." + report.region = logs_client.region + report.resource_id = logs_client.audited_account + report.resource_arn = logs_client.log_group_arn_template + report.resource_tags = [] + return [report] + + for log_group in logs_client.log_groups.values(): + if not log_group.name.startswith(prefixes): + continue + + report = Check_Report_AWS(metadata=self.metadata(), resource=log_group) + if log_group.data_protection_status == ACTIVATED: + report.status = "PASS" + report.status_extended = f"AgentCore log group {log_group.name} has a data protection policy activated." + elif ACCOUNT_DATA_PROTECTION in log_group.inherited_properties: + report.status = "PASS" + report.status_extended = f"AgentCore log group {log_group.name} inherits the account-level data protection policy." + else: + report.status = "FAIL" + report.status_extended = f"AgentCore log group {log_group.name} does not have an active data protection policy, so sensitive data written by the agent is not masked." + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.py index 49bf9a03a3..17da91376f 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.py @@ -15,7 +15,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled( Check ): - def execute(self): + """CloudWatch Logs metric filter and alarm exist for AWS Config configuration changes. + + Looks for a CloudWatch Logs metric filter matching the expected pattern on a + log group used by a CloudTrail trail, with at least one alarm on its metric. + + - PASS: A matching metric filter with an associated alarm exists. + - FAIL: No matching metric filter, or a filter without an alarm, was found. + - MANUAL: CloudTrail trails, log groups, metric filters or alarms could + not be listed in at least one region, so the absence of a filter/alarm + cannot be asserted. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Evaluate the metric filter and alarm coverage for the account. + + Returns: + list[Check_Report_AWS]: A single report for the account. + """ pattern = build_metric_filter_pattern( event_source="config.amazonaws.com", event_names=[ @@ -35,16 +52,29 @@ class cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_change self.metadata(), ) - if cloudtrail_client.trails is not None: - if report is None: - report = Check_Report_AWS(metadata=self.metadata(), resource={}) - report.status = "FAIL" - report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated." - report.region = logs_client.region - report.resource_id = logs_client.audited_account - report.resource_arn = logs_client.log_group_arn_template - report.resource_tags = [] + inventory_unavailable = ( + cloudtrail_client.trails_unavailable + or logs_client.log_groups_unavailable + or logs_client.metric_filters_unavailable + or cloudwatch_client.metric_alarms_unavailable + ) - findings.append(report) + if report is None: + report = Check_Report_AWS(metadata=self.metadata(), resource={}) + report.status = "FAIL" + report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated." + report.region = logs_client.region + report.resource_id = logs_client.audited_account + report.resource_arn = logs_client.log_group_arn_template + report.resource_tags = [] + + # A denied listing in any region means the inventory is incomplete: a + # PASS is still backed by a real filter and alarm, but a FAIL (nothing + # found, or a filter found without its alarm) cannot be trusted. + if report.status == "FAIL" and inventory_unavailable: + report.status = "MANUAL" + report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms." + + findings.append(report) return findings diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.py index e9567315f4..1c9efa94e2 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.py @@ -15,7 +15,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled( Check ): - def execute(self): + """CloudWatch Logs metric filter and alarm exist for CloudTrail configuration changes. + + Looks for a CloudWatch Logs metric filter matching the expected pattern on a + log group used by a CloudTrail trail, with at least one alarm on its metric. + + - PASS: A matching metric filter with an associated alarm exists. + - FAIL: No matching metric filter, or a filter without an alarm, was found. + - MANUAL: CloudTrail trails, log groups, metric filters or alarms could + not be listed in at least one region, so the absence of a filter/alarm + cannot be asserted. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Evaluate the metric filter and alarm coverage for the account. + + Returns: + list[Check_Report_AWS]: A single report for the account. + """ pattern = build_metric_filter_pattern( event_names=[ "CreateTrail", @@ -35,16 +52,29 @@ class cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_change self.metadata(), ) - if cloudtrail_client.trails is not None: - if report is None: - report = Check_Report_AWS(metadata=self.metadata(), resource={}) - report.status = "FAIL" - report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated." - report.region = logs_client.region - report.resource_id = logs_client.audited_account - report.resource_arn = logs_client.log_group_arn_template - report.resource_tags = [] + inventory_unavailable = ( + cloudtrail_client.trails_unavailable + or logs_client.log_groups_unavailable + or logs_client.metric_filters_unavailable + or cloudwatch_client.metric_alarms_unavailable + ) - findings.append(report) + if report is None: + report = Check_Report_AWS(metadata=self.metadata(), resource={}) + report.status = "FAIL" + report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated." + report.region = logs_client.region + report.resource_id = logs_client.audited_account + report.resource_arn = logs_client.log_group_arn_template + report.resource_tags = [] + + # A denied listing in any region means the inventory is incomplete: a + # PASS is still backed by a real filter and alarm, but a FAIL (nothing + # found, or a filter found without its alarm) cannot be trusted. + if report.status == "FAIL" and inventory_unavailable: + report.status = "MANUAL" + report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms." + + findings.append(report) return findings diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures.py index 1b2e5173bb..c99d164bce 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures.py @@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_authentication_failures(Check): - def execute(self): + """Account has a CloudWatch Logs metric filter and alarm for AWS Management Console authentication failures. + + Looks for a CloudWatch Logs metric filter matching the expected pattern on a + log group used by a CloudTrail trail, with at least one alarm on its metric. + + - PASS: A matching metric filter with an associated alarm exists. + - FAIL: No matching metric filter, or a filter without an alarm, was found. + - MANUAL: CloudTrail trails, log groups, metric filters or alarms could + not be listed in at least one region, so the absence of a filter/alarm + cannot be asserted. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Evaluate the metric filter and alarm coverage for the account. + + Returns: + list[Check_Report_AWS]: A single report for the account. + """ pattern = build_metric_filter_pattern( event_names=["ConsoleLogin"], extra_clauses=[("errorMessage", "=", "Failed authentication")], @@ -28,16 +45,29 @@ class cloudwatch_log_metric_filter_authentication_failures(Check): self.metadata(), ) - if cloudtrail_client.trails is not None: - if report is None: - report = Check_Report_AWS(metadata=self.metadata(), resource={}) - report.status = "FAIL" - report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated." - report.region = logs_client.region - report.resource_id = logs_client.audited_account - report.resource_arn = logs_client.log_group_arn_template - report.resource_tags = [] + inventory_unavailable = ( + cloudtrail_client.trails_unavailable + or logs_client.log_groups_unavailable + or logs_client.metric_filters_unavailable + or cloudwatch_client.metric_alarms_unavailable + ) - findings.append(report) + if report is None: + report = Check_Report_AWS(metadata=self.metadata(), resource={}) + report.status = "FAIL" + report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated." + report.region = logs_client.region + report.resource_id = logs_client.audited_account + report.resource_arn = logs_client.log_group_arn_template + report.resource_tags = [] + + # A denied listing in any region means the inventory is incomplete: a + # PASS is still backed by a real filter and alarm, but a FAIL (nothing + # found, or a filter found without its alarm) cannot be trusted. + if report.status == "FAIL" and inventory_unavailable: + report.status = "MANUAL" + report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms." + + findings.append(report) return findings diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes.py index 9976a885bb..f876dbeda2 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes.py @@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_aws_organizations_changes(Check): - def execute(self): + """CloudWatch Logs metric filter and alarm exist for AWS Organizations changes. + + Looks for a CloudWatch Logs metric filter matching the expected pattern on a + log group used by a CloudTrail trail, with at least one alarm on its metric. + + - PASS: A matching metric filter with an associated alarm exists. + - FAIL: No matching metric filter, or a filter without an alarm, was found. + - MANUAL: CloudTrail trails, log groups, metric filters or alarms could + not be listed in at least one region, so the absence of a filter/alarm + cannot be asserted. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Evaluate the metric filter and alarm coverage for the account. + + Returns: + list[Check_Report_AWS]: A single report for the account. + """ pattern = build_metric_filter_pattern( event_source="organizations.amazonaws.com", event_names=[ @@ -50,16 +67,29 @@ class cloudwatch_log_metric_filter_aws_organizations_changes(Check): self.metadata(), ) - if cloudtrail_client.trails is not None: - if report is None: - report = Check_Report_AWS(metadata=self.metadata(), resource={}) - report.status = "FAIL" - report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated." - report.region = logs_client.region - report.resource_id = logs_client.audited_account - report.resource_arn = logs_client.log_group_arn_template - report.resource_tags = [] + inventory_unavailable = ( + cloudtrail_client.trails_unavailable + or logs_client.log_groups_unavailable + or logs_client.metric_filters_unavailable + or cloudwatch_client.metric_alarms_unavailable + ) - findings.append(report) + if report is None: + report = Check_Report_AWS(metadata=self.metadata(), resource={}) + report.status = "FAIL" + report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated." + report.region = logs_client.region + report.resource_id = logs_client.audited_account + report.resource_arn = logs_client.log_group_arn_template + report.resource_tags = [] + + # A denied listing in any region means the inventory is incomplete: a + # PASS is still backed by a real filter and alarm, but a FAIL (nothing + # found, or a filter found without its alarm) cannot be trusted. + if report.status == "FAIL" and inventory_unavailable: + report.status = "MANUAL" + report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms." + + findings.append(report) return findings diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.py index 20d1d62a5a..faa759608b 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.py @@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk(Check): - def execute(self): + """Account has a CloudWatch log metric filter and alarm for disabling or scheduled deletion of customer-managed KMS keys. + + Looks for a CloudWatch Logs metric filter matching the expected pattern on a + log group used by a CloudTrail trail, with at least one alarm on its metric. + + - PASS: A matching metric filter with an associated alarm exists. + - FAIL: No matching metric filter, or a filter without an alarm, was found. + - MANUAL: CloudTrail trails, log groups, metric filters or alarms could + not be listed in at least one region, so the absence of a filter/alarm + cannot be asserted. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Evaluate the metric filter and alarm coverage for the account. + + Returns: + list[Check_Report_AWS]: A single report for the account. + """ pattern = build_metric_filter_pattern( event_source="kms.amazonaws.com", event_names=["DisableKey", "ScheduleKeyDeletion"], @@ -28,16 +45,29 @@ class cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk(Chec self.metadata(), ) - if cloudtrail_client.trails is not None: - if report is None: - report = Check_Report_AWS(metadata=self.metadata(), resource={}) - report.status = "FAIL" - report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated." - report.region = logs_client.region - report.resource_id = logs_client.audited_account - report.resource_arn = logs_client.log_group_arn_template - report.resource_tags = [] + inventory_unavailable = ( + cloudtrail_client.trails_unavailable + or logs_client.log_groups_unavailable + or logs_client.metric_filters_unavailable + or cloudwatch_client.metric_alarms_unavailable + ) - findings.append(report) + if report is None: + report = Check_Report_AWS(metadata=self.metadata(), resource={}) + report.status = "FAIL" + report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated." + report.region = logs_client.region + report.resource_id = logs_client.audited_account + report.resource_arn = logs_client.log_group_arn_template + report.resource_tags = [] + + # A denied listing in any region means the inventory is incomplete: a + # PASS is still backed by a real filter and alarm, but a FAIL (nothing + # found, or a filter found without its alarm) cannot be trusted. + if report.status == "FAIL" and inventory_unavailable: + report.status = "MANUAL" + report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms." + + findings.append(report) return findings diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.py index d5fe1ef994..95579f5a37 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.py @@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_for_s3_bucket_policy_changes(Check): - def execute(self): + """CloudWatch log metric filter and alarm exist for S3 bucket policy changes. + + Looks for a CloudWatch Logs metric filter matching the expected pattern on a + log group used by a CloudTrail trail, with at least one alarm on its metric. + + - PASS: A matching metric filter with an associated alarm exists. + - FAIL: No matching metric filter, or a filter without an alarm, was found. + - MANUAL: CloudTrail trails, log groups, metric filters or alarms could + not be listed in at least one region, so the absence of a filter/alarm + cannot be asserted. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Evaluate the metric filter and alarm coverage for the account. + + Returns: + list[Check_Report_AWS]: A single report for the account. + """ pattern = build_metric_filter_pattern( event_source="s3.amazonaws.com", event_names=[ @@ -38,16 +55,29 @@ class cloudwatch_log_metric_filter_for_s3_bucket_policy_changes(Check): self.metadata(), ) - if cloudtrail_client.trails is not None: - if report is None: - report = Check_Report_AWS(metadata=self.metadata(), resource={}) - report.status = "FAIL" - report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated." - report.region = logs_client.region - report.resource_id = logs_client.audited_account - report.resource_arn = logs_client.log_group_arn_template - report.resource_tags = [] + inventory_unavailable = ( + cloudtrail_client.trails_unavailable + or logs_client.log_groups_unavailable + or logs_client.metric_filters_unavailable + or cloudwatch_client.metric_alarms_unavailable + ) - findings.append(report) + if report is None: + report = Check_Report_AWS(metadata=self.metadata(), resource={}) + report.status = "FAIL" + report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated." + report.region = logs_client.region + report.resource_id = logs_client.audited_account + report.resource_arn = logs_client.log_group_arn_template + report.resource_tags = [] + + # A denied listing in any region means the inventory is incomplete: a + # PASS is still backed by a real filter and alarm, but a FAIL (nothing + # found, or a filter found without its alarm) cannot be trusted. + if report.status == "FAIL" and inventory_unavailable: + report.status = "MANUAL" + report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms." + + findings.append(report) return findings diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.py index 4347a9b3aa..af43cfbb0f 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.py @@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_policy_changes(Check): - def execute(self): + """CloudWatch Logs metric filter and alarm exist for IAM policy changes. + + Looks for a CloudWatch Logs metric filter matching the expected pattern on a + log group used by a CloudTrail trail, with at least one alarm on its metric. + + - PASS: A matching metric filter with an associated alarm exists. + - FAIL: No matching metric filter, or a filter without an alarm, was found. + - MANUAL: CloudTrail trails, log groups, metric filters or alarms could + not be listed in at least one region, so the absence of a filter/alarm + cannot be asserted. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Evaluate the metric filter and alarm coverage for the account. + + Returns: + list[Check_Report_AWS]: A single report for the account. + """ pattern = build_metric_filter_pattern( event_names=[ "DeleteGroupPolicy", @@ -44,16 +61,29 @@ class cloudwatch_log_metric_filter_policy_changes(Check): self.metadata(), ) - if cloudtrail_client.trails is not None: - if report is None: - report = Check_Report_AWS(metadata=self.metadata(), resource={}) - report.status = "FAIL" - report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated." - report.region = logs_client.region - report.resource_id = logs_client.audited_account - report.resource_arn = logs_client.log_group_arn_template - report.resource_tags = [] + inventory_unavailable = ( + cloudtrail_client.trails_unavailable + or logs_client.log_groups_unavailable + or logs_client.metric_filters_unavailable + or cloudwatch_client.metric_alarms_unavailable + ) - findings.append(report) + if report is None: + report = Check_Report_AWS(metadata=self.metadata(), resource={}) + report.status = "FAIL" + report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated." + report.region = logs_client.region + report.resource_id = logs_client.audited_account + report.resource_arn = logs_client.log_group_arn_template + report.resource_tags = [] + + # A denied listing in any region means the inventory is incomplete: a + # PASS is still backed by a real filter and alarm, but a FAIL (nothing + # found, or a filter found without its alarm) cannot be trusted. + if report.status == "FAIL" and inventory_unavailable: + report.status = "MANUAL" + report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms." + + findings.append(report) return findings diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_root_usage/cloudwatch_log_metric_filter_root_usage.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_root_usage/cloudwatch_log_metric_filter_root_usage.py index acdb49a9dc..149e3738ff 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_root_usage/cloudwatch_log_metric_filter_root_usage.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_root_usage/cloudwatch_log_metric_filter_root_usage.py @@ -12,7 +12,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_root_usage(Check): - def execute(self): + """Account has a CloudWatch Logs metric filter and alarm for root account usage. + + Looks for a CloudWatch Logs metric filter matching the expected pattern on a + log group used by a CloudTrail trail, with at least one alarm on its metric. + + - PASS: A matching metric filter with an associated alarm exists. + - FAIL: No matching metric filter, or a filter without an alarm, was found. + - MANUAL: CloudTrail trails, log groups, metric filters or alarms could + not be listed in at least one region, so the absence of a filter/alarm + cannot be asserted. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Evaluate the metric filter and alarm coverage for the account. + + Returns: + list[Check_Report_AWS]: A single report for the account. + """ pattern = r"\$\.userIdentity\.type\s*=\s*.?Root.+\$\.userIdentity\.invokedBy NOT EXISTS.+\$\.eventType\s*!=\s*.?AwsServiceEvent.?" findings = [] @@ -24,16 +41,29 @@ class cloudwatch_log_metric_filter_root_usage(Check): self.metadata(), ) - if cloudtrail_client.trails is not None: - if report is None: - report = Check_Report_AWS(metadata=self.metadata(), resource={}) - report.status = "FAIL" - report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated." - report.region = logs_client.region - report.resource_id = logs_client.audited_account - report.resource_arn = logs_client.log_group_arn_template - report.resource_tags = [] + inventory_unavailable = ( + cloudtrail_client.trails_unavailable + or logs_client.log_groups_unavailable + or logs_client.metric_filters_unavailable + or cloudwatch_client.metric_alarms_unavailable + ) - findings.append(report) + if report is None: + report = Check_Report_AWS(metadata=self.metadata(), resource={}) + report.status = "FAIL" + report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated." + report.region = logs_client.region + report.resource_id = logs_client.audited_account + report.resource_arn = logs_client.log_group_arn_template + report.resource_tags = [] + + # A denied listing in any region means the inventory is incomplete: a + # PASS is still backed by a real filter and alarm, but a FAIL (nothing + # found, or a filter found without its alarm) cannot be trusted. + if report.status == "FAIL" and inventory_unavailable: + report.status = "MANUAL" + report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms." + + findings.append(report) return findings diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes.py index 3557632904..a60476fa0c 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes.py @@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_security_group_changes(Check): - def execute(self): + """CloudWatch Logs metric filter and alarm exist for security group changes. + + Looks for a CloudWatch Logs metric filter matching the expected pattern on a + log group used by a CloudTrail trail, with at least one alarm on its metric. + + - PASS: A matching metric filter with an associated alarm exists. + - FAIL: No matching metric filter, or a filter without an alarm, was found. + - MANUAL: CloudTrail trails, log groups, metric filters or alarms could + not be listed in at least one region, so the absence of a filter/alarm + cannot be asserted. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Evaluate the metric filter and alarm coverage for the account. + + Returns: + list[Check_Report_AWS]: A single report for the account. + """ pattern = build_metric_filter_pattern( event_names=[ "AuthorizeSecurityGroupIngress", @@ -34,16 +51,29 @@ class cloudwatch_log_metric_filter_security_group_changes(Check): self.metadata(), ) - if cloudtrail_client.trails is not None: - if report is None: - report = Check_Report_AWS(metadata=self.metadata(), resource={}) - report.status = "FAIL" - report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated." - report.region = logs_client.region - report.resource_id = logs_client.audited_account - report.resource_arn = logs_client.log_group_arn_template - report.resource_tags = [] + inventory_unavailable = ( + cloudtrail_client.trails_unavailable + or logs_client.log_groups_unavailable + or logs_client.metric_filters_unavailable + or cloudwatch_client.metric_alarms_unavailable + ) - findings.append(report) + if report is None: + report = Check_Report_AWS(metadata=self.metadata(), resource={}) + report.status = "FAIL" + report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated." + report.region = logs_client.region + report.resource_id = logs_client.audited_account + report.resource_arn = logs_client.log_group_arn_template + report.resource_tags = [] + + # A denied listing in any region means the inventory is incomplete: a + # PASS is still backed by a real filter and alarm, but a FAIL (nothing + # found, or a filter found without its alarm) cannot be trusted. + if report.status == "FAIL" and inventory_unavailable: + report.status = "MANUAL" + report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms." + + findings.append(report) return findings diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.py index 07475a6185..f870a93109 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.py @@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_sign_in_without_mfa(Check): - def execute(self): + """CloudWatch log metric filter and alarm exist for Management Console sign-in without MFA. + + Looks for a CloudWatch Logs metric filter matching the expected pattern on a + log group used by a CloudTrail trail, with at least one alarm on its metric. + + - PASS: A matching metric filter with an associated alarm exists. + - FAIL: No matching metric filter, or a filter without an alarm, was found. + - MANUAL: CloudTrail trails, log groups, metric filters or alarms could + not be listed in at least one region, so the absence of a filter/alarm + cannot be asserted. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Evaluate the metric filter and alarm coverage for the account. + + Returns: + list[Check_Report_AWS]: A single report for the account. + """ pattern = build_metric_filter_pattern( event_names=["ConsoleLogin"], extra_clauses=[("additionalEventData.MFAUsed", "!=", "Yes")], @@ -28,16 +45,29 @@ class cloudwatch_log_metric_filter_sign_in_without_mfa(Check): self.metadata(), ) - if cloudtrail_client.trails is not None: - if report is None: - report = Check_Report_AWS(metadata=self.metadata(), resource={}) - report.status = "FAIL" - report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated." - report.region = logs_client.region - report.resource_id = logs_client.audited_account - report.resource_arn = logs_client.log_group_arn_template - report.resource_tags = [] + inventory_unavailable = ( + cloudtrail_client.trails_unavailable + or logs_client.log_groups_unavailable + or logs_client.metric_filters_unavailable + or cloudwatch_client.metric_alarms_unavailable + ) - findings.append(report) + if report is None: + report = Check_Report_AWS(metadata=self.metadata(), resource={}) + report.status = "FAIL" + report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated." + report.region = logs_client.region + report.resource_id = logs_client.audited_account + report.resource_arn = logs_client.log_group_arn_template + report.resource_tags = [] + + # A denied listing in any region means the inventory is incomplete: a + # PASS is still backed by a real filter and alarm, but a FAIL (nothing + # found, or a filter found without its alarm) cannot be trusted. + if report.status == "FAIL" and inventory_unavailable: + report.status = "MANUAL" + report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms." + + findings.append(report) return findings diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_unauthorized_api_calls/cloudwatch_log_metric_filter_unauthorized_api_calls.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_unauthorized_api_calls/cloudwatch_log_metric_filter_unauthorized_api_calls.py index a328560ee3..045c5f4b96 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_unauthorized_api_calls/cloudwatch_log_metric_filter_unauthorized_api_calls.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_unauthorized_api_calls/cloudwatch_log_metric_filter_unauthorized_api_calls.py @@ -12,7 +12,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_unauthorized_api_calls(Check): - def execute(self): + """CloudWatch Logs metric filter and alarm exist for unauthorized API calls. + + Looks for a CloudWatch Logs metric filter matching the expected pattern on a + log group used by a CloudTrail trail, with at least one alarm on its metric. + + - PASS: A matching metric filter with an associated alarm exists. + - FAIL: No matching metric filter, or a filter without an alarm, was found. + - MANUAL: CloudTrail trails, log groups, metric filters or alarms could + not be listed in at least one region, so the absence of a filter/alarm + cannot be asserted. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Evaluate the metric filter and alarm coverage for the account. + + Returns: + list[Check_Report_AWS]: A single report for the account. + """ pattern = r"\$\.errorCode\s*=\s*.?\*UnauthorizedOperation.+\$\.errorCode\s*=\s*.?AccessDenied\*.?" findings = [] @@ -24,16 +41,29 @@ class cloudwatch_log_metric_filter_unauthorized_api_calls(Check): self.metadata(), ) - if cloudtrail_client.trails is not None: - if report is None: - report = Check_Report_AWS(metadata=self.metadata(), resource={}) - report.status = "FAIL" - report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated." - report.region = logs_client.region - report.resource_id = logs_client.audited_account - report.resource_arn = logs_client.log_group_arn_template - report.resource_tags = [] + inventory_unavailable = ( + cloudtrail_client.trails_unavailable + or logs_client.log_groups_unavailable + or logs_client.metric_filters_unavailable + or cloudwatch_client.metric_alarms_unavailable + ) - findings.append(report) + if report is None: + report = Check_Report_AWS(metadata=self.metadata(), resource={}) + report.status = "FAIL" + report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated." + report.region = logs_client.region + report.resource_id = logs_client.audited_account + report.resource_arn = logs_client.log_group_arn_template + report.resource_tags = [] + + # A denied listing in any region means the inventory is incomplete: a + # PASS is still backed by a real filter and alarm, but a FAIL (nothing + # found, or a filter found without its alarm) cannot be trusted. + if report.status == "FAIL" and inventory_unavailable: + report.status = "MANUAL" + report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms." + + findings.append(report) return findings diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_service.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_service.py index 56b7ebe25c..017deb3331 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_service.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_service.py @@ -19,6 +19,9 @@ class CloudWatch(AWSService): # Call AWSService's __init__ super().__init__(__class__.__name__, provider) self.metric_alarms = [] + # True when DescribeAlarms was denied in at least one audited region, + # so the alarm inventory may be incomplete. + self.metric_alarms_unavailable = False self.__threading_call__(self._describe_alarms) if self.metric_alarms: self._list_tags_for_resource() @@ -56,13 +59,16 @@ class CloudWatch(AWSService): logger.error( f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + self.metric_alarms_unavailable = True if not self.metric_alarms: self.metric_alarms = None else: + self.metric_alarms_unavailable = True logger.error( f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) except Exception as error: + self.metric_alarms_unavailable = True logger.error( f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) @@ -92,6 +98,9 @@ class Logs(AWSService): # index for cross-service evidence lookups. self.all_log_groups = {} self.log_groups = {} + # True when DescribeLogGroups was denied in at least one audited + # region, so the log group inventory may be incomplete. + self.log_groups_unavailable = False self._log_groups_hydrated = set() self.log_group_limit = get_resource_scan_limit( self.audit_config, "max_cloudwatch_log_groups" @@ -103,6 +112,9 @@ class Logs(AWSService): self.resource_policies = {} self.__threading_call__(self._describe_resource_policies) self.metric_filters = [] + # True when DescribeMetricFilters was denied in at least one audited + # region, so the metric filter inventory may be incomplete. + self.metric_filters_unavailable = False self.__threading_call__(self._describe_metric_filters) if self.log_groups: if ( @@ -166,6 +178,9 @@ class Logs(AWSService): arn=arn, name=filter["filterName"], metric=filter["metricTransformations"][0]["metricName"], + metric_namespace=filter["metricTransformations"][0].get( + "metricNamespace" + ), pattern=filter.get("filterPattern", ""), log_group=log_group, region=regional_client.region, @@ -176,18 +191,32 @@ class Logs(AWSService): logger.error( f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + self.metric_filters_unavailable = True if not self.metric_filters: self.metric_filters = None else: + self.metric_filters_unavailable = True logger.error( f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) except Exception as error: + self.metric_filters_unavailable = True logger.error( f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) def _describe_log_groups(self, regional_client): + """List the log groups in a region into the complete and the analysed indexes. + + A denied DescribeLogGroups sets both indexes to None, but only while nothing has been + collected yet: that None is the state checks read as "inventory unknown", and it must stay + distinguishable from an account that genuinely has no log groups. Any other failure leaves + the indexes as they are, so a partial inventory reads as a smaller one. + + dataProtectionStatus and inheritedProperties are stored as reported. An absent + dataProtectionStatus is the API saying the log group has never had a policy, and it is kept + as None rather than a status string so a check can tell "never configured" from DISABLED. + """ logger.info("CloudWatch Logs - Describing log groups...") try: describe_log_groups_paginator = regional_client.get_paginator( @@ -215,6 +244,12 @@ class Logs(AWSService): never_expire=never_expire, kms_id=kms, creation_time=log_group.get("creationTime"), + data_protection_status=log_group.get( + "dataProtectionStatus" + ), + inherited_properties=log_group.get( + "inheritedProperties", [] + ), region=regional_client.region, ) self.all_log_groups[log_group_object.arn] = log_group_object @@ -224,14 +259,17 @@ class Logs(AWSService): logger.error( f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + self.log_groups_unavailable = True if not self.log_groups: self.all_log_groups = None self.log_groups = None else: + self.log_groups_unavailable = True logger.error( f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) except Exception as error: + self.log_groups_unavailable = True logger.error( f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) @@ -337,6 +375,11 @@ class LogGroup(BaseModel): never_expire: bool kms_id: Optional[str] creation_time: Optional[int] = None + # None when the log group has never had a data protection policy, otherwise + # ACTIVATED, DELETED, ARCHIVED or DISABLED. + data_protection_status: Optional[str] = None + # Properties inherited from account-level settings, e.g. ACCOUNT_DATA_PROTECTION. + inherited_properties: list[str] = [] region: str log_streams: dict[str, list[str]] = ( {} @@ -354,6 +397,7 @@ class MetricFilter(BaseModel): arn: str name: str metric: str + metric_namespace: Optional[str] = None pattern: str log_group: Optional[LogGroup] = None region: str diff --git a/prowler/providers/aws/services/cloudwatch/lib/metric_filters.py b/prowler/providers/aws/services/cloudwatch/lib/metric_filters.py index e5d104b840..2f5a6e4c51 100644 --- a/prowler/providers/aws/services/cloudwatch/lib/metric_filters.py +++ b/prowler/providers/aws/services/cloudwatch/lib/metric_filters.py @@ -48,7 +48,28 @@ def check_cloudwatch_log_metric_filter( metric_filters: list, metric_alarms: list, metadata: dict, -): +) -> Check_Report_AWS | None: + """Report whether a trail's log group has a matching metric filter and an alarm. + + Only metric filters attached to a log group that a CloudTrail trail delivers to + are considered, and only those whose own pattern matches + ``metric_filter_pattern``. A filter whose log group was not retrieved is skipped + rather than dereferenced. One compliant filter anywhere short-circuits to PASS; + otherwise the last matching filter found without an alarm is returned as FAIL. + + Args: + metric_filter_pattern: regex from ``build_metric_filter_pattern``, matched + against each filter's pattern with ``re.DOTALL``. + trails: CloudTrail trails keyed by ARN; only those with a log group count. + metric_filters: CloudWatch Logs metric filters to evaluate. + metric_alarms: CloudWatch alarms, matched to a filter by metric name and + region, and by namespace when both sides expose one. + metadata: check metadata for the emitted report. + + Returns: + A ``Check_Report_AWS`` for the deciding log group, or ``None`` when no + filter matched or an inventory was not collected. + """ report = None # 1. Iterate for CloudWatch Log Group in CloudTrail trails log_groups = [] @@ -58,6 +79,10 @@ def check_cloudwatch_log_metric_filter( log_groups.append(trail.log_group_arn.split(":")[6]) # 2. Describe metric filters for previous log groups for metric_filter in metric_filters: + # A filter whose log group was not retrieved cannot be matched against + # the trail log groups, so it cannot satisfy the requirement. + if metric_filter.log_group is None: + continue if metric_filter.log_group.name in log_groups and re.search( metric_filter_pattern, metric_filter.pattern, flags=re.DOTALL ): @@ -66,9 +91,20 @@ def check_cloudwatch_log_metric_filter( ) report.status = "FAIL" report.status_extended = f"CloudWatch log group {metric_filter.log_group.name} found with metric filter {metric_filter.name} but no alarms associated." - # 3. Check if there is an alarm for the metric + # 3. Check if there is an alarm for the metric. The alarm must + # watch the same metric name in the same region, and the same + # namespace when both sides expose one — a same-named metric + # in another namespace or region is a different metric. for alarm in metric_alarms: - if alarm.metric == metric_filter.metric: + if ( + alarm.metric == metric_filter.metric + and alarm.region == metric_filter.region + and ( + not metric_filter.metric_namespace + or not alarm.name_space + or alarm.name_space == metric_filter.metric_namespace + ) + ): report.status = "PASS" report.status_extended = f"CloudWatch log group {metric_filter.log_group.name} found with metric filter {metric_filter.name} and alarms set." break diff --git a/prowler/providers/aws/services/codebuild/codebuild_project_uses_allowed_github_organizations/codebuild_project_uses_allowed_github_organizations.py b/prowler/providers/aws/services/codebuild/codebuild_project_uses_allowed_github_organizations/codebuild_project_uses_allowed_github_organizations.py index 750a5a6fdd..11bb9a8b00 100644 --- a/prowler/providers/aws/services/codebuild/codebuild_project_uses_allowed_github_organizations/codebuild_project_uses_allowed_github_organizations.py +++ b/prowler/providers/aws/services/codebuild/codebuild_project_uses_allowed_github_organizations/codebuild_project_uses_allowed_github_organizations.py @@ -23,7 +23,7 @@ class codebuild_project_uses_allowed_github_organizations(Check): project_role = next( ( role - for role in iam_client.roles + for role in iam_client.roles or [] if role.arn == project.service_role_arn ), None, diff --git a/prowler/providers/aws/services/ecr/ecr_registry_enhanced_scanning_enabled/__init__.py b/prowler/providers/aws/services/ecr/ecr_registry_enhanced_scanning_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/ecr/ecr_registry_enhanced_scanning_enabled/ecr_registry_enhanced_scanning_enabled.metadata.json b/prowler/providers/aws/services/ecr/ecr_registry_enhanced_scanning_enabled/ecr_registry_enhanced_scanning_enabled.metadata.json new file mode 100644 index 0000000000..ecd5069429 --- /dev/null +++ b/prowler/providers/aws/services/ecr/ecr_registry_enhanced_scanning_enabled/ecr_registry_enhanced_scanning_enabled.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "aws", + "CheckID": "ecr_registry_enhanced_scanning_enabled", + "CheckTitle": "ECR registry has enhanced scanning enabled", + "CheckType": [ + "Software and Configuration Checks/Vulnerabilities/CVE", + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "ecr", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Other", + "ResourceGroup": "container", + "Description": "Amazon ECR registries with repositories are evaluated for **enhanced scanning**, the Amazon Inspector-powered scan type that covers operating system **and** programming language packages and can rescan images continuously as new CVEs are published. A registry left on **basic** scanning is reported as failing.", + "Risk": "**Basic** scanning matches only OS packages against a static CVE list, so **application dependencies** (npm, PyPI, Maven, Go, .NET) are never assessed. It rescans only on push or on an explicit StartImageScan, at most once per 24 hours, so a CVE published since the last scan stays invisible until someone acts. Vulnerable images keep being pulled, enabling **RCE** and supply chain compromise.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/AmazonECR/latest/userguide/image-scanning-enhanced.html", + "https://docs.aws.amazon.com/inspector/latest/user/scanning-ecr.html", + "https://docs.aws.amazon.com/AmazonECR/latest/userguide/image-scanning.html" + ], + "Remediation": { + "Code": { + "CLI": "aws ecr put-registry-scanning-configuration --scan-type ENHANCED --rules 'scanFrequency=CONTINUOUS_SCAN,repositoryFilters=[{filter=*,filterType=WILDCARD}]'", + "NativeIaC": "```yaml\nResources:\n RegistryScanningConfiguration:\n Type: AWS::ECR::RegistryScanningConfiguration\n Properties:\n ScanType: ENHANCED # Critical: BASIC limits the registry to operating-system coverage\n Rules:\n - ScanFrequency: CONTINUOUS_SCAN\n RepositoryFilters:\n - Filter: \"*\" # Critical: coverage comes from the filters, so * is what reaches every repository\n FilterType: WILDCARD\n```", + "Other": "1. Open the AWS Management Console and go to Amazon ECR\n2. In the left menu, click Private registry, then Scanning\n3. Click Edit\n4. Set Scanning type to Enhanced scanning\n5. Under Enhanced scanning, add or keep a repository filter matching every repository that must be scanned. Coverage comes from the filters, not from the frequency: a filter of * covers the whole registry, and any repository no filter matches is left unscanned\n6. Set the scan frequency for those filters, either Continuous scanning or Scan on push\n7. Click Save", + "Terraform": "```hcl\nresource \"aws_ecr_registry_scanning_configuration\" \"\" {\n scan_type = \"ENHANCED\"\n\n rule {\n scan_frequency = \"CONTINUOUS_SCAN\"\n repository_filter {\n filter = \"*\"\n filter_type = \"WILDCARD\"\n }\n }\n}\n```" + }, + "Recommendation": { + "Text": "Set the registry scan type to **enhanced scanning**, which delegates scanning to **Amazon Inspector** and adds programming language package coverage plus continuous rescanning on top of the operating system coverage that basic scanning provides. Feed the resulting findings into CI/CD gates so vulnerable images are not promoted.", + "Url": "https://hub.prowler.com/check/ecr_registry_enhanced_scanning_enabled" + } + }, + "Categories": [ + "container-security" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Scoped to registries that hold at least one repository. The ECR API accepts both the CONTINUOUS_SCAN and SCAN_ON_PUSH frequencies for the ENHANCED scan type, so ENHANCED on its own does not imply continuous rescanning; frequency is a separate axis this check does not assert. It is NOT a coverage guarantee, and this check does not claim one: enhanced scanning is driven by repository filters, so clearing the scan-all filter leaves any repository no filter matches unscanned. What this check asserts is the registry's scan TYPE, not that every repository in it is covered. Registry-wide scan-on-push coverage and its repository filters are asserted by ecr_registry_scan_images_on_push_enabled, which passes for either scan type. Reported as MANUAL when GetRegistryScanningConfiguration could not be read, because an unretrieved scan type is not evidence of compliance." +} diff --git a/prowler/providers/aws/services/ecr/ecr_registry_enhanced_scanning_enabled/ecr_registry_enhanced_scanning_enabled.py b/prowler/providers/aws/services/ecr/ecr_registry_enhanced_scanning_enabled/ecr_registry_enhanced_scanning_enabled.py new file mode 100644 index 0000000000..49c7819217 --- /dev/null +++ b/prowler/providers/aws/services/ecr/ecr_registry_enhanced_scanning_enabled/ecr_registry_enhanced_scanning_enabled.py @@ -0,0 +1,43 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.ecr.ecr_client import ecr_client + + +class ecr_registry_enhanced_scanning_enabled(Check): + """Verify that in-use ECR registries have enhanced scanning enabled. + + Enhanced scanning (Amazon Inspector) covers operating system and programming + language packages with continuous rescanning as new CVEs are published, while + basic scanning only covers operating system packages at push time against a + static CVE list. Only registries holding at least one repository are checked. + - PASS: the registry scan type is ENHANCED. + - FAIL: the registry is on another scan type. + - MANUAL: the registry scanning configuration could not be retrieved, so the + scan type is unknown. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the check logic. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + for registry in ecr_client.registries.values(): + # We want to check the registry if it is in use, hence there are repositories + if len(registry.repositories) != 0: + report = Check_Report_AWS(metadata=self.metadata(), resource=registry) + if registry.scan_type is None: + report.status = "MANUAL" + report.status_extended = f"ECR registry {registry.id} scanning configuration could not be retrieved, check manually if enhanced scanning is enabled." + elif registry.scan_type == "ENHANCED": + report.status = "PASS" + report.status_extended = ( + f"ECR registry {registry.id} has enhanced scanning enabled." + ) + else: + report.status = "FAIL" + report.status_extended = f"ECR registry {registry.id} has {registry.scan_type} scanning enabled instead of enhanced scanning." + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/ecr/ecr_registry_scan_images_on_push_enabled/ecr_registry_scan_images_on_push_enabled.metadata.json b/prowler/providers/aws/services/ecr/ecr_registry_scan_images_on_push_enabled/ecr_registry_scan_images_on_push_enabled.metadata.json index b589a8db7c..4b119021bd 100644 --- a/prowler/providers/aws/services/ecr/ecr_registry_scan_images_on_push_enabled/ecr_registry_scan_images_on_push_enabled.metadata.json +++ b/prowler/providers/aws/services/ecr/ecr_registry_scan_images_on_push_enabled/ecr_registry_scan_images_on_push_enabled.metadata.json @@ -1,7 +1,7 @@ { "Provider": "aws", "CheckID": "ecr_registry_scan_images_on_push_enabled", - "CheckTitle": "ECR registry has image scanning on push enabled for all repositories", + "CheckTitle": "ECR registry has automated image scanning enabled for all repositories", "CheckType": [ "Software and Configuration Checks/AWS Security Best Practices", "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" @@ -12,8 +12,8 @@ "Severity": "medium", "ResourceType": "Other", "ResourceGroup": "container", - "Description": "Amazon ECR registries with repositories are evaluated for image scanning configured as `scan on push` at the registry level, with scan rules that cover all repositories (no restrictive filters), for either **basic** or **enhanced** scanning.", - "Risk": "Absent or filtered `scan on push` lets **vulnerable images** be pushed and deployed without timely detection, enabling exploitation of known CVEs (RCE, privilege escalation), supply chain compromise, and lateral movement - threatening workload integrity and data confidentiality.", + "Description": "Amazon ECR registries with repositories are evaluated for automated image scanning at the registry level -- `scan on push` or `continuous scanning` -- with scan rules that cover all repositories (no restrictive filters), for either **basic** or **enhanced** scanning. A registry whose rules specify only `MANUAL` scanning does not scan pushed images.", + "Risk": "Without automated registry scanning, **vulnerable images** are pushed and deployed without timely detection, enabling exploitation of known CVEs (RCE, privilege escalation), supply chain compromise, and lateral movement - threatening workload integrity and data confidentiality. Repository filters narrow the same risk to whichever repositories they exclude.", "RelatedUrl": "", "AdditionalURLs": [ "https://docs.aws.amazon.com/AmazonECR/latest/userguide/image-scanning.html" diff --git a/prowler/providers/aws/services/ecr/ecr_registry_scan_images_on_push_enabled/ecr_registry_scan_images_on_push_enabled.py b/prowler/providers/aws/services/ecr/ecr_registry_scan_images_on_push_enabled/ecr_registry_scan_images_on_push_enabled.py index eee087359d..65a2f435aa 100644 --- a/prowler/providers/aws/services/ecr/ecr_registry_scan_images_on_push_enabled/ecr_registry_scan_images_on_push_enabled.py +++ b/prowler/providers/aws/services/ecr/ecr_registry_scan_images_on_push_enabled/ecr_registry_scan_images_on_push_enabled.py @@ -1,27 +1,69 @@ from prowler.lib.check.models import Check, Check_Report_AWS from prowler.providers.aws.services.ecr.ecr_client import ecr_client +# The two frequencies that scan an image without anyone asking. MANUAL is the third value +# GetRegistryScanningConfiguration can return, and it is the default a BASIC registry gets when +# scan on push is not specified, so it is the state this check exists to catch. +AUTOMATED_SCAN_FREQUENCIES = {"SCAN_ON_PUSH", "CONTINUOUS_SCAN"} + class ecr_registry_scan_images_on_push_enabled(Check): - def execute(self): + def execute(self) -> list[Check_Report_AWS]: + """Execute the check against every ECR registry that holds repositories. + + Returns: + A list of reports, one per in-use registry, PASS when its rules cover all + repositories with a frequency in AUTOMATED_SCAN_FREQUENCIES. + """ findings = [] for registry in ecr_client.registries.values(): # We want to check the registry if it is in use, hence there are repositories if len(registry.repositories) != 0: report = Check_Report_AWS(metadata=self.metadata(), resource=registry) report.status = "FAIL" - report.status_extended = f"ECR registry {registry.id} has {registry.scan_type} scanning without scan on push enabled." + report.status_extended = f"ECR registry {registry.id} has {registry.scan_type} scanning without automated scanning enabled." if registry.rules: - report.status = "PASS" - report.status_extended = f"ECR registry {registry.id} has {registry.scan_type} scan with scan on push enabled." - filters = True - for rule in registry.rules: - if not rule.scan_filters or "'*'" in str(rule.scan_filters): - filters = False - if filters: - report.status = "FAIL" - report.status_extended = f"ECR registry {registry.id} has {registry.scan_type} scanning with scan on push but with repository filters." + # Read the frequency rather than inferring it from the presence of a rule. A rule + # always carries one -- scanFrequency is required on the shape -- and a MANUAL + # rule is a registry where nothing is scanned until someone runs a scan by hand. + frequencies = { + rule.scan_frequency + for rule in registry.rules + if rule.scan_frequency in AUTOMATED_SCAN_FREQUENCIES + } + if frequencies: + report.status = "PASS" + report.status_extended = f"ECR registry {registry.id} has {registry.scan_type} scanning with {self._describe(frequencies)} for all repositories." + filters = True + for rule in registry.rules: + if not rule.scan_filters or "'*'" in str(rule.scan_filters): + filters = False + if filters: + report.status = "FAIL" + report.status_extended = f"ECR registry {registry.id} has {registry.scan_type} scanning with {self._describe(frequencies)} but with repository filters." + else: + report.status_extended = f"ECR registry {registry.id} has {registry.scan_type} scanning set to manual only, so images are not scanned when they are pushed." findings.append(report) return findings + + @staticmethod + def _describe(frequencies: set) -> str: + """Name automated scan frequencies in a fixed order. + + Args: + frequencies: The registry's configured frequencies, already narrowed to + AUTOMATED_SCAN_FREQUENCIES. + + Returns: + The frequencies in a fixed order, so the message does not vary between runs for + the same registry. + """ + wording = { + "SCAN_ON_PUSH": "scan on push", + "CONTINUOUS_SCAN": "continuous scanning", + } + return " and ".join( + wording[f] for f in ("SCAN_ON_PUSH", "CONTINUOUS_SCAN") if f in frequencies + ) diff --git a/prowler/providers/aws/services/eks/eks_cluster_vpc_cni_network_policy_enforced/__init__.py b/prowler/providers/aws/services/eks/eks_cluster_vpc_cni_network_policy_enforced/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/eks/eks_cluster_vpc_cni_network_policy_enforced/eks_cluster_vpc_cni_network_policy_enforced.metadata.json b/prowler/providers/aws/services/eks/eks_cluster_vpc_cni_network_policy_enforced/eks_cluster_vpc_cni_network_policy_enforced.metadata.json new file mode 100644 index 0000000000..f9b4bb0b30 --- /dev/null +++ b/prowler/providers/aws/services/eks/eks_cluster_vpc_cni_network_policy_enforced/eks_cluster_vpc_cni_network_policy_enforced.metadata.json @@ -0,0 +1,42 @@ +{ + "Provider": "aws", + "CheckID": "eks_cluster_vpc_cni_network_policy_enforced", + "CheckTitle": "EKS cluster enforces Kubernetes network policies through the Amazon VPC CNI add-on", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "TTPs/Lateral Movement" + ], + "ServiceName": "eks", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "AwsEksCluster", + "ResourceGroup": "container", + "Description": "**Amazon EKS clusters** are evaluated for whether the **Amazon VPC CNI** managed add-on sets `enableNetworkPolicy` to `true`, which is what makes the CNI enforce Kubernetes `NetworkPolicy` resources. The policy objects themselves live in the cluster and are not exposed by the EKS API, so only this enforcement precondition is verified.", + "Risk": "Without CNI **network policy enforcement** every `NetworkPolicy` an operator authors is inert, so pods reach every other pod and service in the cluster. That unrestricted east-west path lets one compromised container move **laterally** to sidecars, tool executors and internal APIs, widening the blast radius and easing **data exfiltration**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/eks/latest/userguide/cni-network-policy.html", + "https://docs.aws.amazon.com/eks/latest/APIReference/API_UpdateAddon.html", + "https://docs.aws.amazon.com/eks/latest/userguide/updating-an-add-on.html" + ], + "Remediation": { + "Code": { + "CLI": "aws eks update-addon --cluster-name --addon-name vpc-cni --resolve-conflicts PRESERVE --configuration-values '{\"enableNetworkPolicy\":\"true\"}'", + "NativeIaC": "```yaml\n# CloudFormation: enable network policy enforcement in the VPC CNI add-on\nResources:\n :\n Type: AWS::EKS::Addon\n Properties:\n ClusterName: \n AddonName: vpc-cni\n ResolveConflicts: PRESERVE\n ConfigurationValues: '{\"enableNetworkPolicy\":\"true\"}' # critical: makes the CNI enforce NetworkPolicy resources\n```", + "Other": "1. Open the AWS Console and go to EKS > Clusters\n2. Select and open the Add-ons tab\n3. Select the Amazon VPC CNI add-on and click Edit\n4. Expand Optional configuration settings and set enableNetworkPolicy to \"true\" in the configuration values\n5. Click Save changes", + "Terraform": "```hcl\n# Enable network policy enforcement in the VPC CNI managed add-on\nresource \"aws_eks_addon\" \"\" {\n cluster_name = \"\"\n addon_name = \"vpc-cni\"\n resolve_conflicts_on_update = \"PRESERVE\"\n\n configuration_values = jsonencode({\n enableNetworkPolicy = \"true\" # critical: makes the CNI enforce NetworkPolicy resources\n })\n}\n```" + }, + "Recommendation": { + "Text": "Set `enableNetworkPolicy` to `true` on the Amazon VPC CNI add-on, then author `NetworkPolicy` resources that allow each workload only its declared dependencies, ideally with `strict` enforcement mode so traffic is denied until the policy is in place. Layer security groups for Pods to reach VPC resources such as databases.", + "Url": "https://hub.prowler.com/check/eks_cluster_vpc_cni_network_policy_enforced" + } + }, + "Categories": [ + "trust-boundaries", + "cluster-security" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "The EKS API exposes only the add-on setting, not the Kubernetes NetworkPolicy resources, so a PASS is the enforcement precondition rather than proof that pod-to-pod traffic is restricted. A FAIL is a statement about the managed add-on, not about the cluster: two documented architectures enforce network policies with this setting false and neither is visible to the EKS API. A third-party policy engine -- the EKS Best Practices Guide recommends Calico and Cilium in its 'ThirdParty Network Policy Engines' section (eks/latest/best-practices/network-security.html) -- would correctly leave it false, since two enforcers are not run together. And a self-managed VPC CNI can be enabled by Helm or by the amazon-vpc-cni ConfigMap key enable-network-policy-controller with the aws-node DaemonSet, two of the three paths AWS documents (eks/latest/userguide/cni-network-policy-configure.html); only the managed add-on path is readable here. A cluster with no vpc-cni managed add-on at all reports MANUAL for the same reason. Detecting Calico or Cilium is deliberately not attempted, because any signal would be a guess presented as a measurement." +} diff --git a/prowler/providers/aws/services/eks/eks_cluster_vpc_cni_network_policy_enforced/eks_cluster_vpc_cni_network_policy_enforced.py b/prowler/providers/aws/services/eks/eks_cluster_vpc_cni_network_policy_enforced/eks_cluster_vpc_cni_network_policy_enforced.py new file mode 100644 index 0000000000..cd634c9278 --- /dev/null +++ b/prowler/providers/aws/services/eks/eks_cluster_vpc_cni_network_policy_enforced/eks_cluster_vpc_cni_network_policy_enforced.py @@ -0,0 +1,129 @@ +import json +from typing import Optional + +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.eks.eks_client import eks_client + +VPC_CNI_ADDON_NAME = "vpc-cni" +NETWORK_POLICY_KEY = "enableNetworkPolicy" + + +def parse_configuration_values(configuration_values: Optional[str]) -> Optional[dict]: + """Decode an EKS add-on `configurationValues` blob. + + Args: + configuration_values: The raw JSON string returned by DescribeAddon, which is + absent when no configuration has been supplied for the add-on. + + Returns: + The decoded mapping, an empty mapping when nothing was supplied, or None when + the blob is not a readable JSON object. + """ + if not configuration_values: + return {} + try: + configuration = json.loads(configuration_values) + except ValueError: + return None + return configuration if isinstance(configuration, dict) else None + + +def boolean_configuration_value(value: object) -> Optional[bool]: + """Read a VPC CNI boolean setting. + + The add-on configuration schema types these as a string carrying `"format": "boolean"`, + so the API returns `"true"` rather than `true`; a JSON boolean is accepted as well + because the schema is add-on-version specific and this blob is otherwise untyped. + + Args: + value: The value found in the add-on configuration, if any. + + Returns: + The boolean it denotes, or None when it is absent or not a recognized boolean. + """ + if isinstance(value, bool): + return value + if isinstance(value, str) and value.strip().lower() in ("true", "false"): + return value.strip().lower() == "true" + return None + + +class eks_cluster_vpc_cni_network_policy_enforced(Check): + """Ensure the Amazon VPC CNI add-on enforces Kubernetes network policies. + + Kubernetes NetworkPolicy resources live in the cluster and are not exposed by the + EKS API. What the API does expose is whether the Amazon VPC CNI managed add-on has + network policy enforcement switched on, which is the precondition for any + NetworkPolicy to take effect. + - PASS: The Amazon VPC CNI add-on sets enableNetworkPolicy to true. + - FAIL: The Amazon VPC CNI add-on sets enableNetworkPolicy to false. + - MANUAL: The setting cannot be read, or the cluster does not use the Amazon VPC CNI + managed add-on. + + TWO WAYS A CLUSTER CAN ENFORCE NETWORK POLICIES WITHOUT THIS SETTING BEING TRUE, so a + FAIL is a statement about the managed add-on and not about the cluster. Neither is + fixable by reading more of the AWS API: both live in in-cluster state that + DescribeAddon cannot see. + + 1. A third-party policy engine. The EKS Best Practices Guide recommends Calico and + Cilium for requirements the VPC CNI does not cover, such as Layer 7 and DNS + hostname rules, in its "ThirdParty Network Policy Engines" section + (https://docs.aws.amazon.com/eks/latest/best-practices/network-security.html). + A cluster enforcing through one of those would correctly leave this setting false, + because two enforcers are not run together. + 2. A self-managed VPC CNI. AWS documents three ways to enable the feature and only the + first is visible here + (https://docs.aws.amazon.com/eks/latest/userguide/cni-network-policy-configure.html): + `aws eks update-addon --addon-name vpc-cni` with configurationValues; `helm upgrade + ... aws-vpc-cni`; or the `amazon-vpc-cni` ConfigMap key + `enable-network-policy-controller: "true"` together with policy enforcement in the + aws-node container of the VPC CNI DaemonSet. The second and third leave the EKS + control plane with nothing to report. + + Detecting either is deliberately NOT attempted. Calico and Cilium are invisible to the + AWS API, so any signal would be a guess presented as a measurement. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the check logic. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + for cluster in eks_client.clusters: + report = Check_Report_AWS(metadata=self.metadata(), resource=cluster) + report.status = "MANUAL" + addon = cluster.addons.get(VPC_CNI_ADDON_NAME) + + if addon is None and cluster.addons_discovery_failed: + report.status_extended = f"EKS cluster {cluster.name} add-ons could not be listed, so Kubernetes network policy enforcement in the Amazon VPC CNI add-on cannot be determined." + elif addon is None: + report.status_extended = f"EKS cluster {cluster.name} does not use the Amazon VPC CNI managed add-on, so Kubernetes network policy enforcement cannot be determined from the EKS API. Review the self-managed CNI configuration in the cluster." + elif addon.configuration_discovery_failed: + report.status_extended = f"EKS cluster {cluster.name} Amazon VPC CNI add-on configuration could not be read, so Kubernetes network policy enforcement cannot be determined." + else: + configuration = parse_configuration_values(addon.configuration_values) + if configuration is None: + report.status_extended = f"EKS cluster {cluster.name} Amazon VPC CNI add-on configuration values are not a readable JSON object, so Kubernetes network policy enforcement cannot be determined." + else: + network_policy_enabled = boolean_configuration_value( + configuration.get(NETWORK_POLICY_KEY) + ) + if network_policy_enabled is None: + report.status_extended = f"EKS cluster {cluster.name} Amazon VPC CNI add-on does not set {NETWORK_POLICY_KEY} to true or false, so Kubernetes network policy enforcement cannot be determined." + elif network_policy_enabled: + report.status = "PASS" + report.status_extended = f"EKS cluster {cluster.name} enforces Kubernetes network policies through the Amazon VPC CNI add-on. This does not confirm that NetworkPolicy resources restricting pod-to-pod traffic exist in the cluster." + else: + # States the measurement, not the inference from it. The previous wording -- + # "cluster does not enforce Kubernetes network policies" -- claimed a cluster + # property from an add-on setting, and is false for a cluster enforcing + # through Calico or Cilium, or through a self-managed VPC CNI. Both are + # documented architectures rather than edge cases; see the class docstring. + report.status = "FAIL" + report.status_extended = f"EKS cluster {cluster.name} Amazon VPC CNI managed add-on does not enforce Kubernetes network policies, since it sets {NETWORK_POLICY_KEY} to false. Enforcement by a third-party policy engine or a self-managed VPC CNI is not visible to the EKS API and is not evaluated." + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/eks/eks_service.py b/prowler/providers/aws/services/eks/eks_service.py index b0ccace49b..ef06feb823 100644 --- a/prowler/providers/aws/services/eks/eks_service.py +++ b/prowler/providers/aws/services/eks/eks_service.py @@ -6,14 +6,19 @@ from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered from prowler.providers.aws.lib.service.service import AWSService +# DescribeAddon has no batch form, so only add-ons a check reads are described. +COLLECTED_ADDONS = ("vpc-cni",) + class EKS(AWSService): def __init__(self, provider): + """Collect the audited account's EKS clusters, their configuration and their add-ons.""" # Call AWSService's __init__ super().__init__(__class__.__name__, provider) self.clusters = [] self.__threading_call__(self._list_clusters) self._describe_cluster(self.regional_clients) + self.__threading_call__(self._describe_cluster_addons, self.clusters) def _list_clusters(self, regional_client): logger.info("EKS listing clusters...") @@ -95,12 +100,69 @@ class EKS(AWSService): f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _describe_cluster_addons(self, cluster): + """Attach the add-ons named in COLLECTED_ADDONS, with their configuration, to a cluster. + + ListAddons names every add-on installed on the cluster and DescribeAddon then supplies + the ARN and the raw `configurationValues` blob for the ones checks read. A failed listing + sets `addons_discovery_failed` on the cluster and a failed describe sets + `configuration_discovery_failed` on the add-on, so a check can tell an add-on that is + absent from one whose state could not be read instead of reporting both as absent. + """ + logger.info("EKS describing cluster add-ons...") + try: + regional_client = self.regional_clients[cluster.region] + list_addons_paginator = regional_client.get_paginator("list_addons") + for page in list_addons_paginator.paginate(clusterName=cluster.name): + for addon_name in page["addons"]: + if addon_name in COLLECTED_ADDONS: + cluster.addons[addon_name] = EKSAddon(name=addon_name) + except Exception as error: + cluster.addons_discovery_failed = True + logger.error( + f"{cluster.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return + + for addon in cluster.addons.values(): + try: + describe_addon = regional_client.describe_addon( + clusterName=cluster.name, addonName=addon.name + ) + addon.arn = describe_addon["addon"].get("addonArn") + addon.configuration_values = describe_addon["addon"].get( + "configurationValues" + ) + except Exception as error: + addon.configuration_discovery_failed = True + logger.error( + f"{cluster.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + class EKSClusterLoggingEntity(BaseModel): types: list[str] = None enabled: bool = None +class EKSAddon(BaseModel): + """An EKS managed add-on, with the configuration values collected for it. + + Attributes: + name: The add-on name as returned by ListAddons. + arn: The add-on ARN, absent when DescribeAddon could not be read. + configuration_values: The raw JSON blob supplied for the add-on, absent when none + was supplied or when DescribeAddon could not be read. + configuration_discovery_failed: True when DescribeAddon failed, so a check can + tell a setting that is unset from one that could not be read. + """ + + name: str + arn: Optional[str] = None + configuration_values: Optional[str] = None + configuration_discovery_failed: bool = False + + class EKSCluster(BaseModel): name: str arn: str @@ -113,4 +175,6 @@ class EKSCluster(BaseModel): public_access_cidrs: list[str] = [] encryptionConfig: bool = None deletion_protection: bool = None + addons: dict[str, EKSAddon] = {} + addons_discovery_failed: bool = False tags: Optional[list] = [] diff --git a/prowler/providers/aws/services/elbv2/elbv2_listener_fips_tls_enabled/__init__.py b/prowler/providers/aws/services/elbv2/elbv2_listener_fips_tls_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/elbv2/elbv2_listener_fips_tls_enabled/elbv2_listener_fips_tls_enabled.metadata.json b/prowler/providers/aws/services/elbv2/elbv2_listener_fips_tls_enabled/elbv2_listener_fips_tls_enabled.metadata.json new file mode 100644 index 0000000000..9e321f7c54 --- /dev/null +++ b/prowler/providers/aws/services/elbv2/elbv2_listener_fips_tls_enabled/elbv2_listener_fips_tls_enabled.metadata.json @@ -0,0 +1,43 @@ +{ + "Provider": "aws", + "CheckID": "elbv2_listener_fips_tls_enabled", + "CheckTitle": "ELBv2 HTTPS/TLS listeners use a FIPS TLS security policy", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "elbv2", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "AwsElbv2LoadBalancer", + "ResourceGroup": "network", + "Description": "**ELBv2 HTTPS and TLS listeners** are assessed for use of a **FIPS** TLS security policy (`ELBSecurityPolicy-*-FIPS-*`). FIPS policies terminate TLS with the AWS-LC FIPS validated cryptographic module.", + "Risk": "Listeners without a FIPS policy terminate TLS with cryptographic modules that are not FIPS 140 validated, which does not meet requirements to protect federal or regulated data with **NIST CMVP validated cryptography**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/elasticloadbalancing/latest/application/describe-ssl-policies.html", + "https://docs.aws.amazon.com/elasticloadbalancing/latest/network/describe-ssl-policies.html", + "https://aws.amazon.com/compliance/fips/" + ], + "Remediation": { + "Code": { + "CLI": "aws elbv2 modify-listener --listener-arn --ssl-policy ELBSecurityPolicy-TLS13-1-2-FIPS-2023-04", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::ElasticLoadBalancingV2::Listener\n Properties:\n LoadBalancerArn: \n Protocol: HTTPS\n Port: 443\n DefaultActions:\n - Type: forward\n TargetGroupArn: \n Certificates:\n - CertificateArn: \n SslPolicy: ELBSecurityPolicy-TLS13-1-2-FIPS-2023-04 # FIX: uses a FIPS TLS policy\n```", + "Other": "1. In the AWS Console, go to EC2 > Load Balancers\n2. Select the load balancer and open the Listeners tab\n3. Select each HTTPS/TLS listener and choose Edit\n4. Set Security policy to a FIPS policy such as ELBSecurityPolicy-TLS13-1-2-FIPS-2023-04\n5. Save changes", + "Terraform": "```hcl\nresource \"aws_lb_listener\" \"\" {\n load_balancer_arn = \"\"\n port = 443\n protocol = \"HTTPS\"\n ssl_policy = \"ELBSecurityPolicy-TLS13-1-2-FIPS-2023-04\" # FIX: FIPS TLS policy\n certificate_arn = \"\"\n\n default_action {\n type = \"forward\"\n target_group_arn = \"\"\n }\n}\n```" + }, + "Recommendation": { + "Text": "Use a **FIPS** TLS security policy, such as `ELBSecurityPolicy-TLS13-1-2-FIPS-2023-04`, on every HTTPS and TLS listener that carries federal or regulated data.", + "Url": "https://hub.prowler.com/check/elbv2_listener_fips_tls_enabled" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [ + "elbv2_insecure_ssl_ciphers", + "elbv2_listener_pqc_tls_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/aws/services/elbv2/elbv2_listener_fips_tls_enabled/elbv2_listener_fips_tls_enabled.py b/prowler/providers/aws/services/elbv2/elbv2_listener_fips_tls_enabled/elbv2_listener_fips_tls_enabled.py new file mode 100644 index 0000000000..3a9d07f4ba --- /dev/null +++ b/prowler/providers/aws/services/elbv2/elbv2_listener_fips_tls_enabled/elbv2_listener_fips_tls_enabled.py @@ -0,0 +1,35 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.elbv2.elbv2_client import elbv2_client + + +class elbv2_listener_fips_tls_enabled(Check): + """Ensure every ELBv2 HTTPS or TLS listener uses a FIPS TLS security policy.""" + + def execute(self) -> list[Check_Report_AWS]: + """Report whether each load balancer terminates HTTPS/TLS with a FIPS policy.""" + findings = [] + for lb in elbv2_client.loadbalancersv2.values(): + if lb.listener_discovery_failed: + continue + report = Check_Report_AWS(metadata=self.metadata(), resource=lb) + tls_listeners = { + listener_arn: listener + for listener_arn, listener in lb.listeners.items() + if listener.protocol in ("HTTPS", "TLS") + } + non_fips_listeners = [ + f"{listener.protocol}:{listener.port} ({listener_arn}) uses {listener.ssl_policy or ''}" + for listener_arn, listener in tls_listeners.items() + if "FIPS" not in (listener.ssl_policy or "").split("-") + ] + if not tls_listeners: + report.status = "PASS" + report.status_extended = f"ELBv2 {lb.name} has no HTTPS/TLS listeners." + elif non_fips_listeners: + report.status = "FAIL" + report.status_extended = f"ELBv2 {lb.name} has HTTPS/TLS listeners without a FIPS TLS security policy: {', '.join(non_fips_listeners)}." + else: + report.status = "PASS" + report.status_extended = f"ELBv2 {lb.name} has all HTTPS/TLS listeners using a FIPS TLS security policy." + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/guardduty/guardduty_ai_protection_enabled/__init__.py b/prowler/providers/aws/services/guardduty/guardduty_ai_protection_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/guardduty/guardduty_ai_protection_enabled/guardduty_ai_protection_enabled.metadata.json b/prowler/providers/aws/services/guardduty/guardduty_ai_protection_enabled/guardduty_ai_protection_enabled.metadata.json new file mode 100644 index 0000000000..93dda805b4 --- /dev/null +++ b/prowler/providers/aws/services/guardduty/guardduty_ai_protection_enabled/guardduty_ai_protection_enabled.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "aws", + "CheckID": "guardduty_ai_protection_enabled", + "CheckTitle": "GuardDuty detector has AI Protection enabled", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices/Runtime Behavior Analysis", + "TTPs/Credential Access", + "Effects/Resource Consumption" + ], + "ServiceName": "guardduty", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "AwsGuardDutyDetector", + "ResourceGroup": "security", + "Description": "Active **Amazon GuardDuty detectors** are assessed for **AI Protection** being enabled, which analyzes AWS CloudTrail data events from Amazon Bedrock, Amazon Bedrock AgentCore and Amazon SageMaker AI to flag anomalous model invocations, cost harvesting and prompt injection. Detectors that do not report the feature return `MANUAL`, because absence means the Region does not offer it.", + "Risk": "Without **AI Protection**, model invocation activity is never baselined, so attackers using **stolen credentials** can invoke foundation models undetected.\n\nThat costs **confidentiality** of prompts and outputs, and **availability** too: expensive prompts harvest inference spend and exhaust quota.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/guardduty/latest/ug/ai-protection.html", + "https://docs.aws.amazon.com/guardduty/latest/ug/ai-protection-enable-standalone-account.html", + "https://docs.aws.amazon.com/guardduty/latest/ug/findings-ai-protection.html", + "https://docs.aws.amazon.com/guardduty/latest/ug/guardduty_regions.html" + ], + "Remediation": { + "Code": { + "CLI": "aws guardduty update-detector --detector-id --features Name=AI_PROTECTION,Status=ENABLED", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::GuardDuty::Detector\n Properties:\n Enable: true\n Features:\n - Name: AI_PROTECTION # Critical: selects the GuardDuty AI Protection plan\n Status: ENABLED # Critical: turns AI Protection on\n```", + "Other": "1. Open the AWS Console and go to Amazon GuardDuty\n2. In the Region selector, choose a Region that offers AI Protection\n3. In the navigation pane, choose Protection plans\n4. Choose Configure all enablements, then under AI Protection choose Enable\n5. Choose Save all, then Confirm and save\n6. In an organization, do this from the delegated GuardDuty administrator account and auto-enable it for new accounts", + "Terraform": "```hcl\nresource \"aws_guardduty_detector_feature\" \"\" {\n detector_id = aws_guardduty_detector..id\n name = \"AI_PROTECTION\" # Critical: GuardDuty AI Protection plan\n status = \"ENABLED\" # Critical: enable the feature\n}\n```" + }, + "Recommendation": { + "Text": "Enable **GuardDuty AI Protection** in every account and Region hosting AI workloads.\n- Enable it org-wide from the delegated GuardDuty administrator and auto-enable it for new accounts\n- Enforce **Amazon Bedrock Guardrails** for prompt attacks, which AI Protection requires to raise prompt injection findings\n- Route findings to AWS Security Hub and review them on a defined cadence", + "Url": "https://hub.prowler.com/check/guardduty_ai_protection_enabled" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/guardduty/guardduty_ai_protection_enabled/guardduty_ai_protection_enabled.py b/prowler/providers/aws/services/guardduty/guardduty_ai_protection_enabled/guardduty_ai_protection_enabled.py new file mode 100644 index 0000000000..734849d5d5 --- /dev/null +++ b/prowler/providers/aws/services/guardduty/guardduty_ai_protection_enabled/guardduty_ai_protection_enabled.py @@ -0,0 +1,59 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.guardduty.guardduty_client import guardduty_client + + +class guardduty_ai_protection_enabled(Check): + """Ensure GuardDuty AI Protection is enabled on every active detector. + + AI Protection analyzes AWS CloudTrail data events from Amazon Bedrock, Amazon + Bedrock AgentCore and Amazon SageMaker AI, which makes it the detective control + for AI workloads. + + The feature has three observable states rather than two: + + 1. Reported as ENABLED: PASS. + 2. Reported as DISABLED: FAIL. + 3. Not reported at all: MANUAL. GuardDuty omits features that the Region or the + GuardDuty version does not offer, and "could not tell" is not "not + compliant". The same account can carry a feature in some Regions and omit it + in others, so absence cannot be read as disablement. + + A suspended detector, or one whose GetDetector call failed, is MANUAL as well: + the feature state is unknown either way, and guardduty_is_enabled owns the + detector-level finding. Regions with no detector at all are left to + guardduty_is_enabled entirely. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Assess AI Protection on every GuardDuty detector in the account. + + Returns: + list[Check_Report_AWS]: one report per detector that exists. PASS when AI + Protection is enabled, FAIL when GuardDuty reported the feature + disabled, and MANUAL when either the detector state or the feature + itself was not reported. + """ + findings = [] + for detector in guardduty_client.detectors: + if not detector.enabled_in_account: + continue + + report = Check_Report_AWS(metadata=self.metadata(), resource=detector) + + if not detector.status: + report.status = "MANUAL" + report.status_extended = f"GuardDuty detector {detector.id} is not enabled or could not be read, so AI Protection coverage could not be determined." + elif detector.ai_protection is None: + report.status = "MANUAL" + report.status_extended = f"GuardDuty detector {detector.id} does not report the AI Protection feature, so verify manually whether AI Protection is available in region {detector.region}." + elif detector.ai_protection: + report.status = "PASS" + report.status_extended = ( + f"GuardDuty detector {detector.id} has AI Protection enabled." + ) + else: + report.status = "FAIL" + report.status_extended = f"GuardDuty detector {detector.id} does not have AI Protection enabled." + + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/guardduty/guardduty_eks_runtime_monitoring_enabled/guardduty_eks_runtime_monitoring_enabled.metadata.json b/prowler/providers/aws/services/guardduty/guardduty_eks_runtime_monitoring_enabled/guardduty_eks_runtime_monitoring_enabled.metadata.json index 99d2ebfc83..a4599023db 100644 --- a/prowler/providers/aws/services/guardduty/guardduty_eks_runtime_monitoring_enabled/guardduty_eks_runtime_monitoring_enabled.metadata.json +++ b/prowler/providers/aws/services/guardduty/guardduty_eks_runtime_monitoring_enabled/guardduty_eks_runtime_monitoring_enabled.metadata.json @@ -22,10 +22,10 @@ ], "Remediation": { "Code": { - "CLI": "aws guardduty update-detector --detector-id --features name=EKS_RUNTIME_MONITORING,status=ENABLED", + "CLI": "aws guardduty update-detector --detector-id --features Name=EKS_RUNTIME_MONITORING,Status=ENABLED", "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::GuardDuty::Detector\n Properties:\n Enable: true\n Features:\n - Name: EKS_RUNTIME_MONITORING # Critical: selects EKS Runtime Monitoring feature\n Status: ENABLED # Critical: enables the feature to pass the check\n```", "Other": "1. Open the AWS Console and go to Amazon GuardDuty\n2. In the left pane, select Settings > Runtime monitoring\n3. Under EKS Runtime Monitoring, switch the status to Enabled\n4. Click Save changes", - "Terraform": "```hcl\nresource \"aws_guardduty_detector\" \"\" {\n enable = true\n\n features {\n name = \"EKS_RUNTIME_MONITORING\" # Critical: selects EKS Runtime Monitoring feature\n status = \"ENABLED\" # Critical: enables the feature to pass the check\n }\n}\n```" + "Terraform": "```hcl\nresource \"aws_guardduty_detector\" \"\" {\n enable = true\n}\n\nresource \"aws_guardduty_detector_feature\" \"\" {\n detector_id = aws_guardduty_detector..id\n name = \"EKS_RUNTIME_MONITORING\" # Critical: selects EKS Runtime Monitoring\n status = \"ENABLED\" # Critical: enables the feature\n\n additional_configuration {\n name = \"EKS_ADDON_MANAGEMENT\"\n status = \"ENABLED\"\n }\n}\n```" }, "Recommendation": { "Text": "- Enable **EKS Runtime Monitoring** with automated agent management across all accounts and clusters\n- Enforce **least privilege** for agents and segment cluster access\n- Integrate findings with response workflows and periodically verify runtime coverage", diff --git a/prowler/providers/aws/services/guardduty/guardduty_runtime_monitoring_enabled/__init__.py b/prowler/providers/aws/services/guardduty/guardduty_runtime_monitoring_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/guardduty/guardduty_runtime_monitoring_enabled/guardduty_runtime_monitoring_enabled.metadata.json b/prowler/providers/aws/services/guardduty/guardduty_runtime_monitoring_enabled/guardduty_runtime_monitoring_enabled.metadata.json new file mode 100644 index 0000000000..36f201d703 --- /dev/null +++ b/prowler/providers/aws/services/guardduty/guardduty_runtime_monitoring_enabled/guardduty_runtime_monitoring_enabled.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "aws", + "CheckID": "guardduty_runtime_monitoring_enabled", + "CheckTitle": "GuardDuty detector has Runtime Monitoring enabled", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices/Runtime Behavior Analysis", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" + ], + "ServiceName": "guardduty", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "AwsGuardDutyDetector", + "ResourceGroup": "security", + "Description": "GuardDuty detectors are evaluated for unified **Runtime Monitoring** being enabled. The configuration is at the detector level and relates to visibility into *process execution, file access, and network connections* on Amazon EC2 instances, Amazon ECS on AWS Fargate tasks, and Amazon EKS nodes and containers. The legacy EKS-only feature covers Amazon EKS alone and does not satisfy this check.", + "Risk": "Without **Runtime Monitoring**, on-host behavior of EC2, Fargate and EKS workloads is blind to detection. Adversaries can run malware or cryptominers, break out of containers, harvest credentials from instance metadata, tamper with workloads, or pivot to other services, degrading confidentiality, corrupting integrity, and exhausting resources (availability).", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/guardduty/latest/ug/runtime-monitoring.html", + "https://docs.aws.amazon.com/guardduty/latest/ug/runtime-monitoring-configuration.html", + "https://docs.aws.amazon.com/config/latest/developerguide/guardduty-runtime-monitoring-enabled.html", + "https://docs.aws.amazon.com/securityhub/latest/userguide/guardduty-controls.html#guardduty-11" + ], + "Remediation": { + "Code": { + "CLI": "aws guardduty update-detector --detector-id --features Name=RUNTIME_MONITORING,Status=ENABLED", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::GuardDuty::Detector\n Properties:\n Enable: true\n Features:\n - Name: RUNTIME_MONITORING # Critical: selects unified Runtime Monitoring, which covers EC2, ECS-Fargate and EKS\n Status: ENABLED # Critical: enables the feature to pass the check\n AdditionalConfiguration:\n - Name: EC2_AGENT_MANAGEMENT\n Status: ENABLED\n - Name: ECS_FARGATE_AGENT_MANAGEMENT\n Status: ENABLED\n - Name: EKS_ADDON_MANAGEMENT\n Status: ENABLED\n```", + "Other": "1. Open the AWS Console and go to Amazon GuardDuty\n2. In the left pane, select Protection plans > Runtime Monitoring\n3. Switch Runtime Monitoring to Enabled\n4. Enable automated agent configuration for Amazon EC2, AWS Fargate (Amazon ECS only) and Amazon EKS\n5. Click Save changes\n6. If you were using EKS Runtime Monitoring, migrate to Runtime Monitoring; the two features are mutually exclusive", + "Terraform": "```hcl\nresource \"aws_guardduty_detector_feature\" \"\" {\n detector_id = aws_guardduty_detector..id\n name = \"RUNTIME_MONITORING\" # Critical: unified feature covering EC2, ECS-Fargate and EKS\n status = \"ENABLED\" # Critical: enables the feature to pass the check\n\n additional_configuration {\n name = \"EC2_AGENT_MANAGEMENT\"\n status = \"ENABLED\"\n }\n\n additional_configuration {\n name = \"ECS_FARGATE_AGENT_MANAGEMENT\"\n status = \"ENABLED\"\n }\n\n additional_configuration {\n name = \"EKS_ADDON_MANAGEMENT\"\n status = \"ENABLED\"\n }\n}\n```" + }, + "Recommendation": { + "Text": "- Enable unified **Runtime Monitoring** with automated agent management for Amazon EC2, AWS Fargate (Amazon ECS only) and Amazon EKS across all accounts\n- Migrate from EKS Runtime Monitoring, which covers Amazon EKS only and is mutually exclusive with Runtime Monitoring\n- Review runtime coverage statistics rather than treating enablement as coverage, and route findings to Security Hub or EventBridge for response", + "Url": "https://hub.prowler.com/check/guardduty_runtime_monitoring_enabled" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/guardduty/guardduty_runtime_monitoring_enabled/guardduty_runtime_monitoring_enabled.py b/prowler/providers/aws/services/guardduty/guardduty_runtime_monitoring_enabled/guardduty_runtime_monitoring_enabled.py new file mode 100644 index 0000000000..64d86fc184 --- /dev/null +++ b/prowler/providers/aws/services/guardduty/guardduty_runtime_monitoring_enabled/guardduty_runtime_monitoring_enabled.py @@ -0,0 +1,75 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.guardduty.guardduty_client import guardduty_client + + +class guardduty_runtime_monitoring_enabled(Check): + """Ensure GuardDuty unified Runtime Monitoring is enabled on every active detector. + + Runtime Monitoring covers Amazon EC2 instances, Amazon ECS on AWS Fargate tasks + and Amazon EKS nodes and containers. Legacy EKS Runtime Monitoring covers Amazon + EKS alone, and its AdditionalConfiguration offers no EC2 or Fargate agent + management, so a detector running only the legacy feature has no runtime coverage + for EC2 or Fargate workloads and cannot PASS. The two features are mutually + exclusive at the API, so the FAIL message names the legacy case to point at + migration rather than at first-time enablement. That exclusivity is also why the + legacy verdict is reached before the unknown one: a legacy detector is precisely + the one whose GetDetector response carries no RUNTIME_MONITORING entry, so testing + for the unknown state first would report every legacy detector as undetermined. + + A detector whose own state could not be read is MANUAL rather than absent from the + report. Detector.status is True only when GetDetector returned ENABLED and stays + None both for a suspended detector and for a GetDetector call that failed, so those + two cannot be told apart and neither is a definite absence of runtime coverage. + Leaving such a detector out of the findings would leave its Region with nothing to + read at all, and an unreported Region reads as a compliant one. + + Regions with no detector at all are left to guardduty_is_enabled entirely, which is + also the check that owns the detector-level verdict. + + guardduty_eks_runtime_monitoring_enabled remains the EKS-scoped check. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Assess unified Runtime Monitoring on every GuardDuty detector in the account. + + Returns: + list[Check_Report_AWS]: one report per detector that exists. PASS when + unified Runtime Monitoring is enabled, FAIL when GuardDuty reported the + feature disabled or reported only the legacy EKS one, and MANUAL when + either the detector state or the feature itself was not reported and no + legacy coverage was reported either. + """ + findings = [] + for detector in guardduty_client.detectors: + if not detector.enabled_in_account: + continue + + report = Check_Report_AWS(metadata=self.metadata(), resource=detector) + report.status = "FAIL" + report.status_extended = f"GuardDuty detector {detector.id} does not have Runtime Monitoring enabled." + if not detector.status: + report.status = "MANUAL" + report.status_extended = f"GuardDuty detector {detector.id} is not enabled or could not be read, so Runtime Monitoring coverage could not be determined." + elif detector.runtime_monitoring is True: + report.status = "PASS" + report.status_extended = ( + f"GuardDuty detector {detector.id} has Runtime Monitoring enabled." + ) + elif detector.eks_runtime_monitoring: + # Ordered ahead of the unknown branch below because a detector running the + # legacy feature is the shape that omits RUNTIME_MONITORING entirely: the + # two are mutually exclusive at the API. eks_runtime_monitoring is set by + # either feature, but the PASS branch above already took the unified case, + # so reaching here means EKS_RUNTIME_MONITORING is what enabled it. That is + # a known absence of EC2 and Fargate coverage, not an unknown one. + report.status_extended = f"GuardDuty detector {detector.id} only has the legacy EKS Runtime Monitoring enabled, leaving Amazon EC2 instances and Amazon ECS on Fargate tasks without runtime coverage." + elif detector.runtime_monitoring is None: + # GetDetector did not return RUNTIME_MONITORING at all, which is not the + # same as returning it DISABLED: a Region that does not offer the unified + # feature, or a features array that could not be read, would otherwise be + # reported as a definite FAIL. No legacy coverage was reported either, so + # nothing is known about this detector's runtime coverage. + report.status = "MANUAL" + report.status_extended = f"GuardDuty detector {detector.id} did not report the Runtime Monitoring feature, so runtime coverage could not be determined; verify manually." + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/guardduty/guardduty_service.py b/prowler/providers/aws/services/guardduty/guardduty_service.py index ed575e6a4b..2a23145040 100644 --- a/prowler/providers/aws/services/guardduty/guardduty_service.py +++ b/prowler/providers/aws/services/guardduty/guardduty_service.py @@ -63,6 +63,15 @@ class GuardDuty(AWSService): ) def _get_detector(self, detector): + """Read a detector's status, data sources and features. + + A feature GuardDuty does not return is left as None rather than False, so a + Region that does not offer the feature stays distinguishable from one that + turned it off. + + Args: + detector: Detector object to populate in place. + """ logger.info("GuardDuty - getting detector info...") try: if detector.id and detector.enabled_in_account: @@ -108,11 +117,33 @@ class GuardDuty(AWSService): and feat.get("Status", "DISABLED") == "ENABLED" ): detector.lambda_protection = True - elif ( - feat.get("Name", "") == "EKS_RUNTIME_MONITORING" - and feat.get("Status", "DISABLED") == "ENABLED" + elif feat.get("Name", "") == "AI_PROTECTION": + # Recorded even when DISABLED, so a Region that offers AI + # Protection and turned it off stays distinguishable from one + # that never reports the feature. + detector.ai_protection = ( + feat.get("Status", "DISABLED") == "ENABLED" + ) + elif feat.get("Name", "") in ( + "EKS_RUNTIME_MONITORING", + "RUNTIME_MONITORING", ): - detector.eks_runtime_monitoring = True + enabled = feat.get("Status", "DISABLED") == "ENABLED" + # Unified Runtime Monitoring (RUNTIME_MONITORING) already + # includes threat detection for Amazon EKS resources and is + # mutually exclusive with EKS_RUNTIME_MONITORING, so either + # feature means the detector has EKS runtime coverage. + if enabled: + detector.eks_runtime_monitoring = True + if feat.get("Name", "") == "RUNTIME_MONITORING": + # Only the unified feature covers Amazon EC2 and Amazon + # ECS on Fargate, so it is tracked separately. Recorded even + # when DISABLED, for the same reason AI_PROTECTION above is: + # a Region that offers the feature and turned it off must + # stay distinguishable from one that never reported it. A + # plain bool cannot express that, and the check would report + # a definite FAIL on a Region that has no unified feature. + detector.runtime_monitoring = enabled except Exception as error: logger.error( @@ -345,8 +376,12 @@ class Detector(BaseModel): rds_protection: bool = False eks_audit_log_protection: bool = False eks_runtime_monitoring: bool = False + # None when GuardDuty did not return the feature: unknown, not disabled. + runtime_monitoring: Optional[bool] = None lambda_protection: bool = False ec2_malware_protection: bool = False + # None when GuardDuty did not return the feature: unknown, not disabled. + ai_protection: Optional[bool] = None # Organization configuration fields organization_auto_enable_members: str = "NONE" # NEW, ALL, or NONE organization_config_available: bool = False diff --git a/prowler/providers/aws/services/iam/iam_policy_no_agentcore_workload_access_token_wildcard/__init__.py b/prowler/providers/aws/services/iam/iam_policy_no_agentcore_workload_access_token_wildcard/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/iam/iam_policy_no_agentcore_workload_access_token_wildcard/iam_policy_no_agentcore_workload_access_token_wildcard.metadata.json b/prowler/providers/aws/services/iam/iam_policy_no_agentcore_workload_access_token_wildcard/iam_policy_no_agentcore_workload_access_token_wildcard.metadata.json new file mode 100644 index 0000000000..5693212ae3 --- /dev/null +++ b/prowler/providers/aws/services/iam/iam_policy_no_agentcore_workload_access_token_wildcard/iam_policy_no_agentcore_workload_access_token_wildcard.metadata.json @@ -0,0 +1,44 @@ +{ + "Provider": "aws", + "CheckID": "iam_policy_no_agentcore_workload_access_token_wildcard", + "CheckTitle": "Custom IAM policy scopes Bedrock AgentCore workload access token retrieval to workload identity ARNs", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "TTPs/Credential Access", + "Effects/Data Exposure" + ], + "ServiceName": "iam", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "AwsIamPolicy", + "ResourceGroup": "IAM", + "Description": "**Customer-managed IAM policies** are examined for `Allow` statements granting `bedrock-agentcore:GetWorkloadAccessToken`, `GetWorkloadAccessTokenForJWT` or `GetWorkloadAccessTokenForUserId` over resources that reach a workload identity other than the caller's own -- `*`, or an AgentCore ARN whose resource field wildcards past `workload-identity-directory`.", + "Risk": "A workload access token carries both user and agent identity and unlocks the outbound credential providers holding that user's stored credentials. `GetWorkloadAccessTokenForUserId` takes a caller-supplied user ID the platform does not verify, so AWS documents the IAM scope as the binding: with a wildcard resource, a compromised agent can mint tokens for **other users** of the same agent.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/get-workload-access-token.html", + "https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/identity.html", + "https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/agent-identity-directory.html", + "https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/identity-manage-agent-ids.html" + ], + "Remediation": { + "Code": { + "CLI": "aws iam create-policy-version --policy-arn --policy-document '{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"bedrock-agentcore:GetWorkloadAccessToken\"],\"Resource\":[\"arn:aws:bedrock-agentcore:::workload-identity-directory/default\",\"arn:aws:bedrock-agentcore:::workload-identity-directory/default/workload-identity/\"]}]}' --set-as-default", + "NativeIaC": "```yaml\n# CloudFormation: scope the token actions to this workload's identity\nResources:\n :\n Type: AWS::IAM::ManagedPolicy\n Properties:\n PolicyDocument:\n Version: '2012-10-17'\n Statement:\n - Effect: Allow\n Action:\n - bedrock-agentcore:GetWorkloadAccessToken\n - bedrock-agentcore:GetWorkloadAccessTokenForJWT\n Resource: # FIX: replace '*' with the workload identity this policy is for\n - !Sub 'arn:${AWS::Partition}:bedrock-agentcore:${AWS::Region}:${AWS::AccountId}:workload-identity-directory/default'\n - !Sub 'arn:${AWS::Partition}:bedrock-agentcore:${AWS::Region}:${AWS::AccountId}:workload-identity-directory/default/workload-identity/'\n # Where a JWT is always available, deny the unverified user-ID path outright\n - Effect: Deny\n Action: bedrock-agentcore:GetWorkloadAccessTokenForUserId\n Resource: !Sub 'arn:${AWS::Partition}:bedrock-agentcore:${AWS::Region}:${AWS::AccountId}:workload-identity-directory/default'\n```", + "Other": "1. In the AWS console, open IAM > Policies and select \n2. Choose Edit > JSON\n3. Find every statement whose Action includes bedrock-agentcore:GetWorkloadAccessToken, GetWorkloadAccessTokenForJWT or GetWorkloadAccessTokenForUserId\n4. Replace \"Resource\": \"*\" with the workload identity ARNs the holder legitimately acts for, for example arn:aws:bedrock-agentcore:::workload-identity-directory/default/workload-identity/\n5. If a JWT identifying the end user is always available, prefer GetWorkloadAccessTokenForJWT and add an explicit Deny for GetWorkloadAccessTokenForUserId\n6. Save as a new default version and re-run the check", + "Terraform": "```hcl\n# Scope the token actions to this workload's identity\nresource \"aws_iam_policy\" \"\" {\n name = \"\"\n policy = jsonencode({\n Version = \"2012-10-17\"\n Statement = [{\n Effect = \"Allow\"\n Action = [\n \"bedrock-agentcore:GetWorkloadAccessToken\",\n \"bedrock-agentcore:GetWorkloadAccessTokenForJWT\",\n ]\n # FIX: replace '*' with the workload identity this policy is for\n Resource = [\n \"arn:aws:bedrock-agentcore:${var.region}:${var.account_id}:workload-identity-directory/default\",\n \"arn:aws:bedrock-agentcore:${var.region}:${var.account_id}:workload-identity-directory/default/workload-identity/\",\n ]\n }]\n })\n}\n```" + }, + "Recommendation": { + "Text": "Scope the workload access token actions to the workload identity ARNs the policy holder acts for, never `*`. AWS states the security binding of `GetWorkloadAccessTokenForUserId` rests on IAM scope, since the platform treats the user ID as an opaque unverified string: **do not grant it broadly via managed policies or wildcard resource statements**. Prefer `GetWorkloadAccessTokenForJWT` and deny the user-ID path where a JWT is always available.", + "Url": "https://hub.prowler.com/check/iam_policy_no_agentcore_workload_access_token_wildcard" + } + }, + "Categories": [ + "identity-access", + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Scope is the customer-managed population, matching every other iam_policy_* check; inline policies are the subject of the iam_inline_policy_* checks.\n\nThree deliberate limits. A bare Action \"*\" is not read as a grant of these operations, because that is what the administrative-privileges checks report; the Action must carry the bedrock-agentcore prefix, wildcards within it included. The assertion is at ARN-type granularity rather than per workload identity: a resource confined to the workload-identity-directory namespace passes even with a wildcard inside it, because that is the scope the AgentCore console issues. A resource naming another AgentCore type passes for the opposite reason, since these actions accept no such resource and the grant reaches no workload identity.\n\nAn unconditional Deny of the same operation on Resource \"*\" clears the finding; a Deny scoped to one directory does not, because the Allow still reaches every other one." +} diff --git a/prowler/providers/aws/services/iam/iam_policy_no_agentcore_workload_access_token_wildcard/iam_policy_no_agentcore_workload_access_token_wildcard.py b/prowler/providers/aws/services/iam/iam_policy_no_agentcore_workload_access_token_wildcard/iam_policy_no_agentcore_workload_access_token_wildcard.py new file mode 100644 index 0000000000..f6bc2fa584 --- /dev/null +++ b/prowler/providers/aws/services/iam/iam_policy_no_agentcore_workload_access_token_wildcard/iam_policy_no_agentcore_workload_access_token_wildcard.py @@ -0,0 +1,426 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.iam.iam_client import iam_client +from prowler.providers.aws.services.iam.lib.policy import iam_pattern_matches + +AGENTCORE_SERVICE_PREFIX = "bedrock-agentcore" +# The three operations that hand a caller a workload access token. Confirmed against the +# bedrock-agentcore service model: GetWorkloadAccessToken issues a token for the calling +# workload, ForJWT exchanges a user JWT, ForUserId names the user directly. +WORKLOAD_ACCESS_TOKEN_OPERATIONS = ( + "GetWorkloadAccessToken", + "GetWorkloadAccessTokenForJWT", + "GetWorkloadAccessTokenForUserId", +) +# Every workload identity ARN sits under the workload-identity-directory resource path, both the +# directory itself and the workload-identity children beneath it. +WORKLOAD_IDENTITY_SEGMENT = "workload-identity-directory" +# The leading resource-path segment of every AgentCore type that is NOT a workload identity, from +# AWS's machine-readable service reference +# (servicereference.us-east-1.amazonaws.com/v1/bedrock-agentcore/bedrock-agentcore.json): 32 resource +# types collapsing to 23 distinct leading segments, of which workload-identity-directory is the only +# one hosting the two in-scope types. +# +# SEGMENTS, NOT NAMES, and that distinction is the whole point. Probing concrete resources -- say +# token-vault/default, gateway/my-gateway, runtime/my-runtime and a workload identity called +# another-workload -- makes their example NAMES load-bearing. A resource field keyed on any other name +# then matches none of them, and the check concludes it is confined to the workload-identity +# namespace: "...:*prod-*" reaches runtime/prod-chatbot, gateway/prod-chatbot-gw, +# memory/prod-chatbot-mem and two distinct workload identities, while reading as reaching none of +# them. Names cannot be enumerated -- the AgentCore devguide's own examples are prod-chatbot, +# dev-chatbot and customer-support-agent, and its own example policy wildcards on the name -- so no +# probe corpus can be completed. Resource TYPES can be enumerated, and are, above. +# +# This is the same correction already applied to the two short-ARN branches, which stopped pinning +# region, account and partition for exactly this reason; here it stops pinning the resource NAME. +NON_WORKLOAD_IDENTITY_SEGMENTS = ( + "ab-test", + "batch-evaluate", + "browser", + "browser-custom", + "browser-profile", + "capacity-provider", + "code-interpreter", + "code-interpreter-custom", + "configuration-bundle", + "dataset", + "evaluator", + "gateway", + "harness", + "memory", + "online-evaluation-config", + "payment-manager", + "policy-engine", + "recommendation", + "registry", + "runtime", + "token-vault", + "tool", +) + + +def _as_list(value) -> list: + """Normalize to list: None -> [], scalar -> [val], list -> list.""" + if value is None: + return [] + return value if isinstance(value, list) else [value] + + +def _statements(document: dict) -> list: + """Extract Statement, normalizing single-statement dict to list.""" + statements = document.get("Statement", []) + if not isinstance(statements, list): + statements = [statements] + return [statement for statement in statements if isinstance(statement, dict)] + + +def _covered_token_operations(statement: dict) -> set: + """Return the workload access token operations this statement's Action covers. + + Only actions that can name the bedrock-agentcore service are read, and the service field is + matched as an IAM pattern rather than compared literally -- `bedrock-*:GetWorkloadAccessToken` + reaches the operation, and an exact comparison read it as granting nothing at all. + + A bare "*" is deliberately not treated as a grant of these operations: a statement allowing + every action on every resource is what check_admin_access reports, and re-reporting it here + would duplicate the administrative-privileges checks rather than add a claim. The + `separator != ":"` guard is what preserves that, since "*" partitions to an empty separator. + """ + covered = set() + for action in _as_list(statement.get("Action")): + if not isinstance(action, str): + continue + service, separator, operation = action.strip().partition(":") + # A statement allowing EVERY action is the administrative-privileges checks' finding, + # whether it is spelled "*" or "*:*"; re-reporting it here would duplicate them. The + # separator test covers the bare "*", which partitions to an empty separator. + if separator != ":" or (service == "*" and operation == "*"): + continue + if not iam_pattern_matches(service, AGENTCORE_SERVICE_PREFIX): + continue + covered.update( + token_operation + for token_operation in WORKLOAD_ACCESS_TOKEN_OPERATIONS + if iam_pattern_matches(operation, token_operation) + ) + return covered + + +def _token_operations_removed_by(statement: dict) -> set: + """Return the token operations a DENY statement's Action removes. + + Separate from _covered_token_operations on purpose, because the bare-star exclusion that is + right on the Allow side inverts on the Deny side. There, skipping ``*`` and ``*:*`` avoids + duplicating the administrative-privileges checks; here it meant an unconditional Deny of EVERY + action credited nothing, so a policy that grants nothing at all was reported FAIL at high + severity -- contradicting this check's own published Notes, which say an unconditional Deny of + the operation on Resource "*" clears the finding. The Allow-side guard is deliberately left + alone: relaxing it would reverse the settled decision that admin-level grants belong to + check_admin_access. + + Deny expressed as NotAction is still NOT read, here or on the Allow side. Inverting it requires + resolving the whole action namespace, which is more than this check can claim; not crediting it + errs toward reporting rather than toward silence, so a policy denied that way may still FAIL. + """ + removed = set() + for action in _as_list(statement.get("Action")): + if not isinstance(action, str): + continue + service, separator, operation = action.strip().partition(":") + if separator != ":": + # A bare "*" denies every action, these three among them. + if service == "*": + removed.update(WORKLOAD_ACCESS_TOKEN_OPERATIONS) + continue + if not iam_pattern_matches(service, AGENTCORE_SERVICE_PREFIX): + continue + removed.update( + token_operation + for token_operation in WORKLOAD_ACCESS_TOKEN_OPERATIONS + if iam_pattern_matches(operation, token_operation) + ) + return removed + + +def _may_reach_segment(resource_field: str, segment: str) -> bool: + """Return True if this resource field could name a resource whose path starts with ``segment``. + + Decided from the field's own shape rather than by matching example resources, so no resource NAME + is load-bearing. The two wildcards are NOT interchangeable and that distinction is the whole + function: ``*`` spans any run of characters including none, while ``?`` consumes EXACTLY ONE. So + the field's HEAD -- everything before its first ``*`` -- is a fixed-length template in which each + ``?`` stands for one unknown character, and every string the field matches begins with something + that template accepts. + + Compare the template to the segment position by position, then decide who supplies the remainder: + + - A position where the template holds a literal that differs from the segment's character rules + the segment out entirely, however long the field is. + - If the template is at least as long as the segment and no position disagreed, the segment is + covered, so the field may reach it. + - If the template is SHORTER, only a ``*`` can supply what is missing. Without one the field has + a fixed length too short to contain the segment, so it reaches nothing under it. + + THE HEAD IS CUT AT ``*`` ONLY, NEVER AT ``?``, and the two must not be treated alike. ``?`` + consumes exactly one character, so it belongs to the fixed-length head and is skipped + position-by-position above; ``*`` spans an unbounded run, so it terminates the head. Cutting at + ``?`` as well empties the head of any ``?``-leading field, an empty head is compatible with every + segment, and the field then reads as reaching all 22 of them: ``?orkload-identity-directory/*`` + would be reported while its byte-neighbour ``w?rkload-identity-directory/*`` is not. Measured + against an exact oracle over 1345 fields, that spelling costs 265 false FAILs. + + THE COMPLEMENTARY ERROR IS TO RETURN False ON AN EMPTY HEAD, and it is worse. That repairs the + ``?`` rows and simultaneously turns bare ``*`` and ``*prod-*`` into "reaches nothing", which + reinstates the false PASS this segment test exists to remove: 10 oracle mismatches against 0 for + the rule as written. So the decision rests on whether a ``*`` is PRESENT, not on whether the head + is empty, because ``*`` must keep absorbing everything. + + Deliberately errs toward True on a coarse pair such as head ``browser-custom/x`` against segment + ``browser``: over-estimating reach can only move a verdict toward reporting, and the reach test + that follows still decides the workload-identity question on its own terms. + """ + head, spans = resource_field.split("*", 1)[0], "*" in resource_field + for index in range(min(len(head), len(segment))): + if head[index] != "?" and head[index] != segment[index]: + return False + if len(head) >= len(segment): + return True + return spans + + +def _reaches_other_workload_identities(resource: str) -> bool: + """Return True if this resource lets the token actions name a workload identity that + is not the caller's own. + + A resource confined to the workload-identity-directory namespace is accepted, wildcards + within it included: the AgentCore console itself issues that scope, and the ARN type is + the granularity this check asserts. A resource of some other AgentCore type -- a token + vault, a gateway -- is accepted too, but for the opposite reason: the token actions + accept no such resource, so the grant reaches no workload identity at all. + + EVERY field of the pattern is matched as an IAM pattern, the first one included, because a + leading star matches "arn" as surely as it matches anything else. Comparing that field to the + literal "arn" instead would read ``*:aws:bedrock-agentcore:us-east-1:123456789012:*`` and + ``*:*:*:*:*:*`` as naming no workload identity, while their correctly spelled six-field + equivalent names every one. + + A pattern with fewer than six fields reaches a workload identity when a star in its LAST + spelled-out field can span the fields it never spells out, because IAM wildcards match the + colon. That question is answered structurally rather than by probing a concrete ARN, and + deliberately so: matching against one ``us-east-1``/``123456789012`` ARN makes the region, + account and partition load-bearing, so ``arn:aws:bedrock-agentcore:us-west-2:*`` reads as + reaching nothing while the byte-identical ``us-east-1`` spelling is reported. No finite probe + corpus fixes that -- an account PREFIX such as + ``arn:aws:bedrock-agentcore:us-east-1:111122223333*`` has nothing to enumerate. What the fields + it does spell out must still do is name an ARN at all: ``arn:aws:s3:*`` is short and starred but + names another service. + + ``arn:aws:bedrock-agentcore`` and ``arn:aws:bedrock-agentcore:us-east-1`` carry no star, so they + match no ARN and reach nothing. The star is what separates them from the cases above, not the + length. + + THE RESOURCE FIELD IS DECIDED STRUCTURALLY TOO, by ``_may_reach_segment`` against the enumerable + list of AgentCore resource-path segments, and NOT by comparison against concrete example + resources. The tempting argument for a small probe corpus is that the namespace test intercepts + everything confined to workload-identity-directory before this one runs, so a single probe cannot + produce a false verdict. That premise does not hold: + + ``arn:aws:bedrock-agentcore:us-east-1:123456789012:*prod-*`` is not confined to the namespace, yet + it matches none of a four-resource probe corpus, so the namespace test intercepts it anyway and + clears a statement reaching ``runtime/prod-chatbot``, ``gateway/prod-chatbot-gw``, + ``memory/prod-chatbot-mem``, a token vault, a custom browser, and two distinct workload + identities. The pair that shows such a corpus has no defensible boundary: resource field ``*`` is + reported while ``*prod-*`` is not, and no rule stated anywhere separates them except "matches one + of four example NAMES", which is an artifact of the corpus rather than a property of IAM. + + So the lesson is the one the short-ARN branches already record, one level down: a probe corpus can + only decide a question whose answer space it enumerates. Regions, accounts and partitions could + not be enumerated there; resource NAMES cannot be enumerated here, since AgentCore creates + identities named after the runtime or gateway that made them. Resource TYPES can be, so the test + is built on those. + """ + resource = resource.strip() + if resource == "*": + return True + arn_fields = resource.split(":", 5) + if len(arn_fields) < 6: + if "*" not in arn_fields[-1]: + return False + if not iam_pattern_matches(arn_fields[0], "arn"): + return False + return len(arn_fields) < 3 or iam_pattern_matches( + arn_fields[2], AGENTCORE_SERVICE_PREFIX + ) + if not iam_pattern_matches(arn_fields[0], "arn"): + return False + if not iam_pattern_matches(arn_fields[2], AGENTCORE_SERVICE_PREFIX): + return False + resource_field = arn_fields[5] + # Confined to the workload-identity namespace when it can reach NO resource of another AgentCore + # type. The startswith test this replaced asked whether the field begins with the literal + # namespace prefix, which is a different question and got the ordering backwards: the confined + # workload-identity-* was reported while the strictly broader workload-identity-directory* -- + # whose reach is a superset of it -- was accepted. + # + # BOTH TESTS ARE STRUCTURAL, and they have to stay that way together. Comparing a field against + # concrete example resources instead lets a field keyed on any name those examples do not use + # satisfy both at once: "*prod-*" matches none of the four non-workload-identity probes, so this + # branch would call it confined, and it also misses the single another-workload probe below, so + # neither test would report it. Making only one of them structural leaves the verdict unchanged, + # so do not read this branch as a guard for the one below -- it does not intercept everything + # confined to the namespace, and a pattern that is not confined at all can reach it. + if not any( + _may_reach_segment(resource_field, segment) + for segment in NON_WORKLOAD_IDENTITY_SEGMENTS + ): + return False + return _may_reach_segment(resource_field, WORKLOAD_IDENTITY_SEGMENT) + + +def _is_workload_identity_scoped(statement: dict) -> bool: + """Return True if no resource the statement names reaches another workload identity. + + A statement using NotResource names no resource at all -- it grants everything except + an excluded list -- so it is not scoped. + + Only Resource and NotResource are read. An Allow-side Condition is NOT evaluated, so a + statement narrowed solely by one -- aws:ResourceTag is a condition key on both workload + identity resource types -- is still reported. That is deliberate conservatism, the mirror + of the Deny-side decision below: a condition is not credited with confining a grant any + more than it is credited with removing one. What it costs is that the finding may name a + statement an unread condition already scopes, which is why the FAIL text claims only that + the RESOURCES do not confine it. + """ + resources = _as_list(statement.get("Resource")) + if not resources: + return "NotResource" not in statement + return not any( + isinstance(resource, str) and _reaches_other_workload_identities(resource) + for resource in resources + ) + + +def _denied_token_operations(document: dict) -> set: + """Return the token operations an unconditional Deny removes across all resources. + + A conditional Deny is not counted: it only applies when the condition holds, so it + does not take the permission away from the request the Allow statement grants. + """ + denied = set() + for statement in _statements(document): + if statement.get("Effect") != "Deny" or statement.get("Condition"): + continue + if any( + isinstance(resource, str) and resource.strip() == "*" + for resource in _as_list(statement.get("Resource")) + ): + denied.update(_token_operations_removed_by(statement)) + return denied + + +def _has_unevaluated_notaction(document: dict) -> bool: + """True if an Allow statement expresses its actions as NotAction. + + NotAction under Effect Allow grants everything EXCEPT what it lists, so a policy using + it can grant the very actions this check looks for while carrying no Action key at all. + Reading only Action would find nothing and report a clean policy. Inverting NotAction + correctly means resolving it against the full action namespace and its interaction with + Resource and NotResource, which is more than this check can honestly claim to do -- so + the statement is declared unevaluated rather than guessed at. + """ + for statement in _statements(document): + if statement.get("Effect") == "Allow" and "NotAction" in statement: + return True + return False + + +class iam_policy_no_agentcore_workload_access_token_wildcard(Check): + """Check whether a customer-managed policy scopes AgentCore workload access token retrieval. + + A workload access token identifies the agent to AgentCore, so a policy granting the retrieval + operations on every resource lets its holder obtain a token for any workload identity and act as + that agent. FAIL when the grant reaches workload identities beyond a named directory; PASS when + the resource is confined to the workload-identity-directory namespace, when it names an AgentCore + type these actions do not accept, or when an unconditional Deny on ``Resource: "*"`` clears it; + MANUAL when the policy document could not be read or expresses a shape this check does not + evaluate. + + Caveats: + Customer-managed policies only. The assertion is at ARN-type granularity rather than per + workload identity, because a wildcard inside the directory namespace is the scope the + AgentCore console itself issues. A bare ``Action: "*"`` is left to the + administrative-privileges checks. + """ + + def execute(self) -> Check_Report_AWS: + """Flag policies granting token ops beyond caller's workload ID. + + MANUAL is used deliberately below, where the document could not be read or expresses a shape + this check does not evaluate. An unread document must not report as compliant: the grant it + might contain is precisely what is being looked for, so PASS there would assert something never + established. This is not off-contract -- 110 upstream checks emit MANUAL and + `lib/check/models.py` places no restriction on it. + + THE COST, recorded so it is not rediscovered: `lib/outputs/asff/asff.py` SKIPS findings whose + status is MANUAL, because MANUAL is not a valid Security Hub compliance state. A Security Hub + consumer therefore sees NOTHING for an unreadable policy, and absence there reads as + compliance. CSV and OCSF keep the status, so the information survives in those outputs. That is + a gap in one output format, not a reason to report an unread document as PASS or FAIL. + """ + findings = [] + for policy in iam_client.policies.values(): + # Only customer-managed policies: the inline population is a separate check, + # and an AWS-managed policy cannot be edited to remediate a finding. + if policy.type != "Custom": + continue + if not policy.attached and not iam_client.provider.scan_unused_services: + continue + + report = Check_Report_AWS(metadata=self.metadata(), resource=policy) + report.region = iam_client.region + + if not policy.document: + report.status = "MANUAL" + report.status_extended = ( + f"Custom Policy {policy.name} could not be evaluated because its " + "policy document was not retrieved." + ) + findings.append(report) + continue + + if _has_unevaluated_notaction(policy.document): + report.status = "MANUAL" + report.status_extended = ( + f"Custom Policy {policy.name} expresses an Allow statement with " + "NotAction, which this check does not evaluate, so its effective grants " + "could not be determined; review it manually." + ) + findings.append(report) + continue + + denied = _denied_token_operations(policy.document) + unscoped = set() + for statement in _statements(policy.document): + if statement.get("Effect") != "Allow": + continue + if _is_workload_identity_scoped(statement): + continue + unscoped.update(_covered_token_operations(statement) - denied) + + if unscoped: + report.status = "FAIL" + report.status_extended = ( + f"Custom Policy {policy.name} allows " + f"{', '.join(sorted(AGENTCORE_SERVICE_PREFIX + ':' + operation for operation in unscoped))} " + "on resources outside a workload identity ARN, so its resources do not " + "confine token retrieval to the workload's own identity; conditions on " + "the statement are not evaluated." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Custom Policy {policy.name} does not allow AgentCore workload access " + "token retrieval outside a workload identity ARN." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/iam/iam_policy_passrole_to_bedrock_agentcore_restricted/__init__.py b/prowler/providers/aws/services/iam/iam_policy_passrole_to_bedrock_agentcore_restricted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/iam/iam_policy_passrole_to_bedrock_agentcore_restricted/iam_policy_passrole_to_bedrock_agentcore_restricted.metadata.json b/prowler/providers/aws/services/iam/iam_policy_passrole_to_bedrock_agentcore_restricted/iam_policy_passrole_to_bedrock_agentcore_restricted.metadata.json new file mode 100644 index 0000000000..21c86ab8dd --- /dev/null +++ b/prowler/providers/aws/services/iam/iam_policy_passrole_to_bedrock_agentcore_restricted/iam_policy_passrole_to_bedrock_agentcore_restricted.metadata.json @@ -0,0 +1,45 @@ +{ + "Provider": "aws", + "CheckID": "iam_policy_passrole_to_bedrock_agentcore_restricted", + "CheckTitle": "Custom IAM policy restricts iam:PassRole to Bedrock AgentCore to specific roles", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "TTPs/Privilege Escalation" + ], + "ServiceName": "iam", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "AwsIamPolicy", + "ResourceGroup": "IAM", + "Description": "**Customer-managed IAM policies** are examined for `Allow` statements granting `iam:PassRole` over every role -- `Resource` `*`, or an IAM ARN whose resource field is nothing but wildcards -- where the passed role can reach **Bedrock AgentCore**: the statement pins `iam:PassedToService` to an AgentCore principal, or sets no such condition while the policy allows an AgentCore action.", + "Risk": "AgentCore runtimes, gateways, code interpreters, browsers and evaluation configs all run under a role the caller names in the create call.\n\nWith `iam:PassRole` unbounded, any principal holding the policy can hand AgentCore **any role in the account**, an administrator role included, then reach that role's permissions through agent code it controls. The role itself needs no change.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/evaluations-prerequisites.html", + "https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-permissions.html", + "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_passrole.html", + "https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_iam-condition-keys.html" + ], + "Remediation": { + "Code": { + "CLI": "aws iam create-policy-version --policy-arn --policy-document '{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"iam:PassRole\"],\"Resource\":\"arn:aws:iam:::role/*\",\"Condition\":{\"StringEquals\":{\"iam:PassedToService\":\"bedrock-agentcore.amazonaws.com\"}}}]}' --set-as-default", + "NativeIaC": "```yaml\n# CloudFormation: name the roles AgentCore may be handed\nResources:\n :\n Type: AWS::IAM::ManagedPolicy\n Properties:\n PolicyDocument:\n Version: '2012-10-17'\n Statement:\n - Effect: Allow\n Action: iam:PassRole\n # FIX: replace '*' with the execution roles AgentCore is meant to run as.\n # A name prefix is enough -- this is the scope AWS's own AgentCore\n # Evaluations reference policy uses.\n Resource: !Sub 'arn:${AWS::Partition}:iam::${AWS::AccountId}:role/*'\n Condition:\n StringEquals:\n iam:PassedToService: bedrock-agentcore.amazonaws.com\n```", + "Other": "1. In the AWS console, open IAM > Policies and select \n2. Choose Edit > JSON\n3. Find every statement whose Action includes iam:PassRole (or iam:* / iam:Pass*) with \"Resource\": \"*\" or an IAM ARN such as arn:aws:iam:::role/*\n4. Replace that resource with the AgentCore execution roles the holder should be able to pass -- a role ARN, or a role-name prefix such as arn:aws:iam:::role/AgentCoreEvaluationRole*\n5. Add Condition StringEquals iam:PassedToService = bedrock-agentcore.amazonaws.com so the roles cannot be handed to any other service\n6. Save as a new default version and re-run the check", + "Terraform": "```hcl\n# Name the roles AgentCore may be handed\nresource \"aws_iam_policy\" \"\" {\n name = \"\"\n policy = jsonencode({\n Version = \"2012-10-17\"\n Statement = [{\n Effect = \"Allow\"\n Action = [\"iam:PassRole\"]\n # FIX: replace '*' with the execution roles AgentCore is meant to run as\n Resource = \"arn:aws:iam::${var.account_id}:role/*\"\n Condition = {\n StringEquals = { \"iam:PassedToService\" = \"bedrock-agentcore.amazonaws.com\" }\n }\n }]\n })\n}\n```" + }, + "Recommendation": { + "Text": "Name the roles that may be passed instead of allowing every role. AWS's own AgentCore Evaluations reference policy shows the shape: `iam:PassRole` on `arn:aws:iam::*:role/AgentCoreEvaluationRole*` under `StringEquals iam:PassedToService = bedrock-agentcore.amazonaws.com`. A role-name prefix satisfies this check; `*` and `role/*` do not. Keep the condition as well, so the same roles cannot be handed to another service.", + "Url": "https://hub.prowler.com/check/iam_policy_passrole_to_bedrock_agentcore_restricted" + } + }, + "Categories": [ + "identity-access", + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [ + "iam_policy_allows_privilege_escalation" + ], + "Notes": "Companion to iam_policy_allows_privilege_escalation, which reports a full AgentCore create-and-invoke action set but evaluates Action alone, so it reports that combination whatever the PassRole scope and nothing when part of it is absent. This check asserts what that leaves open, how far the PassRole grant reaches, and needs only one AgentCore action beside it.\n\nScope is the customer-managed population. A statement pinning iam:PassedToService to another service is out of scope, and a bare Action \"*\" counts as neither the grant nor the AgentCore action, so administrator policies are left to the administrative-privileges checks.\n\nA resource names every role when no role name escapes it, which is broader than a field of wildcards alone: role/?* and *role* both qualify, as does an ARN whose star spans the account and resource fields. A bounded set passes, since role/? names single-character roles only and role/AgentCoreEvaluationRole* is the scope AWS's own AgentCore Evaluations reference policy uses." +} diff --git a/prowler/providers/aws/services/iam/iam_policy_passrole_to_bedrock_agentcore_restricted/iam_policy_passrole_to_bedrock_agentcore_restricted.py b/prowler/providers/aws/services/iam/iam_policy_passrole_to_bedrock_agentcore_restricted/iam_policy_passrole_to_bedrock_agentcore_restricted.py new file mode 100644 index 0000000000..112db48d18 --- /dev/null +++ b/prowler/providers/aws/services/iam/iam_policy_passrole_to_bedrock_agentcore_restricted/iam_policy_passrole_to_bedrock_agentcore_restricted.py @@ -0,0 +1,606 @@ +import re + +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.iam.iam_client import iam_client +from prowler.providers.aws.services.iam.lib.policy import iam_pattern_matches + +AGENTCORE_SERVICE_PREFIX = "bedrock-agentcore" +AGENTCORE_SERVICE_PRINCIPAL = "bedrock-agentcore.amazonaws.com" +# Bedrock AgentCore also reaches IAM through subdomain principals such as +# runtime-identity.bedrock-agentcore.amazonaws.com, so a literal value in that family +# pins the statement to AgentCore just as the base principal does. +AGENTCORE_PRINCIPAL_FAMILY_PATTERN = re.compile( + rf"^([a-z0-9-]+\.)?{AGENTCORE_SERVICE_PREFIX}(-[a-z0-9-]+)?\.amazonaws\.com$", + re.IGNORECASE, +) +# Concrete principals used to ask whether a condition value, READ AS AN IAM PATTERN, covers one of +# them. That is a different question from the regex above, which asks whether the value IS a family +# member, and asking only the second let a wildcard covering the family out of scope entirely. Both +# are needed: the regex catches a literal subdomain nobody enumerated here, and the probes catch a +# pattern that names no principal literally while reaching several. +AGENTCORE_PRINCIPAL_PROBES = ( + AGENTCORE_SERVICE_PRINCIPAL, + f"runtime-identity.{AGENTCORE_SERVICE_PRINCIPAL}", +) +# Two role names used to ask whether a resource field names EVERY role rather than some of them: +# the shortest a role name can be, and the longest. Only "*" can span an arbitrary run of +# characters -- "?" matches exactly one -- so a pattern built from literals and "?" alone covers a +# bounded set of names and cannot match both probes, while any pattern that does match both leaves +# no role name outside it. +_SHORTEST_ROLE_NAME = "role/a" +_LONGEST_ROLE_NAME = "role/" + "r0le-name-" * 6 + "abcd" # 64 chars, the IAM maximum +EVERY_ROLE_RESOURCE_PROBES = (_SHORTEST_ROLE_NAME, _LONGEST_ROLE_NAME) +# Operators that COMPARE the request's iam:PassedToService against the statement's own values, so a +# value under one of them names a service this statement can hand a role to. An allow-list, because +# the deny-list this replaced -- two substring tests for "not" and "ifexists" -- dropped the entire +# condition on a one-word operator change, and a dropped condition fell through to the document-wide +# fallback in _targets_agentcore as though the statement pinned nothing at all. +# +# *IfExists and ForAllValues ARE included, unlike _RESTRICTIVE_ATTESTATION_OPERATORS in +# kms/lib/enclave.py which rejects both as vacuous-true when the key is absent. The difference is the +# question being asked: this function asks which services a statement NAMES, not whether the +# statement is restrictive. A value is named whether or not the operator would also admit a request +# that omits the key. +# +# The ARN operators are DELIBERATELY absent, which is why this list is shorter than the trust check's +# and not an oversight in it. iam:PassedToService is a STRING-typed key holding a service principal, +# and AWS documents it as working with the string operators; an ARN operator on it cannot compare +# meaningfully. The trust check needs ArnEquals and ArnLike because aws:SourceArn is ARN-typed. Two +# allow-lists differing by the TYPE of the key they read is correct; differing for no stated reason is +# what gets flagged, so the reason is here. +# +# Consequence worth naming: ArnLikeIfExists on iam:PassedToService contributes no value, so the +# statement takes the no-pin path and reaches every service -- the same route as carrying no condition +# at all. It still FAILs beside an AgentCore action, by that route rather than by being read as a pin. +_PASSED_TO_SERVICE_OPERATORS = frozenset( + f"{qualifier}{operator}{suffix}".lower() + for qualifier in ("", "ForAnyValue:", "ForAllValues:") + for operator in ("StringEquals", "StringEqualsIgnoreCase", "StringLike") + for suffix in ("", "IfExists") +) + + +def _as_list(value) -> list: + """Normalize to list: None -> [], scalar -> [val], list -> list.""" + if value is None: + return [] + return value if isinstance(value, list) else [value] + + +def _statements(document: dict) -> list: + """Extract Statement, normalizing single-statement dict to list.""" + statements = document.get("Statement", []) + if not isinstance(statements, list): + statements = [statements] + return [statement for statement in statements if isinstance(statement, dict)] + + +def _covers_passrole(statement: dict) -> bool: + """Return True if the statement's Action covers iam:PassRole. + + The service field is matched as an IAM pattern rather than compared literally, so + `*:PassRole` is read as covering it. A bare "*" still does not count: a statement allowing + every action on every resource is what the administrative-privileges checks report, and + re-reporting it here would duplicate them instead of adding a claim. The `separator != ":"` + guard is what preserves that, since "*" partitions to an empty separator. + """ + for action in _as_list(statement.get("Action")): + if not isinstance(action, str): + continue + service, separator, operation = action.strip().partition(":") + # A statement allowing EVERY action is the administrative-privileges checks' finding, + # whether it is spelled "*" or "*:*"; the separator test covers the bare "*". + if separator != ":" or (service == "*" and operation == "*"): + continue + if not iam_pattern_matches(service, "iam"): + continue + if iam_pattern_matches(operation, "PassRole"): + return True + return False + + +def _grants_agentcore_action(document: dict) -> bool: + """Return True if the policy allows at least one bedrock-agentcore action. + + This is what puts an otherwise service-agnostic PassRole grant in scope: the same + policy can both create an AgentCore resource and choose the role it runs as. A bare + "*" is again excluded, so an administrator policy is not pulled in on that basis. + + The service field is matched as an IAM pattern, as it is everywhere else these checks read + one. It matters most here: ``bedrock-*:CreateAgentRuntime`` is a plausible thing to write, since + one prefix covers bedrock and bedrock-agentcore together. A literal comparison would read it as + no AgentCore reach, which takes an unpinned PassRole grant beside it out of scope entirely and + clears the policy. + """ + for statement in _statements(document): + if statement.get("Effect") != "Allow": + continue + for action in _as_list(statement.get("Action")): + if not isinstance(action, str): + continue + service, separator, _ = action.strip().partition(":") + if separator == ":" and iam_pattern_matches( + service, AGENTCORE_SERVICE_PREFIX + ): + return True + return False + + +def _passed_to_service_values(statement: dict) -> list: + """Collect the services a statement's iam:PassedToService condition names. + + Read through the allow-list above rather than by rejecting operator names. An operator this code + does not recognise must never be silently skipped, because ``_targets_agentcore`` treats "no + values" as "reaches every service" and then consults the whole document. So a skipped condition + inverts the verdict in BOTH directions, on nothing more than a one-word change of operator: + + - ``StringEqualsIfExists`` naming AgentCore, on ``Resource: "*"``, yields no values, so a policy + carrying no other AgentCore action falls out of scope entirely -- a PassRole grant on every + role in the account, cleared by a high-severity privilege-escalation check. + - ``StringEqualsIfExists`` naming another service yields no values too, so the document-wide + fallback pulls the statement back in beside any AgentCore action and reports it, when a + statement pinned to sagemaker is out of scope by the same rule spelled ``StringEquals``. + + A negated operator is deliberately not read: it names the services the statement will NOT pass + to, and the set it does reach is everything else, which is what "no values" already means here. + """ + values = [] + condition = statement.get("Condition", {}) + if not isinstance(condition, dict): + return values + for operator, block in condition.items(): + if not isinstance(operator, str) or not isinstance(block, dict): + continue + lowered_operator = operator.lower() + if lowered_operator not in _PASSED_TO_SERVICE_OPERATORS: + continue + for key, value in block.items(): + if isinstance(key, str) and key.lower() == "iam:passedtoservice": + values.extend(_as_list(value)) + return values + + +def _null_guarded_keys(condition: dict) -> set: + """Return the lowercased condition keys a ``Null: "false"`` test forces to be present. + + The helper the trust check in this PR defines, for the same reason, and byte-identical to it + EXCEPT for the value type read -- see below, and see that file's docstring for the other half. + Both spellings are handled, the scalar and the list, as kms/lib/enclave.py does. + + A JSON ``false`` counts as well as the string ``"false"``, because on THIS check's surface IAM + stores and returns both. Measured on a customer-managed policy, which is exactly this check's + population: ``create_policy`` with ``{"Null": {"iam:PassedToService": false}}`` is accepted and + ``get_policy_version`` returns a Python ``bool``, unconverted. Reading only the string leaves the + guard invisible, and the consequence is a false report on AWS's own prescribed hardening: a pin + AWS's simulator holds to one service -- absent key ``implicitDeny``, AgentCore ``implicitDeny`` + -- would be reported as passing every role to AgentCore. + + The trust sibling stays string-only ON PURPOSE, because a trust policy normalizes its scalars + and no bool can reach it: the divergence is measured, not drift. + + This also DIVERGES from kms/lib/enclave.py deliberately: that copy tests ``isinstance(value, + str)`` only and carries the same blind spot. Diverging toward the correct reading rather than + inheriting the house copy's defect. + + EVERY value must be ``false``, not merely one of them, because IAM ORs the values inside a single + condition operator. ``Null: {key: ["true","false"]}`` therefore means "key absent OR key present", + which is always true and binds NOTHING, yet reading it with ``any`` credited it as a guard and + rescued a defeasible pin -- so ADDING the word ``true`` to a guard list improved the score. + Measured on IAM's own evaluator with the key omitted: ``allowed`` for ``["true","false"]`` and + ``["false","true"]``, indistinguishable from carrying no Null block, against ``implicitDeny`` for + ``"false"``, ``["false"]`` and ``["false","false"]``. Reachable: ``create_policy`` stores a + multi-value list and ``get_policy_version`` returns it unchanged, and the trust surface preserves + a multi-element list too even though it collapses a single-element one to a scalar. + + The ``candidates and`` guard is not decoration: ``all()`` over an empty list is True, so an empty + value list would otherwise be read as the strongest possible guard. + + ``0`` is NOT a guard, and it is the VALUE comparison that excludes it, not the type test: + ``str(0)`` is ``"0"``, which is simply not ``"false"``. The ``isinstance`` merely narrows the + accepted types to the two JSON scalars IAM actually returns here. Spelled out because the + tempting formulation is the broken one -- ``not candidate`` or ``candidate is False`` reads + ``0``, ``""``, ``None`` and ``[]`` as guards, since ``isinstance(False, int)`` is True in Python + and falsiness is not the question being asked. + + ACCESS ANALYZER DOES NOT CORROBORATE THIS BOUNDARY, so do not cite it as support. Measured: it + reports TYPE_MISMATCH_BOOLEAN for ``"FALSE"`` and for ``" false "``, both of which this helper + CREDITS, as well as for ``0``, which it does not. Its type-checking is therefore stricter than + this helper's casing tolerance in one direction and looser in the other, and the boundary drawn + here is this check's own decision rather than an external one. Over-recognising is the dangerous + direction, because crediting a guard turns a FAIL into a PASS. + """ + guarded = set() + for operator, block in condition.items(): + if not isinstance(operator, str) or operator.lower() != "null": + continue + if not isinstance(block, dict): + continue + for key, value in block.items(): + if not isinstance(key, str): + continue + candidates = value if isinstance(value, list) else [value] + if candidates and all( + isinstance(candidate, (str, bool)) + and str(candidate).strip().lower() == "false" + for candidate in candidates + ): + guarded.add(key.lower()) + return guarded + + +def _passed_to_service_pin_is_defeasible(statement: dict) -> bool: + """Return True if every operator naming iam:PassedToService can be skipped by the caller. + + An *IfExists operator is not evaluated when the request omits the key, and ForAllValues is + vacuous-true for an absent key -- kms/lib/enclave.py records these as the same trap. So a + statement whose only pin is one of those reaches every service as well as the one it names, + and cannot be treated as confined to it. + + UNLESS the same statement carries ``Null: "false"`` on the same key, which forces the key to be + present and removes the skip. Reading that guard is what stops the check penalising the spelling + AWS prescribes -- "You should always include the Null condition operator ... with a false value". + Without it the hardened form scores worse than the plain one, which is backwards: a caller cannot + omit a key the guard requires, so the guard can only narrow the grant. + + Conditions are ANDed, so one non-defeasible operator naming the key holds the request to that + key's values whatever else the statement carries; the pin is defeasible only if all of them are. + """ + defeasible = [] + condition = statement.get("Condition", {}) + if not isinstance(condition, dict): + return False + guarded = "iam:passedtoservice" in _null_guarded_keys(condition) + for operator, block in condition.items(): + if not isinstance(operator, str) or not isinstance(block, dict): + continue + lowered_operator = operator.lower() + if lowered_operator not in _PASSED_TO_SERVICE_OPERATORS: + continue + if not any( + isinstance(key, str) and key.lower() == "iam:passedtoservice" + for key in block + ): + continue + defeasible.append( + ( + lowered_operator.endswith("ifexists") + or lowered_operator.startswith("forallvalues:") + ) + and not guarded + ) + return bool(defeasible) and all(defeasible) + + +def _names_agentcore(values: list) -> bool: + """Return True if any value can name an AgentCore service principal. + + Two questions, and asking only the second was a false PASS on the exact grant this check exists + to catch. Probing the value as a pattern against the ONE base principal, plus a regex asking + whether the value IS a family member, left every wildcard that COVERS the family unrecognised: + + *.bedrock-agentcore.amazonaws.com covers 2 known principals read as another service + *-identity.bedrock-agentcore.amazonaws.com covers runtime-identity read as another service + runtime-identity.* covers runtime-identity read as another service + *.bedrock-agentcore.* covers 2 read as another service + + Read that way each would pin iam:PassedToService to something other than AgentCore, taking the + statement out of scope -- while both the narrower literal and the no-condition case are reported. + Broadening the grant would flip the verdict the safe way round, which is the shape that never + self-corrects. + + So the value is now matched as a pattern against several concrete principals, not one, and a + literal outside that list is still caught by the family regex. ``?`` covers nothing here on + purpose: it matches exactly one character and no principal has a single-character subdomain -- + that ``?`` cannot match zero characters is pinned by iam/lib/policy_test.py, not assumed here. + """ + return any( + isinstance(value, str) + and ( + AGENTCORE_PRINCIPAL_FAMILY_PATTERN.match(value.strip()) + or any( + iam_pattern_matches(value, probe) + for probe in AGENTCORE_PRINCIPAL_PROBES + ) + ) + for value in values + ) + + +def _targets_agentcore(statement: dict, document: dict) -> bool: + """Return True if the statement can hand a role to Bedrock AgentCore. + + Three cases, in the order they are decided: + + 1. A condition NAMES AgentCore. The statement is in scope on its own terms, under any operator + that compares the key, *IfExists included. Dropping a spelling before its value is read is + what would clear a PassRole grant on every role in the account: with no values the statement + looks unpinned, so scope falls to a document that allows no AgentCore action. + 2. A condition pins the key elsewhere and cannot be skipped. The statement cannot reach + AgentCore however the rest of the policy is shaped, so it is out of scope -- this is what + keeps a grant pinned to sagemaker.amazonaws.com out of the check. + 3. Anything else -- no condition on the key, or only a defeasible one -- reaches every service, + so the rest of the policy decides: in scope when the policy also allows an AgentCore action. + """ + values = _passed_to_service_values(statement) + if _names_agentcore(values): + return True + if values and not _passed_to_service_pin_is_defeasible(statement): + return False + return _grants_agentcore_action(document) + + +def _names_every_role(resource: str) -> bool: + """Return True if this one resource names every role rather than specific roles. + + Decided by matching the resource against the shortest and longest role name a pattern would + have to cover, rather than by a regex demanding the resource field be a run of asterisks. That + regex was both too narrow and, being anchored on a literal ``arn:``, blind to a wildcarded + partition. Four resources naming every role in the account read as specific ones: + + role/?* every role whose name has at least one character + *role* every role/... resource there is, since the stars absorb the prefix and the name + arn:aws:iam::* the star spans the account and resource fields + *:aws:iam::123456789012:role/* a leading star matches "arn" + + ``role/?`` still passes and is the case that shows the rule is not "contains a metacharacter": + ``?`` matches exactly one character -- pinned by iam/lib/policy_test.py rather than assumed here + -- so it names single-character roles and nothing else. A name + prefix such as ``role/AgentCoreEvaluationRole*`` passes for the same reason -- it covers a set, + but not every role -- and that is the scope AWS's own AgentCore Evaluations reference policy + uses, so reporting it would report the documented configuration. + + Residual, and it is a judgement not a hole: a pattern of exactly 64 ``?`` would match the long + probe and not the short one, so it reads as specific. It names every role whose name is exactly + 64 characters, which is a set no operator writes by hand. + + A pattern with fewer than six fields is decided structurally, and NOT by probing concrete ARNs. + A probe corpus pins the partition and account it happens to carry, which makes both load-bearing + in the short branch while the six-field branch ignores them: ``arn:aws:iam::555555555555*`` reads + as specific while the identical shape in the probe's own account is reported, and + ``arn:aws-us-gov:iam::*`` and ``arn:aws-cn:iam::*`` read as specific merely because no probe + carries those partitions. No probe corpus can fix an account PREFIX -- there is nothing to + enumerate. So: a star in the LAST spelled-out field spans every + field after it, because IAM wildcards match the colon, and what remains is that the fields + actually spelled out must be able to name a role ARN. + + The region and account positions get an extra test, and both rest on a fixed property of an IAM + ARN rather than on a corpus: + + account an account is twelve digits, so a literal that is not twelve digits names no + account. This is what keeps ``arn:aws:iam::role/Prod*`` and + ``arn:aws:iam::12345:role/*`` specific. + region every IAM ARN has an EMPTY region, so any region pattern that cannot match the + empty string names no region. This is what keeps ``arn:aws:iam:role/Prod*`` + specific -- the same shape one colon short -- and, at six fields, + ``arn:aws:iam:us-east-1:123456789012:role/*``. + + BOTH BRANCHES APPLY BOTH TESTS, and the six-field branch not applying them was the defect that + reached review. It tested only that the partition and service fields could name an IAM ARN and + then probed the resource field, so a fully spelled-out ARN naming a region IAM does not have, or + an account of the wrong length, was read as naming every role: a high-severity + privilege-escalation FAIL on a pattern matching no role ARN at all. That is the same defect the + short branch had already been fixed for, surviving in the branch nobody re-read. + + The two branches phrase the SAME question differently because the field means something + different in each, and this is the part that is easy to get wrong: + + short branch the last spelled-out field carries a star that spans every field after it, so + only its literal HEAD is pinned to a position. ``?`` is discounted there because + it can match the colon and slide the rest of the pattern into a later field -- + which is what keeps ``arn:aws:iam:?*`` naming every role. + six fields the field is delimited on both sides, so the WHOLE field must be able to name a + region or an account, and ``?`` is NOT discounted -- it has no colon to match + and must consume exactly one character of a region that has none. That is why + ``arn:aws:iam:?:123456789012:role/*`` names no role while ``arn:aws:iam:?*`` + names every one. + + The PARTITION position deliberately gets no such test, which is why ``arn:xyz*`` still reads as + naming every role. A partition is not a fixed shape -- ``aws``, ``aws-cn``, ``aws-us-gov`` and the + iso partitions differ -- and a PREFIX of one cannot be enumerated, which is the same reason the + probe corpus was abandoned above. Over-reporting an unspellable partition is the safe direction + for a privilege-escalation check; guessing the partition set is not. + """ + candidate = resource.strip() + if candidate == "*": + return True + arn_fields = candidate.split(":", 5) + if len(arn_fields) < 6: + if "*" not in arn_fields[-1]: + return False + if not iam_pattern_matches(arn_fields[0], "arn"): + return False + if len(arn_fields) > 2 and not iam_pattern_matches(arn_fields[2], "iam"): + return False + if len(arn_fields) == 4: + # The last field sits in the REGION position, and every IAM ARN has an EMPTY region. Any + # literal head -- role/Prod in arn:aws:iam:role/Prod* -- can name no region, so the + # pattern matches no role ARN however its star spans. Discounting ? keeps + # arn:aws:iam:?* naming every role, since ? matches the colon. + region_head = arn_fields[3].split("*", 1)[0].replace("?", "") + if region_head: + return False + if len(arn_fields) == 5: + # The last field sits in the ACCOUNT position, and an account is twelve digits. A + # literal head that is not digits -- role/Prod in arn:aws:iam::role/Prod* -- can name no + # account, so the pattern matches no role ARN however its star spans. + account_head = arn_fields[4].split("*", 1)[0].replace("?", "") + if account_head and not account_head.isdigit(): + return False + return True + if not iam_pattern_matches(arn_fields[0], "arn"): + return False + if not iam_pattern_matches(arn_fields[2], "iam"): + return False + # Every IAM ARN has an EMPTY region, so a region field that cannot match the empty string names + # no region and the pattern reaches no role. Asked as a pattern match against "" rather than by + # stripping metacharacters, because that is the whole question here: "" and "*" match it, while + # "us-east-1" and "?" do not. ? is deliberately NOT discounted, unlike the short branch above -- + # this field is delimited on both sides, so there is no colon for it to match and it must consume + # one character of a region that has none. + if not iam_pattern_matches(arn_fields[3], ""): + return False + account_field = arn_fields[4] + # An account is twelve digits. Two ways a spelled-out field can fail to name one, and the second + # is unreachable in the short branch, where a trailing star always spans the field: + # a literal head that is not digits role/Prod in arn:aws:iam::role/Prod:... + # a starless field of the wrong width 12345, which is digits but names no account + account_head = account_field.split("*", 1)[0].replace("?", "") + if account_head and not account_head.isdigit(): + return False + if "*" not in account_field and len(account_field) != 12: + return False + return all( + iam_pattern_matches(arn_fields[5], probe) + for probe in EVERY_ROLE_RESOURCE_PROBES + ) + + +def _allows_any_role(statement: dict) -> bool: + """Return True if the statement's resources name every role rather than named roles.""" + resources = _as_list(statement.get("Resource")) + if not resources: + # NotResource grants every resource but the excluded ones, so every role outside + # that list stays passable. + return "NotResource" in statement + return any( + isinstance(resource, str) and _names_every_role(resource) + for resource in resources + ) + + +def _deny_removes_passrole(statement: dict) -> bool: + """Return True if a DENY statement's Action removes iam:PassRole. + + Separate from _covers_passrole on purpose, because the bare-star exclusion that is right on the + Allow side inverts on the Deny side. There, skipping ``*`` and ``*:*`` avoids duplicating the + administrative-privileges checks; here it meant an unconditional Deny of EVERY action credited + nothing, so a policy that grants no PassRole at all was reported FAIL. The Allow-side guard is + deliberately left alone: relaxing it would reverse the settled decision that admin-level grants + belong to those checks. + + Deny expressed as NotAction is still NOT read. Inverting it needs the whole action namespace, + and not crediting it errs toward reporting rather than toward silence. + """ + for action in _as_list(statement.get("Action")): + if not isinstance(action, str): + continue + service, separator, operation = action.strip().partition(":") + if separator != ":": + # A bare "*" denies every action, iam:PassRole among them. + if service == "*": + return True + continue + if not iam_pattern_matches(service, "iam"): + continue + if iam_pattern_matches(operation, "PassRole"): + return True + return False + + +def _denies_passrole_everywhere(document: dict) -> bool: + """Return True if an unconditional Deny removes iam:PassRole on every resource.""" + for statement in _statements(document): + if statement.get("Effect") != "Deny" or statement.get("Condition"): + continue + if not _deny_removes_passrole(statement): + continue + if any( + isinstance(resource, str) and resource.strip() == "*" + for resource in _as_list(statement.get("Resource")) + ): + return True + return False + + +def _has_unevaluated_notaction(document: dict) -> bool: + """True if an Allow statement expresses its actions as NotAction. + + NotAction under Effect Allow grants everything EXCEPT what it lists, so a policy using + it can grant iam:PassRole while carrying no Action key at all. Reading only Action would + find nothing and report a clean policy. Inverting NotAction correctly means resolving it + against the full action namespace and its interaction with Resource and NotResource, + which is more than this check can honestly claim to do -- so the statement is declared + unevaluated rather than guessed at. + """ + for statement in _statements(document): + if statement.get("Effect") == "Allow" and "NotAction" in statement: + return True + return False + + +class iam_policy_passrole_to_bedrock_agentcore_restricted(Check): + """Check whether a customer-managed policy scopes iam:PassRole to Bedrock AgentCore. + + A statement granting ``iam:PassRole`` on every role beside any Bedrock AgentCore action lets the + holder hand an arbitrary role to an agent runtime and assume its permissions, which is a + privilege-escalation path. FAIL when the PassRole resource names every role; PASS when it names + a bounded set, when the statement pins ``iam:PassedToService`` to another service, or when no + AgentCore action accompanies it; MANUAL when the policy document could not be read. + + Caveats: + Customer-managed policies only, since inline policies are covered by the + ``iam_inline_policy_*`` checks and an AWS-managed policy cannot be edited to remediate a + finding. A bare ``Action: "*"`` is left to the administrative-privileges checks. Conditions + do not rescue an unbounded resource, because ``iam:PassedToService`` binds the service and + ``iam:AssociatedResourceArn`` the consuming resource, neither the set of roles. + """ + + def execute(self) -> Check_Report_AWS: + """Flag policies allowing PassRole to AgentCore on all roles.""" + findings = [] + for policy in iam_client.policies.values(): + # Only customer-managed policies: the inline population is a separate check, + # and an AWS-managed policy cannot be edited to remediate a finding. + if policy.type != "Custom": + continue + if not policy.attached and not iam_client.provider.scan_unused_services: + continue + + report = Check_Report_AWS(metadata=self.metadata(), resource=policy) + report.region = iam_client.region + + if not policy.document: + report.status = "MANUAL" + report.status_extended = ( + f"Custom Policy {policy.name} could not be evaluated because its " + "policy document was not retrieved." + ) + findings.append(report) + continue + + if _has_unevaluated_notaction(policy.document): + report.status = "MANUAL" + report.status_extended = ( + f"Custom Policy {policy.name} expresses an Allow statement with " + "NotAction, which this check does not evaluate, so whether it allows " + "iam:PassRole could not be determined; review it manually." + ) + findings.append(report) + continue + + unrestricted = False + if not _denies_passrole_everywhere(policy.document): + unrestricted = any( + statement.get("Effect") == "Allow" + and _covers_passrole(statement) + and _allows_any_role(statement) + and _targets_agentcore(statement, policy.document) + for statement in _statements(policy.document) + ) + + if unrestricted: + report.status = "FAIL" + report.status_extended = ( + f"Custom Policy {policy.name} allows iam:PassRole to Bedrock AgentCore " + "on every role instead of the specific execution roles AgentCore is " + "meant to run as." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Custom Policy {policy.name} does not allow iam:PassRole to Bedrock " + "AgentCore on every role." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/iam/iam_role_service_trust_restricts_source_to_account/__init__.py b/prowler/providers/aws/services/iam/iam_role_service_trust_restricts_source_to_account/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/iam/iam_role_service_trust_restricts_source_to_account/iam_role_service_trust_restricts_source_to_account.metadata.json b/prowler/providers/aws/services/iam/iam_role_service_trust_restricts_source_to_account/iam_role_service_trust_restricts_source_to_account.metadata.json new file mode 100644 index 0000000000..b22d5263e7 --- /dev/null +++ b/prowler/providers/aws/services/iam/iam_role_service_trust_restricts_source_to_account/iam_role_service_trust_restricts_source_to_account.metadata.json @@ -0,0 +1,46 @@ +{ + "Provider": "aws", + "CheckID": "iam_role_service_trust_restricts_source_to_account", + "CheckTitle": "IAM role trust policy confines AWS service principals to a specific source account", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "TTPs/Privilege Escalation" + ], + "ServiceName": "iam", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "AwsIamRole", + "ResourceGroup": "IAM", + "Description": "Trust-policy statements letting an **AWS service principal** call `sts:AssumeRole` confine the request source to one account -- via `aws:SourceAccount`, an account-bearing `aws:SourceArn`, or an organization-scoped source. Scope: statements whose condition binds no account, and trust policies that are not a plain service role. Unconditional service roles go to the related check.", + "Risk": "A condition can look protective while binding nothing: a `*IfExists` operator is skipped when the calling service omits the key, a negated operator never matches, and an `aws:SourceArn` that is wildcarded or carries no account field (an S3 bucket ARN) names no account. Any account can then steer the service into assuming the role -- a **cross-service confused deputy** path to its permissions.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html#cross-service-confused-deputy-prevention", + "https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_condition-keys.html#condition-keys-sourcearn", + "https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_condition-keys.html#condition-keys-sourceaccount", + "https://aws.amazon.com/blogs/security/use-scalable-controls-for-aws-services-accessing-your-resources/", + "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_common-scenarios_third-party.html" + ], + "Remediation": { + "Code": { + "CLI": "aws iam update-assume-role-policy --role-name --policy-document '{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\".amazonaws.com\"},\"Action\":\"sts:AssumeRole\",\"Condition\":{\"StringEquals\":{\"aws:SourceAccount\":\"\"}}}]}'", + "NativeIaC": "```yaml\n# CloudFormation: confine the service-principal trust to this account\nResources:\n :\n Type: AWS::IAM::Role\n Properties:\n AssumeRolePolicyDocument:\n Version: '2012-10-17'\n Statement:\n - Effect: Allow\n Principal:\n Service: .amazonaws.com\n Action: sts:AssumeRole\n Condition:\n StringEquals:\n aws:SourceAccount: !Ref AWS::AccountId # binds the call to this account\n ArnLike:\n # Optional but preferred: bind to the calling resource as well.\n # An ARN whose account field is empty (an S3 bucket ARN) or wildcarded\n # does NOT bind the account -- keep aws:SourceAccount in that case.\n aws:SourceArn: !Sub 'arn:${AWS::Partition}::${AWS::Region}:${AWS::AccountId}:/'\n```", + "Other": "1. In the AWS console, go to IAM > Roles\n2. Open and select the Trust relationships tab\n3. Click Edit trust policy\n4. For every statement whose Principal is a Service, add a Condition block that binds the source to an account, using either:\n - StringEquals: aws:SourceAccount = , or\n - ArnLike / ArnEquals: aws:SourceArn = an ARN whose account field is \n5. If the aws:SourceArn value has no account field (for example arn:aws:s3:::amzn-s3-demo-bucket), add aws:SourceAccount as well -- the ARN alone cannot bind the account\n6. Do not rely on a *IfExists operator: it is skipped when the calling service omits the key\n7. Save changes and re-run the check", + "Terraform": "```hcl\n# Confine the service-principal trust to this account\nresource \"aws_iam_role\" \"\" {\n name = \"\"\n\n assume_role_policy = jsonencode({\n Version = \"2012-10-17\"\n Statement = [\n {\n Effect = \"Allow\"\n Principal = { Service = \".amazonaws.com\" }\n Action = \"sts:AssumeRole\"\n Condition = {\n StringEquals = { \"aws:SourceAccount\" = data.aws_caller_identity.current.account_id }\n # Optional but preferred: bind to the calling resource as well.\n ArnLike = { \"aws:SourceArn\" = aws__.example.arn }\n }\n }\n ]\n })\n}\n```" + }, + "Recommendation": { + "Text": "Bind every service-principal trust statement to an account with `aws:SourceAccount`, or with an `aws:SourceArn` whose account field holds the account ID. AWS documents `aws:SourceArn`, `aws:SourceAccount`, `aws:SourceOrgID` and `aws:SourceOrgPaths` as alternatives, so any one of them satisfies this check -- except an ARN with no account field, which needs `aws:SourceAccount` alongside it.", + "Url": "https://hub.prowler.com/check/iam_role_service_trust_restricts_source_to_account" + } + }, + "Categories": [ + "identity-access", + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [ + "iam_role_cross_service_confused_deputy_prevention" + ], + "Notes": "Companion to iam_role_cross_service_confused_deputy_prevention, not a replacement. That check reports a service-principal trust statement carrying no restrictive condition at all, and only on roles it classifies as service roles. This check asserts the clause it leaves open: statements where a condition IS present but confines nothing, and statements no service-role check evaluates. Both can report one role.\n\nA condition confines nothing when it uses a negated operator, an *IfExists or ForAllValues operator with no Null:\"false\" guard on the same key, a wildcarded aws:SourceArn, or an ARN whose account field is empty such as an S3 bucket ARN. A role leaves the other check's population when its trust policy carries a Deny statement, an action outside the assume-role family, or a non-Service principal.\n\nAll four aws:Source* keys count as bindings. sts:ExternalId does not: AWS documents it for third-party access where the third party supplies the value, while a calling service passes source context instead." +} diff --git a/prowler/providers/aws/services/iam/iam_role_service_trust_restricts_source_to_account/iam_role_service_trust_restricts_source_to_account.py b/prowler/providers/aws/services/iam/iam_role_service_trust_restricts_source_to_account/iam_role_service_trust_restricts_source_to_account.py new file mode 100644 index 0000000000..07aa4ac073 --- /dev/null +++ b/prowler/providers/aws/services/iam/iam_role_service_trust_restricts_source_to_account/iam_role_service_trust_restricts_source_to_account.py @@ -0,0 +1,484 @@ +import re + +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.iam.iam_client import iam_client +from prowler.providers.aws.services.iam.lib.policy import iam_pattern_matches + +ASSUME_ROLE_ACTION = "sts:AssumeRole" +ACCOUNT_ID_PATTERN = re.compile(r"^\d{12}$") +ORGANIZATION_ID_PATTERN = re.compile(r"^o-[a-z0-9]{10,32}$") +ORGANIZATION_PATH_PATTERN = re.compile(r"^o-[a-z0-9]{10,32}/") + + +def _as_list(value) -> list: + """Normalize to list: None -> [], scalar -> [val], list -> list. + + The same helper both sibling checks in this PR define. A present-but-null key -- ``"Action": + null`` -- makes ``.get("Action", [])`` return None rather than the default, and iterating that + raises TypeError out of execute(), discarding every finding for the account rather than one + role. IAM will not store such a document, so this is consistency with the siblings and not a + security fix. + """ + if value is None: + return [] + return value if isinstance(value, list) else [value] + + +def _grants_assume_role(statement: dict) -> bool: + """Return True if the statement's Action covers sts:AssumeRole. + + Exactly that one operation, not the wider assume-role family. ``sts:AssumeRoleWithWebIdentity`` + and ``sts:AssumeRoleWithSAML`` are how a federated or web identity assumes a role; an AWS service + principal uses ``sts:AssumeRole``, so a statement granting only one of the other two is not a + service-principal trust grant and is correctly outside this check's population. + + The direction matters and is easy to read backwards: the statement's Action is the PATTERN and + ``sts:AssumeRole`` is the value, so ``sts:*`` and ``sts:Assume*`` match while + ``sts:AssumeRoleWithSAML`` does not. + + Matched with the shared IAM matcher this PR already ships, rather than a literal tuple plus a + trailing-star test. That pair recognised sts:AssumeRole, sts:* and * and any prefix ending in a + star, but nothing else IAM honours: sts:*Role, sts:A*Role, sts:Assume?ole and sts:AssumeRol? + each grant the action and each produced NO REPORT at all, because a statement that does not + grant assume-role drops out of the evaluated population. Beside a second statement the same + miss was worse than silence -- the role reported PASS, asserting it confines every AWS service + principal in its trust policy to a specific account. + """ + for action in _as_list(statement.get("Action")): + if not isinstance(action, str): + continue + if iam_pattern_matches(action, ASSUME_ROLE_ACTION): + return True + return False + + +def _trusts_service_principal(statement: dict) -> bool: + """Return True if the statement trusts at least one AWS service principal. + + A statement that trusts a service principal *alongside* other principal types + still qualifies: the service principal is reachable regardless of what else the + statement trusts, so it needs the same confused-deputy scoping. + """ + principal = statement.get("Principal", {}) + if not isinstance(principal, dict): + # Principal: "*" is a string, not a mapping, and trusts every principal there is -- + # including every service principal. Returning False here dropped the statement from + # the population and the role produced no finding at all. + return principal == "*" + return any( + isinstance(service, str) and service + for service in _as_list(principal.get("Service")) + ) + + +# Operators that COMPARE a request value against the statement's own, so a value under one of them +# genuinely pins the request source. An allow-list, because the deny-list this replaced -- two +# substring tests for "not" and "ifexists" -- admitted every operator it did not recognise, Null +# among them. Modelled on _RESTRICTIVE_ATTESTATION_OPERATORS in kms/lib/enclave.py, which solved the +# same problem for attestation keys; the Arn forms are added here because aws:SourceArn is compared +# with them. Matched lowercased, which keeps an oddly-cased operator as admissible as it was before. +# The IfExists suffix is included, and admitted only under the same Null:"false" guard ForAllValues +# needs. kms/lib/enclave.py calls it "the same trap as ForAllValues without a Null:false guard", so +# rejecting one outright while rescuing the other was inconsistent on that file's own reading; and +# secretsmanager_has_restrictive_resource_policy already ships IfExists paired with Null as its +# accepted restrictive form, so the pairing is a shape prowler recognises rather than a new rule. +_COMPARING_CONDITION_OPERATORS = frozenset( + f"{qualifier}{operator}{suffix}".lower() + for qualifier in ("", "ForAnyValue:", "ForAllValues:") + for suffix in ("", "IfExists") + for operator in ( + "StringEquals", + "StringEqualsIgnoreCase", + "StringLike", + "ArnEquals", + "ArnLike", + ) +) + + +def _null_guarded_keys(condition: dict) -> set: + """Return the lowercased condition keys a ``Null: "false"`` test forces to be present. + + Both spellings are handled, the scalar and the list, as kms/lib/enclave.py does. + + EVERY value must be ``false``, not merely one of them, because IAM ORs the values inside a single + condition operator. ``Null: {key: ["true","false"]}`` means "key absent OR key present", which is + always true and binds NOTHING, yet ``any`` credited it and rescued a defeasible pin. This axis is + shared with the PassRole sibling and fixed identically there, because a MULTI-element list survives + on this surface: ``create_role`` stores ``["true","false"]`` and ``get_role`` returns it as a list. + A single-element ``["false"]`` is collapsed to the scalar ``"false"`` here, which is why only the + multi-value spelling matters. ``candidates and`` is load-bearing: ``all()`` over an empty list is + True, so an empty value list would read as the strongest possible guard. + + TWO AXES, MEASURED SEPARATELY, AND THEY DIVERGE. On VALUE TYPE this stays string-only while the + PassRole sibling also reads a JSON boolean, because a trust policy normalizes scalar types and no + bool can reach here. On LIST ARITY the two agree, because a multi-element list is preserved on both + surfaces. Both halves are stated at both ends rather than left to look like drift, since a helper + pair that agrees on one axis and differs on another is exactly what decays when nobody wrote down + which axis was which. + + A trust policy NORMALIZES its condition scalars, measured both ways round: ``create_role`` with + ``{"Null": {"aws:SourceAccount": false}}`` is ACCEPTED, and the document comes back carrying the + string ``"false"`` -- from ``list_roles``, which is the call this check's collector actually + makes, and identically from ``get_role``. An unquoted ``123456789012`` comes back quoted too. + So no bool or int can reach this helper, and reading one would be dead code with no fixture able + to exercise it. A customer-managed POLICY document, which is all the sibling reads, + PRESERVES both types instead -- two IAM surfaces, two behaviours, which is exactly why this was + measured per surface rather than inferred from one. + + If IAM ever stops normalizing here, take the sibling's reading: ``isinstance(candidate, (str, + bool)) and str(candidate).strip().lower() == "false"``. That admits a JSON ``false`` and still + excludes ``0``, because ``str(0)`` is ``"0"`` -- the value comparison does that work, not the + type test. What it must not become is ``not candidate`` or ``candidate is False``, which read + ``0``, ``""``, ``None`` and ``[]`` as guards; ``isinstance(False, int)`` is True in Python and + falsiness is not the question. Over-recognising a guard turns a FAIL into a PASS. + """ + guarded = set() + for operator, block in condition.items(): + if not isinstance(operator, str) or operator.lower() != "null": + continue + if not isinstance(block, dict): + continue + for key, value in block.items(): + if not isinstance(key, str): + continue + candidates = value if isinstance(value, list) else [value] + if candidates and all( + isinstance(candidate, str) and candidate.strip().lower() == "false" + for candidate in candidates + ): + guarded.add(key.lower()) + return guarded + + +def _enforced_condition_value_groups(statement: dict, condition_key: str) -> list: + """Collect the values a statement pins to condition_key, GROUPED BY OPERATOR. + + One group per operator, because IAM ANDs the operators in a Condition while ORing the values + inside one operator. The grouping follows that structure directly: + + - ACROSS groups, a caller asks whether ANY ONE confines the source. If one operator holds the + request to literal account IDs, the request is confined whatever else it must also satisfy -- + an ANDed operator can only narrow. Pooling operators together would let a broad ``StringLike`` + beside a pinned ``StringEquals`` widen the verdict, which inverts the semantics. + - WITHIN a group, EVERY value must qualify, since IAM lets the request match any one of them. + + This is the per-operator evaluation ``kms/lib/enclave.py`` performs. + + Operators are taken from an ALLOW-LIST, not a deny-list, so an operator this code does not + recognise is never credited. Two consequences worth naming: + + - Negated operators invert the match and so pin the source to nothing. They are absent from the + allow-list by design. + - ``Null`` is a presence test rather than a comparison, so it never contributes a value here. Its + role is only as the guard below. A deny-list would admit it and feed the literal string + ``"false"`` in as though it were an account ID. + + TWO operator families are vacuous on an Allow, and both are credited only under the same guard: + + - ``ForAllValues:*`` "returns true if there are no context keys in the request", which AWS + documents with an explicit warning against using it with an Allow effect. This check evaluates + Allow statements only, so that is the reachable case. + - ``*IfExists`` is not evaluated at all when the request omits the key, which kms/lib/enclave.py + names as "the same trap as ForAllValues without a Null:false guard". + + Both are credited only when the same statement carries a ``Null: "false"`` guard on the SAME key, + which forces the key to be present and removes the vacuity. The guard defeats it identically for + every spelling, so all four of ``ForAllValues:StringEquals``, ``StringEqualsIfExists``, + ``ArnLikeIfExists`` and ``ForAllValues:StringLikeIfExists`` are treated alike -- + ``secretsmanager_has_restrictive_resource_policy`` already ships IfExists paired with Null as its + accepted restrictive form. Refusing the guarded spellings outright would also be wrong because + ``aws:SourceOrgPaths`` is multivalued, making a set operator the only correct way to write it. + + ``ForAnyValue:*`` needs no guard. AWS documents that for no matching context key, or if the key + does not exist, it returns false -- so it fails closed on an Allow. + """ + groups = [] + condition = statement.get("Condition", {}) + if not isinstance(condition, dict): + return groups + null_guarded = _null_guarded_keys(condition) + for operator, block in condition.items(): + if not isinstance(operator, str) or not isinstance(block, dict): + continue + lowered_operator = operator.lower() + if lowered_operator not in _COMPARING_CONDITION_OPERATORS: + continue + group = [] + for key, value in block.items(): + if not isinstance(key, str) or key.lower() != condition_key: + continue + if ( + lowered_operator.startswith("forallvalues:") + or lowered_operator.endswith("ifexists") + ) and key.lower() not in null_guarded: + continue + group.extend(value if isinstance(value, list) else [value]) + if group: + groups.append(group) + return groups + + +def _pins_every_value(statement: dict, condition_key: str, qualifies) -> bool: + """Return True if some one operator holds condition_key to values that all qualify.""" + return any( + all(qualifies(value) for value in group) + for group in _enforced_condition_value_groups(statement, condition_key) + ) + + +def _is_account_id(value) -> bool: + """Return True if the value is a literal 12-digit account ID.""" + return isinstance(value, str) and bool(ACCOUNT_ID_PATTERN.match(value)) + + +def _pins_source_account(statement: dict) -> bool: + """Return True if aws:SourceAccount is pinned to literal account IDs only.""" + return _pins_every_value(statement, "aws:sourceaccount", _is_account_id) + + +def _arn_carries_account(value) -> bool: + """Return True if the ARN's account field is a literal account ID. + + An ARN whose account field is absent (an S3 bucket ARN) or wildcarded does not + confine the caller to one account, so aws:SourceAccount is still required. + """ + if not isinstance(value, str): + return False + arn_fields = value.split(":") + return len(arn_fields) >= 5 and _is_account_id(arn_fields[4]) + + +def _pins_source_arn_to_account(statement: dict) -> bool: + """Return True if some one operator holds every aws:SourceArn value to an account.""" + return _pins_every_value(statement, "aws:sourcearn", _arn_carries_account) + + +def _pins_source_organization(statement: dict) -> bool: + """Return True if the source is pinned to an organization or an OU path. + + AWS documents aws:SourceOrgID and aws:SourceOrgPaths as confused-deputy mitigations + in their own right, so an organization-scoped statement is not reported. + """ + return _pins_every_value( + statement, + "aws:sourceorgid", + lambda value: isinstance(value, str) + and bool(ORGANIZATION_ID_PATTERN.match(value)), + ) or _pins_every_value( + statement, + "aws:sourceorgpaths", + lambda value: isinstance(value, str) + and bool(ORGANIZATION_PATH_PATTERN.match(value)), + ) + + +def _prevents_confused_deputy(statement: dict) -> bool: + """Return True if the statement carries a control AWS documents for *cross-service* + confused-deputy prevention. + + sts:ExternalId is deliberately absent. AWS documents it only for third-party access -- + an external ID is a value the third party supplies -- and an AWS service passes source + account and source ARN context, never an external ID. Crediting it here would accept a + control the calling service can never satisfy. + """ + return ( + _pins_source_account(statement) + or _pins_source_arn_to_account(statement) + or _pins_source_organization(statement) + ) + + +def _has_enforced_condition(statement: dict) -> bool: + """Return True if the statement gates access on at least one enforced condition key. + + A statement with no enforced condition at all places no constraint whatsoever on the + caller. That wholly-unconditional state is a different (and more severe) posture than + a constraint that is present but does not confine the source, and it is what + iam_role_cross_service_confused_deputy_prevention reports. + + This filter DELIBERATELY differs from the allow-list _enforced_condition_value_groups applies, and + the two must not be unified. This one asks only whether the statement is gated at all, so a + ``Null`` presence test counts: it does gate access, even while binding the source to nothing. + Pulling such a statement into scope is the safe direction -- it gets evaluated and reported + rather than silently skipped. The other function asks what the statement PINS, where a presence + test contributes no value and crediting one poisoned the shape test beside it. + """ + condition = statement.get("Condition", {}) + if not isinstance(condition, dict): + return False + for operator, block in condition.items(): + if not isinstance(operator, str) or not isinstance(block, dict): + continue + lowered_operator = operator.lower() + if "not" in lowered_operator or lowered_operator.endswith("ifexists"): + continue + if any(isinstance(key, str) for key in block): + return True + return False + + +def _is_plain_service_trust_policy(statements: list) -> bool: + """Return True if every statement is an Allow of assume-role to services only. + + This is the trust-policy shape that the existing service-role checks assume. A policy + that departs from it -- by carrying a Deny statement, an action outside the + assume-role family such as sts:SetContext, or a non-service principal -- falls outside + their evaluated population entirely, so its service principals go unassessed. + """ + for statement in statements: + if not isinstance(statement, dict): + return False + if statement.get("Effect") != "Allow" or not _grants_assume_role(statement): + return False + principal = statement.get("Principal", {}) + if not isinstance(principal, dict) or set(principal.keys()) != {"Service"}: + return False + return True + + +class iam_role_service_trust_restricts_source_to_account(Check): + """Check whether a role's service-principal trust confines the request to a source account. + + An AWS service principal permitted to assume a role without a source-account or source-ARN + binding exposes the role to the confused-deputy problem: another customer's resource can induce + the service to assume it. FAIL when a trust statement carries a condition that confines nothing; + PASS when every in-scope statement binds a source; MANUAL for a statement using ``NotAction``, + which is the one shape here that cannot be evaluated, since inverting it correctly is more than + this check can claim. A trust policy is always present on a role, so there is no unreadable-document + branch on this surface, unlike the two policy checks beside it. + + Caveats: + Companion to ``iam_role_cross_service_confused_deputy_prevention`` rather than a replacement. + That check reports statements with no restrictive condition at all, on roles it classifies as + service roles; this one asserts the clause it leaves open, so the wholly unconditional + statement produces no finding here and the two can both report one role. Bindings are read + from Allow statements only, so a trust policy confined solely through a Deny is still + reported. + """ + + def execute(self) -> Check_Report_AWS: + """Flag service-principal trust whose present condition binds no account. + + Account bindings are read from Allow statements ONLY. A Deny can also confine the source -- + `StringNotEquals` on `aws:SourceAccount` denies every account but one -- and this check does + not evaluate that, so a trust policy confined solely through a Deny is reported even though + it is confined. Rare, and it errs toward reporting rather than toward silence, but it is an + unevaluated shape and is declared here rather than left to be inferred, as `NotAction` + already is. A Deny still matters for scope: it takes the policy outside the plain-service + shape the sibling checks assume, which is what brings the Allow statements beside it into + this check's population. + + MANUAL is used deliberately for the NotAction shape, and is not off-contract: 110 upstream + checks emit it and `lib/check/models.py` places no restriction on it. THE COST, recorded so it + is not rediscovered: `lib/outputs/asff/asff.py` SKIPS MANUAL findings, since MANUAL is not a + valid Security Hub compliance state, so a Security Hub consumer sees NOTHING for a trust policy + this check could not evaluate, and absence there reads as compliance. CSV and OCSF keep the + status. The sibling token-wildcard check carries the same note, for the same reason. + """ + findings = [] + for role in iam_client.roles or []: + # Service-linked roles are excluded: their trust relationship is managed by + # the service and cannot be edited, so a finding would not be actionable. + if "aws-service-role" in role.arn: + continue + + trust_policy = role.assume_role_policy or {} + statements = trust_policy.get("Statement", []) + if not isinstance(statements, list): + statements = [statements] + + # NotAction under Effect Allow grants everything except what it lists, so a + # trust statement using it can permit sts:AssumeRole while carrying no Action + # key. _grants_assume_role reads only Action, so such a statement would drop out + # of service_statements below and the role would produce no finding at all. + # Inverting NotAction correctly is more than this check can claim, so the role + # is declared unevaluated rather than silently skipped. + if any( + isinstance(statement, dict) + and statement.get("Effect") == "Allow" + and "NotAction" in statement + for statement in statements + ): + report = Check_Report_AWS(metadata=self.metadata(), resource=role) + report.region = iam_client.region + report.status = "MANUAL" + report.status_extended = ( + f"IAM Role {role.name} has a trust policy statement using NotAction, " + "which this check does not evaluate, so whether a service principal is " + "confined to this account could not be determined; review it manually." + ) + findings.append(report) + continue + + service_statements = [ + statement + for statement in statements + if isinstance(statement, dict) + and statement.get("Effect") == "Allow" + and _grants_assume_role(statement) + and _trusts_service_principal(statement) + ] + if not service_statements: + continue + + # A wholly unconditional service-principal trust statement on an otherwise + # plain service role is the fully-unprotected posture that + # iam_role_cross_service_confused_deputy_prevention already reports. This + # check asserts the narrower clause it does not: that a constraint which IS + # present actually confines the source to an account. Statements are therefore + # in scope when they carry an enforced condition, or when the trust policy + # departs from the plain-service shape and so is evaluated by no other check. + is_plain = _is_plain_service_trust_policy(statements) + in_scope = [ + statement + for statement in service_statements + if _has_enforced_condition(statement) or not is_plain + ] + if not in_scope: + continue + + unscoped = [ + statement + for statement in in_scope + if not _prevents_confused_deputy(statement) + ] + + report = Check_Report_AWS(metadata=self.metadata(), resource=role) + report.region = iam_client.region + if unscoped: + # The finding says no condition PINS the key to a literal of the right shape, not + # that the statement sets no key. Most inputs reaching here do set one: StringLike + # aws:SourceAccount "1234*", an unguarded ForAllValues, and Null "false" all set the + # key while pinning nothing. The weaker claim is the one the code supports. + report.status = "FAIL" + report.status_extended = ( + f"IAM Role {role.name} trusts an AWS service principal without confining the " + "request source, since no condition pins aws:SourceAccount to a literal " + "account ID, aws:SourceArn to an ARN carrying one, or aws:SourceOrgID or " + "aws:SourceOrgPaths to an organization." + ) + elif all( + _pins_source_account(statement) + or _pins_source_arn_to_account(statement) + for statement in in_scope + ): + report.status = "PASS" + report.status_extended = ( + f"IAM Role {role.name} confines every AWS service principal in its trust " + "policy to a specific account." + ) + else: + # The organization route reaches PASS through _pins_source_organization, and an + # organization may hold hundreds of accounts. Reporting it with the sentence above + # told the operator something categorically stronger than was verified, so the two + # postures get separate sentences: a reader needs to know which one they have. + report.status = "PASS" + report.status_extended = ( + f"IAM Role {role.name} confines every AWS service principal in its trust " + "policy, but at least one statement is scoped to an organization rather than " + "to a single account, so the trusted source may be any account within it." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/iam/lib/policy.py b/prowler/providers/aws/services/iam/lib/policy.py index 333fbad993..f8f9d75de7 100644 --- a/prowler/providers/aws/services/iam/lib/policy.py +++ b/prowler/providers/aws/services/iam/lib/policy.py @@ -21,6 +21,62 @@ def _get_patterns_from_standard_value(value): return patterns +def iam_pattern_matches(pattern: str, value: str) -> bool: + """Whether an IAM wildcard pattern from a policy document names a given value. + + IAM honours exactly two metacharacters, ``*`` for any run of characters and ``?`` for exactly + one; every other character is literal, ``[seq]`` character classes included. Matching is + case-insensitive, as IAM matches action names. + + Args: + pattern: A value taken from a policy document -- an Action's service or operation, a + Resource ARN field, a condition value. Surrounding whitespace is stripped, which IAM + tolerates. + value: The concrete string to test the pattern against. + + Returns: + True when IAM would consider the pattern to name that value. + + MATCHED WITH A TWO-POINTER SCAN RATHER THAN A TRANSLATED REGEX, and the scan below is the same + one as ``_action_matches`` in ``bedrockagentcore_full_access_policy_attached`` -- taken from it + rather than written again, so there is one implementation of this under review and not two. + Building ``.*`` for every ``*`` and calling ``re``, which is what this file's callers previously + did, backtracks catastrophically on input the ACCOUNT controls: against the 17-character + ``bedrock-agentcore``, a pattern of N stars, a literal absent from the value, then N more stars + took 0.7 ms at N=6, 62 ms at N=10 and 2287 ms at N=14 -- a 31-character policy value, where a + managed policy document allows 6144. Cost rises with the number of quantifiers, which is the + account's side of the input, so a short value does not bound it. A hang raises nothing, so the + bare ``except Exception`` in ``prowler/lib/check/check.py`` cannot catch it and every finding for + the account is discarded in silence. This scan is O(len(pattern) x len(value)) with no + backtracking path at all: 0.006 ms on that same 31-character pattern, 0.214 ms at 4003. + """ + # Normalised here rather than inside the scan, so the scan stays identical to the one already + # under review. IAM tolerates surrounding whitespace and matches case-insensitively, both of + # which the regex form this replaced provided through .strip() and re.IGNORECASE. + pattern = pattern.strip().lower() + action = value.lower() + + p = a = 0 + star = resume = -1 + while a < len(action): + if p < len(pattern) and pattern[p] in ("?", action[a]): + p += 1 + a += 1 + elif p < len(pattern) and pattern[p] == "*": + star = p + resume = a + p += 1 + elif star >= 0: + # Backtrack to the most recent star and let it absorb one more character. Only ever + # one star is reconsidered, which is what bounds this at a product rather than a power. + resume += 1 + a = resume + p = star + 1 + else: + return False + return all(char == "*" for char in pattern[p:]) + + def get_effective_actions(policy: dict) -> set[str]: """ Calculates the set of effectively allowed IAM actions from a policy document. diff --git a/prowler/providers/aws/services/inspector2/inspector2_active_findings_kev_within_due_date/__init__.py b/prowler/providers/aws/services/inspector2/inspector2_active_findings_kev_within_due_date/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/inspector2/inspector2_active_findings_kev_within_due_date/inspector2_active_findings_kev_within_due_date.metadata.json b/prowler/providers/aws/services/inspector2/inspector2_active_findings_kev_within_due_date/inspector2_active_findings_kev_within_due_date.metadata.json new file mode 100644 index 0000000000..3795a96fae --- /dev/null +++ b/prowler/providers/aws/services/inspector2/inspector2_active_findings_kev_within_due_date/inspector2_active_findings_kev_within_due_date.metadata.json @@ -0,0 +1,42 @@ +{ + "Provider": "aws", + "CheckID": "inspector2_active_findings_kev_within_due_date", + "CheckTitle": "Inspector2 has no active findings for CISA Known Exploited Vulnerabilities past their remediation due date", + "CheckType": [ + "Software and Configuration Checks/Vulnerabilities/CVE", + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "inspector2", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "critical", + "ResourceType": "Other", + "ResourceGroup": "security", + "Description": "**Amazon Inspector** active findings for **CISA Known Exploited Vulnerabilities** are compared with the remediation due date (`dateDue`) that CISA assigns to each entry of the KEV catalog. Findings that are still active after that date are reported.\n\nThe result is reported per Region where Inspector is enabled.", + "Risk": "CISA due dates reflect **active exploitation**. Missing them keeps exploited vulnerabilities open beyond the window CISA sets for federal agencies and shows that vulnerability response is not keeping pace with real threats.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.cisa.gov/known-exploited-vulnerabilities-catalog", + "https://docs.aws.amazon.com/inspector/v2/APIReference/API_BatchGetFindingDetails.html" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. In the AWS Console, open Amazon Inspector > Findings\n2. Filter by Vulnerability ID for each overdue CVE reported by this check\n3. Patch or upgrade the affected packages, rebuild container images or update Lambda runtimes\n4. If a fix is not available, apply the mitigations listed in the CISA catalog entry\n5. Confirm the findings move to Closed", + "Terraform": "" + }, + "Recommendation": { + "Text": "Track every KEV finding against its **CISA due date** and remediate before it passes. When no fix exists yet, apply the vendor or CISA mitigations and document the residual risk.", + "Url": "https://hub.prowler.com/check/inspector2_active_findings_kev_within_due_date" + } + }, + "Categories": [ + "vulnerabilities" + ], + "DependsOn": [], + "RelatedTo": [ + "inspector2_active_findings_no_known_exploited_vulnerabilities" + ], + "Notes": "" +} diff --git a/prowler/providers/aws/services/inspector2/inspector2_active_findings_kev_within_due_date/inspector2_active_findings_kev_within_due_date.py b/prowler/providers/aws/services/inspector2/inspector2_active_findings_kev_within_due_date/inspector2_active_findings_kev_within_due_date.py new file mode 100644 index 0000000000..e0ec8ddd30 --- /dev/null +++ b/prowler/providers/aws/services/inspector2/inspector2_active_findings_kev_within_due_date/inspector2_active_findings_kev_within_due_date.py @@ -0,0 +1,66 @@ +from datetime import datetime, timezone + +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.inspector2.inspector2_client import ( + inspector2_client, +) +from prowler.providers.aws.services.inspector2.lib.vulnerabilities import ( + summarize_vulnerabilities, +) + + +class inspector2_active_findings_kev_within_due_date(Check): + """Ensure active Inspector2 findings for CISA KEVs are not past their CISA due date.""" + + def execute(self) -> list[Check_Report_AWS]: + """Report, per Region, whether any CISA KEV finding is past its remediation due date.""" + findings = [] + now = datetime.now(timezone.utc) + known_exploited = inspector2_client.known_exploited_vulnerabilities + lookup_failed = inspector2_client.vulnerability_lookup_failed + for inspector in inspector2_client.inspectors: + if inspector.status != "ENABLED": + continue + report = Check_Report_AWS(metadata=self.metadata(), resource=inspector) + if inspector.findings is None: + report.status = "MANUAL" + report.status_extended = ( + f"Inspector2 findings could not be retrieved in region {inspector.region}; " + "verify the inspector2:ListFindings permission." + ) + findings.append(report) + continue + vulnerability_ids = { + finding.vulnerability_id + for finding in inspector.findings + if finding.vulnerability_id + } + overdue = sorted( + f"{vulnerability_id} (due {known_exploited[vulnerability_id].date_due.date().isoformat()})" + for vulnerability_id in known_exploited.keys() & vulnerability_ids + if known_exploited[vulnerability_id].date_due + and known_exploited[vulnerability_id].date_due < now + ) + unverified_ids = sorted(vulnerability_ids & lookup_failed) + if overdue: + report.status = "FAIL" + report.status_extended = ( + f"Inspector2 has active findings in region {inspector.region} for CISA " + "Known Exploited Vulnerabilities past their remediation due date: " + f"{summarize_vulnerabilities(overdue)}." + ) + elif unverified_ids: + report.status = "MANUAL" + report.status_extended = ( + "Inspector2 could not verify the CISA Known Exploited Vulnerabilities status of " + f"{summarize_vulnerabilities(unverified_ids)} in region {inspector.region}; " + "verify the inspector2:BatchGetFindingDetails permission." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Inspector2 has no active findings in region {inspector.region} for CISA " + "Known Exploited Vulnerabilities past their remediation due date." + ) + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/inspector2/inspector2_active_findings_no_known_exploited_vulnerabilities/__init__.py b/prowler/providers/aws/services/inspector2/inspector2_active_findings_no_known_exploited_vulnerabilities/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/inspector2/inspector2_active_findings_no_known_exploited_vulnerabilities/inspector2_active_findings_no_known_exploited_vulnerabilities.metadata.json b/prowler/providers/aws/services/inspector2/inspector2_active_findings_no_known_exploited_vulnerabilities/inspector2_active_findings_no_known_exploited_vulnerabilities.metadata.json new file mode 100644 index 0000000000..5072ebd6a6 --- /dev/null +++ b/prowler/providers/aws/services/inspector2/inspector2_active_findings_no_known_exploited_vulnerabilities/inspector2_active_findings_no_known_exploited_vulnerabilities.metadata.json @@ -0,0 +1,43 @@ +{ + "Provider": "aws", + "CheckID": "inspector2_active_findings_no_known_exploited_vulnerabilities", + "CheckTitle": "Inspector2 has no active findings for CISA Known Exploited Vulnerabilities", + "CheckType": [ + "Software and Configuration Checks/Vulnerabilities/CVE", + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "inspector2", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "Other", + "ResourceGroup": "security", + "Description": "**Amazon Inspector** active findings are cross-referenced with the **CISA Known Exploited Vulnerabilities (KEV)** catalog, using the CISA data that Inspector returns in the finding details (`BatchGetFindingDetails`) of each CVE.\n\nThe result is reported per Region where Inspector is enabled.", + "Risk": "KEV entries are vulnerabilities **confirmed as exploited in the wild**. Workloads carrying them are prime targets for initial access and ransomware, enabling remote code execution, data exfiltration and lateral movement.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.cisa.gov/known-exploited-vulnerabilities-catalog", + "https://docs.aws.amazon.com/inspector/v2/APIReference/API_BatchGetFindingDetails.html" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. In the AWS Console, open Amazon Inspector > Findings\n2. Filter by Vulnerability ID for each CVE reported by this check\n3. Patch or upgrade the affected packages, rebuild container images or update Lambda runtimes\n4. Confirm the findings move to Closed", + "Terraform": "" + }, + "Recommendation": { + "Text": "Remediate KEV findings before any other vulnerability: patch or upgrade the affected packages, rebuild and redeploy container images, and stop deploying new resources that carry **known exploited vulnerabilities**.", + "Url": "https://hub.prowler.com/check/inspector2_active_findings_no_known_exploited_vulnerabilities" + } + }, + "Categories": [ + "vulnerabilities" + ], + "DependsOn": [], + "RelatedTo": [ + "inspector2_active_findings_exist", + "inspector2_active_findings_kev_within_due_date" + ], + "Notes": "" +} diff --git a/prowler/providers/aws/services/inspector2/inspector2_active_findings_no_known_exploited_vulnerabilities/inspector2_active_findings_no_known_exploited_vulnerabilities.py b/prowler/providers/aws/services/inspector2/inspector2_active_findings_no_known_exploited_vulnerabilities/inspector2_active_findings_no_known_exploited_vulnerabilities.py new file mode 100644 index 0000000000..e483205cb1 --- /dev/null +++ b/prowler/providers/aws/services/inspector2/inspector2_active_findings_no_known_exploited_vulnerabilities/inspector2_active_findings_no_known_exploited_vulnerabilities.py @@ -0,0 +1,57 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.inspector2.inspector2_client import ( + inspector2_client, +) +from prowler.providers.aws.services.inspector2.lib.vulnerabilities import ( + summarize_vulnerabilities, +) + + +class inspector2_active_findings_no_known_exploited_vulnerabilities(Check): + """Ensure no active Inspector2 finding is a CISA Known Exploited Vulnerability.""" + + def execute(self) -> list[Check_Report_AWS]: + """Report, per Region, whether any active finding is a CISA Known Exploited Vulnerability.""" + findings = [] + known_exploited = inspector2_client.known_exploited_vulnerabilities + lookup_failed = inspector2_client.vulnerability_lookup_failed + for inspector in inspector2_client.inspectors: + if inspector.status != "ENABLED": + continue + report = Check_Report_AWS(metadata=self.metadata(), resource=inspector) + if inspector.findings is None: + report.status = "MANUAL" + report.status_extended = ( + f"Inspector2 findings could not be retrieved in region {inspector.region}; " + "verify the inspector2:ListFindings permission." + ) + findings.append(report) + continue + vulnerability_ids = { + finding.vulnerability_id + for finding in inspector.findings + if finding.vulnerability_id + } + kev_ids = sorted(known_exploited.keys() & vulnerability_ids) + unverified_ids = sorted(vulnerability_ids & lookup_failed) + if kev_ids: + report.status = "FAIL" + report.status_extended = ( + f"Inspector2 has active findings in region {inspector.region} for CISA " + f"Known Exploited Vulnerabilities: {summarize_vulnerabilities(kev_ids)}." + ) + elif unverified_ids: + report.status = "MANUAL" + report.status_extended = ( + "Inspector2 could not verify the CISA Known Exploited Vulnerabilities status of " + f"{summarize_vulnerabilities(unverified_ids)} in region {inspector.region}; " + "verify the inspector2:BatchGetFindingDetails permission." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Inspector2 has no active findings in region {inspector.region} for CISA " + "Known Exploited Vulnerabilities." + ) + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/inspector2/inspector2_active_findings_within_max_age/__init__.py b/prowler/providers/aws/services/inspector2/inspector2_active_findings_within_max_age/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/inspector2/inspector2_active_findings_within_max_age/inspector2_active_findings_within_max_age.metadata.json b/prowler/providers/aws/services/inspector2/inspector2_active_findings_within_max_age/inspector2_active_findings_within_max_age.metadata.json new file mode 100644 index 0000000000..7846da4f05 --- /dev/null +++ b/prowler/providers/aws/services/inspector2/inspector2_active_findings_within_max_age/inspector2_active_findings_within_max_age.metadata.json @@ -0,0 +1,43 @@ +{ + "Provider": "aws", + "CheckID": "inspector2_active_findings_within_max_age", + "CheckTitle": "Inspector2 has no active findings older than the configured maximum age", + "CheckType": [ + "Software and Configuration Checks/Vulnerabilities/CVE", + "Software and Configuration Checks/Patch Management", + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "inspector2", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Other", + "ResourceGroup": "security", + "Description": "**Amazon Inspector** active findings are evaluated against the configurable `inspector2_active_finding_max_age_days` threshold (192 days by default), using the time since each finding was first observed (`firstObservedAt`). Suppressed and closed findings are not active and are not evaluated.\n\nThe result is reported per Region where Inspector is enabled.", + "Risk": "Findings left open for months show that **vulnerability response** is not keeping up. Long-lived vulnerabilities give attackers time to discover and exploit them, and a backlog that is neither fixed nor formally accepted hides real risk from decision makers.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/inspector/latest/user/findings-understanding.html", + "https://docs.aws.amazon.com/inspector/latest/user/findings-managing-supression-rules.html" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. In the Amazon Inspector console, open Findings and filter by Finding status = Active\n2. Remediate the findings first observed longest ago\n3. For vulnerabilities you formally accept, choose Suppression rules in the navigation pane and create a rule so they stop counting as active", + "Terraform": "" + }, + "Recommendation": { + "Text": "Remediate findings within your vulnerability response timeframes. Vulnerabilities you decide not to fix should be formally **accepted** and suppressed with a documented justification instead of staying active indefinitely.", + "Url": "https://hub.prowler.com/check/inspector2_active_findings_within_max_age" + } + }, + "Categories": [ + "vulnerabilities" + ], + "DependsOn": [], + "RelatedTo": [ + "inspector2_active_findings_exist" + ], + "Notes": "" +} diff --git a/prowler/providers/aws/services/inspector2/inspector2_active_findings_within_max_age/inspector2_active_findings_within_max_age.py b/prowler/providers/aws/services/inspector2/inspector2_active_findings_within_max_age/inspector2_active_findings_within_max_age.py new file mode 100644 index 0000000000..220ee3c9cd --- /dev/null +++ b/prowler/providers/aws/services/inspector2/inspector2_active_findings_within_max_age/inspector2_active_findings_within_max_age.py @@ -0,0 +1,51 @@ +from datetime import datetime, timedelta, timezone + +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.inspector2.inspector2_client import ( + inspector2_client, +) + + +class inspector2_active_findings_within_max_age(Check): + """Ensure no Inspector2 finding stays active longer than the configured days.""" + + def execute(self) -> list[Check_Report_AWS]: + """Report, per Region, whether any active finding is older than the allowed days.""" + findings = [] + max_age_days = inspector2_client.audit_config.get( + "inspector2_active_finding_max_age_days", 192 + ) + max_age = timedelta(days=max_age_days) + now = datetime.now(timezone.utc) + for inspector in inspector2_client.inspectors: + if inspector.status != "ENABLED": + continue + report = Check_Report_AWS(metadata=self.metadata(), resource=inspector) + if inspector.findings is None: + report.status = "MANUAL" + report.status_extended = ( + f"Inspector2 findings could not be retrieved in region {inspector.region}; " + "verify the inspector2:ListFindings permission." + ) + findings.append(report) + continue + stale_ages = [ + now - finding.first_observed_at + for finding in inspector.findings + if finding.first_observed_at + and now - finding.first_observed_at > max_age + ] + if stale_ages: + report.status = "FAIL" + report.status_extended = ( + f"Inspector2 has {len(stale_ages)} active findings in region {inspector.region} " + f"first observed more than {max_age_days} days ago, the oldest {max(stale_ages).days} days ago." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Inspector2 has no active findings in region {inspector.region} " + f"first observed more than {max_age_days} days ago." + ) + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/inspector2/inspector2_coverage_recently_scanned/__init__.py b/prowler/providers/aws/services/inspector2/inspector2_coverage_recently_scanned/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/inspector2/inspector2_coverage_recently_scanned/inspector2_coverage_recently_scanned.metadata.json b/prowler/providers/aws/services/inspector2/inspector2_coverage_recently_scanned/inspector2_coverage_recently_scanned.metadata.json new file mode 100644 index 0000000000..8fc174f17f --- /dev/null +++ b/prowler/providers/aws/services/inspector2/inspector2_coverage_recently_scanned/inspector2_coverage_recently_scanned.metadata.json @@ -0,0 +1,43 @@ +{ + "Provider": "aws", + "CheckID": "inspector2_coverage_recently_scanned", + "CheckTitle": "Inspector2 covered resource was scanned within the configured number of days", + "CheckType": [ + "Software and Configuration Checks/Vulnerabilities/CVE", + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "inspector2", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Other", + "ResourceGroup": "security", + "Description": "**Amazon Inspector** coverage is evaluated for every actively monitored resource. The time since the resource was last scanned (`lastScannedAt`) is compared with the configurable `inspector2_max_days_since_last_scan` threshold (3 days by default).\n\nResources still pending their first scan are not evaluated.", + "Risk": "Stale scans leave **newly published CVEs** and configuration **drift** undetected. A resource that has not been rescanned for weeks can keep running exploitable packages long after a fix is available.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/inspector/latest/user/assessing-coverage.html", + "https://docs.aws.amazon.com/inspector/latest/user/scanning-ecr.html" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. In the Amazon Inspector console, choose Account management and review the Last scanned at value in the Instances, Container images and Lambda functions tabs\n2. For EC2 instances, confirm the SSM Agent is healthy or, under General settings > EC2 scanning settings, set the scan mode to hybrid\n3. For ECR images, increase the Amazon ECR re-scan duration in the Amazon Inspector settings\n4. Confirm the resources are rescanned", + "Terraform": "" + }, + "Recommendation": { + "Text": "Keep continuous scanning healthy: use **hybrid** EC2 scanning so instances without a working SSM agent are still scanned, set a long ECR **rescan duration** for images in use, and investigate every resource whose last scan is older than the allowed window.", + "Url": "https://hub.prowler.com/check/inspector2_coverage_recently_scanned" + } + }, + "Categories": [ + "vulnerabilities" + ], + "DependsOn": [], + "RelatedTo": [ + "inspector2_is_enabled", + "inspector2_coverage_scan_status_active" + ], + "Notes": "" +} diff --git a/prowler/providers/aws/services/inspector2/inspector2_coverage_recently_scanned/inspector2_coverage_recently_scanned.py b/prowler/providers/aws/services/inspector2/inspector2_coverage_recently_scanned/inspector2_coverage_recently_scanned.py new file mode 100644 index 0000000000..0e5a3bf6fd --- /dev/null +++ b/prowler/providers/aws/services/inspector2/inspector2_coverage_recently_scanned/inspector2_coverage_recently_scanned.py @@ -0,0 +1,57 @@ +from datetime import datetime, timedelta, timezone + +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.inspector2.inspector2_client import ( + inspector2_client, +) + +PENDING_SCAN_REASONS = { + "PENDING_INITIAL_SCAN", + "PENDING_REVIVAL_SCAN", + "SCAN_IN_PROGRESS", +} + + +class inspector2_coverage_recently_scanned(Check): + """Ensure Inspector2 scanned every actively covered resource within the configured days.""" + + def execute(self) -> list[Check_Report_AWS]: + """Report whether each actively covered resource was scanned within the allowed days.""" + findings = [] + max_days = inspector2_client.audit_config.get( + "inspector2_max_days_since_last_scan", 3 + ) + max_elapsed = timedelta(days=max_days) + now = datetime.now(timezone.utc) + for inspector in inspector2_client.inspectors: + if inspector.status != "ENABLED": + continue + if inspector.coverage is None: + report = Check_Report_AWS(metadata=self.metadata(), resource=inspector) + report.status = "MANUAL" + report.status_extended = ( + f"Inspector2 coverage could not be retrieved in region {inspector.region}; " + "verify the inspector2:ListCoverage permission." + ) + findings.append(report) + continue + for resource in inspector.coverage: + if resource.scan_status_code != "ACTIVE": + continue + if ( + resource.last_scanned_at is None + and resource.scan_status_reason in PENDING_SCAN_REASONS + ): + continue + report = Check_Report_AWS(metadata=self.metadata(), resource=resource) + if resource.last_scanned_at is None: + report.status = "FAIL" + report.status_extended = f"{resource.resource_type} {resource.id} has no recorded Inspector2 scan." + elif now - resource.last_scanned_at > max_elapsed: + report.status = "FAIL" + report.status_extended = f"{resource.resource_type} {resource.id} was last scanned by Inspector2 more than {max_days} days ago." + else: + report.status = "PASS" + report.status_extended = f"{resource.resource_type} {resource.id} was last scanned by Inspector2 within the last {max_days} days." + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/inspector2/inspector2_coverage_scan_status_active/__init__.py b/prowler/providers/aws/services/inspector2/inspector2_coverage_scan_status_active/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/inspector2/inspector2_coverage_scan_status_active/inspector2_coverage_scan_status_active.metadata.json b/prowler/providers/aws/services/inspector2/inspector2_coverage_scan_status_active/inspector2_coverage_scan_status_active.metadata.json new file mode 100644 index 0000000000..0488e5097f --- /dev/null +++ b/prowler/providers/aws/services/inspector2/inspector2_coverage_scan_status_active/inspector2_coverage_scan_status_active.metadata.json @@ -0,0 +1,42 @@ +{ + "Provider": "aws", + "CheckID": "inspector2_coverage_scan_status_active", + "CheckTitle": "Inspector2 covered resource is actively scanned", + "CheckType": [ + "Software and Configuration Checks/Vulnerabilities/CVE", + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "inspector2", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Other", + "ResourceGroup": "security", + "Description": "**Amazon Inspector** coverage is evaluated for every resource it tracks (EC2 instances, ECR images and repositories, Lambda functions). A resource whose scan status is `INACTIVE`, for example because of `UNMANAGED_EC2_INSTANCE`, `NO_INVENTORY`, `UNSUPPORTED_OS` or `ACCESS_DENIED`, is not being scanned for vulnerabilities.\n\nStopped, terminated, tag-excluded and aged-out resources are not evaluated.", + "Risk": "Resources that Inspector cannot scan silently fall out of **vulnerability detection**. New CVEs affecting those workloads are never reported, so exploitable software can stay deployed while the account still appears covered.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/inspector/latest/user/assessing-coverage.html", + "https://docs.aws.amazon.com/inspector/latest/user/scanning-ec2.html" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. In the Amazon Inspector console, choose Account management\n2. Open the Instances, Container images or Lambda functions tab and review the resources that are not actively scanned\n3. Fix the reported cause: register EC2 instances with Systems Manager or set the EC2 scan mode to hybrid, use supported operating systems and runtimes, and grant access to the required encryption keys\n4. Confirm the resource is actively scanned", + "Terraform": "" + }, + "Recommendation": { + "Text": "Resolve the reason reported in each inactive resource's scan status so Inspector can scan every in-scope workload. Register EC2 instances with **Systems Manager** or enable **hybrid scanning**, keep operating systems and runtimes supported, and treat scanning gaps as vulnerabilities to track.", + "Url": "https://hub.prowler.com/check/inspector2_coverage_scan_status_active" + } + }, + "Categories": [ + "vulnerabilities" + ], + "DependsOn": [], + "RelatedTo": [ + "inspector2_is_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/aws/services/inspector2/inspector2_coverage_scan_status_active/inspector2_coverage_scan_status_active.py b/prowler/providers/aws/services/inspector2/inspector2_coverage_scan_status_active/inspector2_coverage_scan_status_active.py new file mode 100644 index 0000000000..c1cd63cc02 --- /dev/null +++ b/prowler/providers/aws/services/inspector2/inspector2_coverage_scan_status_active/inspector2_coverage_scan_status_active.py @@ -0,0 +1,46 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.inspector2.inspector2_client import ( + inspector2_client, +) + +NOT_APPLICABLE_SCAN_REASONS = { + "EC2_INSTANCE_STOPPED", + "EXCLUDED_BY_TAG", + "NO_RESOURCES_FOUND", + "PENDING_DISABLE", + "RESOURCE_TERMINATED", + "SCAN_ELIGIBILITY_EXPIRED", +} + + +class inspector2_coverage_scan_status_active(Check): + """Ensure Inspector2 is actively scanning every covered resource.""" + + def execute(self) -> list[Check_Report_AWS]: + """Report whether Inspector2 is actively scanning each covered resource.""" + findings = [] + for inspector in inspector2_client.inspectors: + if inspector.status != "ENABLED": + continue + if inspector.coverage is None: + report = Check_Report_AWS(metadata=self.metadata(), resource=inspector) + report.status = "MANUAL" + report.status_extended = ( + f"Inspector2 coverage could not be retrieved in region {inspector.region}; " + "verify the inspector2:ListCoverage permission." + ) + findings.append(report) + continue + for resource in inspector.coverage: + if resource.scan_status_reason in NOT_APPLICABLE_SCAN_REASONS: + continue + report = Check_Report_AWS(metadata=self.metadata(), resource=resource) + if resource.scan_status_code == "ACTIVE": + report.status = "PASS" + report.status_extended = f"Inspector2 is actively scanning {resource.resource_type} {resource.id}." + else: + report.status = "FAIL" + reason = resource.scan_status_reason or "no reason reported" + report.status_extended = f"Inspector2 is not scanning {resource.resource_type} {resource.id}: {reason}." + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/inspector2/inspector2_service.py b/prowler/providers/aws/services/inspector2/inspector2_service.py index cc6dac7413..6acf186a66 100644 --- a/prowler/providers/aws/services/inspector2/inspector2_service.py +++ b/prowler/providers/aws/services/inspector2/inspector2_service.py @@ -1,16 +1,33 @@ +from datetime import datetime +from typing import Optional + from pydantic.v1 import BaseModel from prowler.lib.logger import logger +from prowler.lib.scan_filters.scan_filters import is_resource_filtered from prowler.providers.aws.lib.service.service import AWSService +FINDING_DETAILS_BATCH_SIZE = 10 + class Inspector2(AWSService): def __init__(self, provider): # Call AWSService's __init__ super().__init__(__class__.__name__, provider) self.inspectors = [] + self.known_exploited_vulnerabilities = {} + self.vulnerability_lookup_failed = set() self.__threading_call__(self._batch_get_account_status) self.__threading_call__(self._list_active_findings, self.inspectors) + enabled_inspectors = [ + inspector for inspector in self.inspectors if inspector.status == "ENABLED" + ] + self.__threading_call__(self._list_findings, enabled_inspectors) + self.__threading_call__(self._list_coverage, enabled_inspectors) + self.__threading_call__( + self._batch_get_finding_details, + self._get_finding_detail_batches(enabled_inspectors), + ) def _batch_get_account_status(self, regional_client): # We use this function to check if inspector2 is enabled @@ -59,6 +76,188 @@ class Inspector2(AWSService): f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _list_findings(self, inspector): + """Store the active findings of the audited account for an enabled Region.""" + logger.info("Inspector2 - Listing active findings details...") + try: + paginator = self.regional_clients[inspector.region].get_paginator( + "list_findings" + ) + findings = [] + for page in paginator.paginate( + filterCriteria={ + "awsAccountId": [ + {"comparison": "EQUALS", "value": self.audited_account}, + ], + "findingStatus": [{"comparison": "EQUALS", "value": "ACTIVE"}], + }, + PaginationConfig={"PageSize": 100}, + ): + for finding in page.get("findings", []): + findings.append( + Finding( + arn=finding.get("findingArn", ""), + type=finding.get("type", ""), + severity=finding.get("severity", ""), + first_observed_at=finding.get("firstObservedAt"), + vulnerability_id=finding.get( + "packageVulnerabilityDetails", {} + ).get("vulnerabilityId"), + resource_ids=[ + resource["id"] + for resource in finding.get("resources", []) + if resource.get("id") + ], + ) + ) + inspector.findings = findings + except Exception as error: + logger.error( + f"{inspector.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _list_coverage(self, inspector): + """Store the resources Inspector2 covers in an enabled Region, respecting audit resources.""" + logger.info("Inspector2 - Listing coverage...") + try: + paginator = self.regional_clients[inspector.region].get_paginator( + "list_coverage" + ) + coverage = [] + for page in paginator.paginate( + filterCriteria={ + "accountId": [ + {"comparison": "EQUALS", "value": self.audited_account}, + ], + }, + PaginationConfig={"PageSize": 200}, + ): + for covered_resource in page.get("coveredResources", []): + resource_id = covered_resource.get("resourceId", "") + resource_type = covered_resource.get("resourceType", "") + scan_status = covered_resource.get("scanStatus", {}) + arn = self._get_covered_resource_arn( + resource_type, resource_id, inspector.region + ) + if self.audit_resources and not is_resource_filtered( + arn, self.audit_resources + ): + continue + coverage.append( + CoveredResource( + id=resource_id, + arn=arn, + region=inspector.region, + resource_type=resource_type, + scan_type=covered_resource.get("scanType", ""), + scan_status_code=scan_status.get("statusCode", ""), + scan_status_reason=scan_status.get("reason", ""), + last_scanned_at=covered_resource.get("lastScannedAt"), + ) + ) + inspector.coverage = coverage + except Exception as error: + logger.error( + f"{inspector.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _get_covered_resource_arn(self, resource_type, resource_id, region): + """Return the ARN of a covered resource, building it for EC2 instance IDs.""" + if resource_type == "AWS_EC2_INSTANCE" and not resource_id.startswith("arn:"): + return f"arn:{self.audited_partition}:ec2:{region}:{self.audited_account}:instance/{resource_id}" + return resource_id + + @staticmethod + def _get_finding_detail_batches(inspectors): + """Group one active finding per CVE into finding details batches per Region.""" + representatives = {} + for inspector in inspectors: + for finding in inspector.findings or []: + if finding.vulnerability_id and finding.vulnerability_id.startswith( + "CVE-" + ): + representatives.setdefault( + finding.vulnerability_id, (inspector.region, finding.arn) + ) + findings_by_region = {} + for vulnerability_id, (region, finding_arn) in representatives.items(): + findings_by_region.setdefault(region, []).append( + (finding_arn, vulnerability_id) + ) + return [ + (region, findings[index : index + FINDING_DETAILS_BATCH_SIZE]) + for region, findings in findings_by_region.items() + for index in range(0, len(findings), FINDING_DETAILS_BATCH_SIZE) + ] + + def _batch_get_finding_details(self, batch): + """Record the CISA KEV data of the CVEs in a batch, flagging failed lookups.""" + region, findings = batch + vulnerability_ids = dict(findings) + logger.info("Inspector2 - Getting finding details...") + try: + response = self.regional_clients[region].batch_get_finding_details( + findingArns=list(vulnerability_ids) + ) + for detail in response.get("findingDetails", []): + vulnerability_id = vulnerability_ids.get(detail.get("findingArn")) + cisa_data = detail.get("cisaData") + if vulnerability_id and cisa_data: + self.known_exploited_vulnerabilities[vulnerability_id] = ( + KnownExploitedVulnerability( + id=vulnerability_id, + date_added=cisa_data.get("dateAdded"), + date_due=cisa_data.get("dateDue"), + ) + ) + for detail_error in response.get("errors", []): + # Inspector has no intelligence for the CVE, so it cannot be a KEV + if detail_error.get("errorCode") == "FINDING_DETAILS_NOT_FOUND": + continue + vulnerability_id = vulnerability_ids.get(detail_error.get("findingArn")) + if vulnerability_id: + self.vulnerability_lookup_failed.add(vulnerability_id) + logger.error( + f"{region} -- {detail_error.get('errorCode')} getting finding details for {vulnerability_id}: {detail_error.get('errorMessage')}" + ) + except Exception as error: + self.vulnerability_lookup_failed.update(vulnerability_ids.values()) + logger.error( + f"{region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class Finding(BaseModel): + """Active Inspector2 finding.""" + + arn: str + type: str + severity: str + first_observed_at: Optional[datetime] + vulnerability_id: Optional[str] + resource_ids: list[str] = [] + + +class CoveredResource(BaseModel): + """Resource tracked by Inspector2 coverage.""" + + id: str + arn: str + region: str + resource_type: str + scan_type: str + scan_status_code: str + scan_status_reason: str + last_scanned_at: Optional[datetime] + + +class KnownExploitedVulnerability(BaseModel): + """CISA Known Exploited Vulnerability data of a CVE.""" + + id: str + date_added: Optional[datetime] + date_due: Optional[datetime] + class Inspector(BaseModel): id: str @@ -70,3 +269,5 @@ class Inspector(BaseModel): lambda_status: str lambda_code_status: str active_findings: bool = None + findings: Optional[list[Finding]] = None + coverage: Optional[list[CoveredResource]] = None diff --git a/prowler/providers/aws/services/inspector2/lib/__init__.py b/prowler/providers/aws/services/inspector2/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/inspector2/lib/vulnerabilities.py b/prowler/providers/aws/services/inspector2/lib/vulnerabilities.py new file mode 100644 index 0000000000..7544f0de4d --- /dev/null +++ b/prowler/providers/aws/services/inspector2/lib/vulnerabilities.py @@ -0,0 +1,8 @@ +MAX_LISTED_VULNERABILITIES = 10 + + +def summarize_vulnerabilities(vulnerabilities: list[str]) -> str: + """Join vulnerability identifiers, truncating long lists.""" + listed = ", ".join(vulnerabilities[:MAX_LISTED_VULNERABILITIES]) + remaining = len(vulnerabilities) - MAX_LISTED_VULNERABILITIES + return f"{listed} and {remaining} more" if remaining > 0 else listed diff --git a/prowler/providers/aws/services/rolesanywhere/rolesanywhere_profile_restricts_session_permissions/rolesanywhere_profile_restricts_session_permissions.py b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_profile_restricts_session_permissions/rolesanywhere_profile_restricts_session_permissions.py index 5138b74ff3..2960518a67 100644 --- a/prowler/providers/aws/services/rolesanywhere/rolesanywhere_profile_restricts_session_permissions/rolesanywhere_profile_restricts_session_permissions.py +++ b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_profile_restricts_session_permissions/rolesanywhere_profile_restricts_session_permissions.py @@ -205,7 +205,9 @@ class rolesanywhere_profile_restricts_session_permissions(Check): not administrative, and disabled profiles. """ findings = [] - roles_by_arn = {role.arn: role for role in iam_client.roles} + # iam:ListRoles denied leaves roles as None: every referenced role is + # then unknown and the profile falls through to MANUAL. + roles_by_arn = {role.arn: role for role in (iam_client.roles or [])} for profile in rolesanywhere_client.profiles.values(): report = Check_Report_AWS(metadata=self.metadata(), resource=profile) role_statuses = { diff --git a/prowler/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/rolesanywhere_trust_anchor_pqc_pki.py b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/rolesanywhere_trust_anchor_pqc_pki.py index 495ed4e9c7..b85e387273 100644 --- a/prowler/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/rolesanywhere_trust_anchor_pqc_pki.py +++ b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/rolesanywhere_trust_anchor_pqc_pki.py @@ -15,10 +15,13 @@ class rolesanywhere_trust_anchor_pqc_pki(Check): """Verify that IAM Roles Anywhere trust anchors are backed by a post-quantum PKI. For trust anchors whose source is ``AWS_ACM_PCA``, the linked Private CA's - ``KeyAlgorithm`` is checked against the configured ML-DSA allowlist. + ``KeyAlgorithm`` is checked against the configured ML-DSA allowlist. A CA + that exists but cannot be inspected (cross-account or missing acm-pca + permissions) is a data-availability gap and is reported as MANUAL. Trust anchors backed by an external ``CERTIFICATE_BUNDLE`` are reported as - FAIL because their certificate signature algorithm cannot be inspected - from the IAM Roles Anywhere API alone. + FAIL by design: the bundle is user-supplied rather than an AWS-managed CA, + so migrating to an ML-DSA AWS Private CA is the remediation regardless of + the bundle's contents. """ def execute(self) -> list[Check_Report_AWS]: @@ -56,13 +59,13 @@ class rolesanywhere_trust_anchor_pqc_pki(Check): "post-quantum (ML-DSA)." ) else: - report.status = "FAIL" + report.status = "MANUAL" report.status_extended = ( f"IAM Roles Anywhere trust anchor {trust_anchor.name} is " f"backed by Private CA {trust_anchor.acm_pca_arn}, which " "could not be inspected (cross-account or missing " - "acm-pca permissions). Verify the CA uses an ML-DSA key " - "algorithm." + "acm-pca permissions). Verify manually that the CA uses " + "an ML-DSA key algorithm." ) else: source = trust_anchor.source_type or "" diff --git a/prowler/providers/aws/services/s3/s3_bucket_cross_region_replication/s3_bucket_cross_region_replication.py b/prowler/providers/aws/services/s3/s3_bucket_cross_region_replication/s3_bucket_cross_region_replication.py index 21c33ed895..d30e4d8ad9 100644 --- a/prowler/providers/aws/services/s3/s3_bucket_cross_region_replication/s3_bucket_cross_region_replication.py +++ b/prowler/providers/aws/services/s3/s3_bucket_cross_region_replication/s3_bucket_cross_region_replication.py @@ -3,12 +3,31 @@ from prowler.providers.aws.services.s3.s3_client import s3_client class s3_bucket_cross_region_replication(Check): - def execute(self): + """Ensure S3 buckets replicate to a bucket in a different region. + + - PASS: At least one enabled replication rule targets a bucket in another region. + - FAIL: Versioning is disabled, no enabled rule exists, or every resolvable + destination is in the same region. + - MANUAL: The versioning or replication configuration could not be retrieved + (missing permissions), or a destination bucket is outside the audited + account/scope so its region cannot be determined. + """ + + def execute(self) -> list[Check_Report_AWS]: findings = [] for bucket in s3_client.buckets.values(): report = Check_Report_AWS(metadata=self.metadata(), resource=bucket) + + if not bucket.versioning_retrieved or not bucket.replication_retrieved: + report.status = "MANUAL" + report.status_extended = f"Cannot evaluate cross region replication for S3 Bucket {bucket.name}: the versioning or replication configuration could not be retrieved. Verify that the scanning credentials are allowed to call s3:GetBucketVersioning and s3:GetReplicationConfiguration." + findings.append(report) + continue + report.status = "FAIL" report.status_extended = f"S3 Bucket {bucket.name} does not have correct cross region replication configuration." + unresolvable_report = None + same_region_report = None if bucket.replication_rules: for rule in bucket.replication_rules: if ( @@ -17,8 +36,7 @@ class s3_bucket_cross_region_replication(Check): and rule.destination ): if rule.destination not in s3_client.buckets: - report.status = "FAIL" - report.status_extended = f"S3 Bucket {bucket.name} has cross region replication rule {rule.id} in bucket {rule.destination.split(':')[-1]} which is out of Prowler's scope." + unresolvable_report = f"S3 Bucket {bucket.name} has cross region replication rule {rule.id} in bucket {rule.destination.split(':')[-1]} which is out of Prowler's scope; verify manually that the destination bucket is in a different region." else: destination_bucket = s3_client.buckets[rule.destination] if destination_bucket.region != bucket.region: @@ -26,8 +44,14 @@ class s3_bucket_cross_region_replication(Check): report.status_extended = f"S3 Bucket {bucket.name} has cross region replication rule {rule.id} in bucket {destination_bucket.name} located in region {destination_bucket.region}." break else: - report.status = "FAIL" - report.status_extended = f"S3 Bucket {bucket.name} has cross region replication rule {rule.id} in bucket {destination_bucket.name} located in the same region." + same_region_report = f"S3 Bucket {bucket.name} has cross region replication rule {rule.id} in bucket {destination_bucket.name} located in the same region." + # Precedence: PASS > MANUAL (unresolvable destination) > FAIL + if report.status != "PASS": + if unresolvable_report: + report.status = "MANUAL" + report.status_extended = unresolvable_report + elif same_region_report: + report.status_extended = same_region_report findings.append(report) return findings diff --git a/prowler/providers/aws/services/s3/s3_bucket_object_versioning/s3_bucket_object_versioning.py b/prowler/providers/aws/services/s3/s3_bucket_object_versioning/s3_bucket_object_versioning.py index 27e88635ff..9652d8fa84 100644 --- a/prowler/providers/aws/services/s3/s3_bucket_object_versioning/s3_bucket_object_versioning.py +++ b/prowler/providers/aws/services/s3/s3_bucket_object_versioning/s3_bucket_object_versioning.py @@ -3,11 +3,27 @@ from prowler.providers.aws.services.s3.s3_client import s3_client class s3_bucket_object_versioning(Check): - def execute(self): + """Ensure S3 buckets have object versioning enabled. + + - PASS: Versioning is enabled. + - FAIL: Versioning is disabled. + - MANUAL: The versioning configuration could not be retrieved (missing + permissions), so the status cannot be asserted. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Evaluate versioning for every audited bucket. + + Returns: + list[Check_Report_AWS]: One report per bucket. + """ findings = [] for bucket in s3_client.buckets.values(): report = Check_Report_AWS(metadata=self.metadata(), resource=bucket) - if bucket.versioning: + if not bucket.versioning_retrieved: + report.status = "MANUAL" + report.status_extended = f"Cannot evaluate versioning for S3 Bucket {bucket.name}: the versioning configuration could not be retrieved. Verify that the scanning credentials are allowed to call s3:GetBucketVersioning." + elif bucket.versioning: report.status = "PASS" report.status_extended = ( f"S3 Bucket {bucket.name} has versioning enabled." diff --git a/prowler/providers/aws/services/s3/s3_service.py b/prowler/providers/aws/services/s3/s3_service.py index 94768138df..32c1f67c65 100644 --- a/prowler/providers/aws/services/s3/s3_service.py +++ b/prowler/providers/aws/services/s3/s3_service.py @@ -122,10 +122,12 @@ class S3(AWSService): f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) else: + bucket.versioning_retrieved = False logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) except Exception as error: + bucket.versioning_retrieved = False if bucket.region: logger.error( f"{bucket.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" @@ -441,10 +443,12 @@ class S3(AWSService): ): bucket.replication = None else: + bucket.replication_retrieved = False logger.error( f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) except Exception as error: + bucket.replication_retrieved = False if regional_client: logger.error( f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" @@ -782,5 +786,9 @@ class Bucket(BaseModel): tags: List[Dict[str, str]] = Field(default_factory=list) lifecycle: List[LifeCycleRule] = Field(default_factory=list) replication_rules: List[ReplicationRule] = Field(default_factory=list) + # False when GetBucketVersioning / GetBucketReplication failed for a reason + # other than the bucket or configuration not existing (e.g. AccessDenied). + versioning_retrieved: bool = True + replication_retrieved: bool = True notification_config: Dict = Field(default_factory=dict) object_sampling: Optional[BucketObjectSampling] = None diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_notebook_instance_without_direct_internet_access_configured/sagemaker_notebook_instance_without_direct_internet_access_configured.py b/prowler/providers/aws/services/sagemaker/sagemaker_notebook_instance_without_direct_internet_access_configured/sagemaker_notebook_instance_without_direct_internet_access_configured.py index 8acfee1522..9e326e5e18 100644 --- a/prowler/providers/aws/services/sagemaker/sagemaker_notebook_instance_without_direct_internet_access_configured/sagemaker_notebook_instance_without_direct_internet_access_configured.py +++ b/prowler/providers/aws/services/sagemaker/sagemaker_notebook_instance_without_direct_internet_access_configured/sagemaker_notebook_instance_without_direct_internet_access_configured.py @@ -3,7 +3,21 @@ from prowler.providers.aws.services.sagemaker.sagemaker_client import sagemaker_ class sagemaker_notebook_instance_without_direct_internet_access_configured(Check): - def execute(self): + """Ensure that SageMaker notebook instances have direct internet access disabled.""" + + def execute(self) -> list[Check_Report_AWS]: + """Report whether each notebook instance has direct internet access disabled. + + - PASS: DirectInternetAccess was read and is Disabled. + - FAIL: DirectInternetAccess was read and is Enabled, so the instance reaches the + internet directly rather than only through the VPC. + - MANUAL: DescribeNotebookInstance did not report DirectInternetAccess. Absent is not + Disabled, and the two cannot share a value here or an instance whose setting was + never read would be reported compliant. + + Returns: + One report per notebook instance in the inventory. + """ findings = [] for notebook_instance in sagemaker_client.sagemaker_notebook_instances: report = Check_Report_AWS( @@ -11,7 +25,13 @@ class sagemaker_notebook_instance_without_direct_internet_access_configured(Chec ) report.status = "PASS" report.status_extended = f"Sagemaker notebook instance {notebook_instance.name} has direct internet access disabled." - if notebook_instance.direct_internet_access: + if notebook_instance.direct_internet_access is None: + # DescribeNotebookInstance did not report DirectInternetAccess, so the + # setting is unknown. Defaulting to PASS would report an unread instance + # as compliant. + report.status = "MANUAL" + report.status_extended = f"Sagemaker notebook instance {notebook_instance.name} did not report DirectInternetAccess, so it could not be determined; verify manually." + elif notebook_instance.direct_internet_access: report.status = "FAIL" report.status_extended = f"Sagemaker notebook instance {notebook_instance.name} has direct internet access enabled." diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_service.py b/prowler/providers/aws/services/sagemaker/sagemaker_service.py index cd0d79933f..be89bf538c 100644 --- a/prowler/providers/aws/services/sagemaker/sagemaker_service.py +++ b/prowler/providers/aws/services/sagemaker/sagemaker_service.py @@ -204,6 +204,39 @@ class SageMaker(AWSService): ) def _describe_notebook_instance(self, notebook_instance): + """Read one notebook instance's settings into the inventory. + + Args: + notebook_instance: The NotebookInstance to populate, identified by + name and region. + + DirectInternetAccess and RootAccess are unrelated settings, and this + method previously guarded on the presence of the first while reading + the value of the second. Three consequences followed, all of them here + rather than in the check that consumes this: + + - An instance with DirectInternetAccess Enabled and RootAccess Disabled + was recorded as having no direct internet access, and reported PASS. + - An instance with DirectInternetAccess Disabled and RootAccess Enabled + was recorded as having it, and reported FAIL. + - With RootAccess absent, subscripting it raised KeyError. The + enclosing ``except Exception`` swallowed that, so the assignments + below it never ran and ``kms_key_id`` and ``lifecycle_config_name`` + were left None for an instance that has them -- degrading checks that + read those fields and never touch this one. + + The third is reachable rather than theoretical: + DescribeNotebookInstanceOutput declares 23 members and carries no + ``required`` key at all at the pinned botocore, so both fields are + optional and a response with one and not the other is legal. + + DirectInternetAccess is therefore assigned in BOTH states. Setting only + the True case would leave None meaning either Disabled or never-read, + and the check has to tell those apart: it reports MANUAL for never-read + rather than defaulting to PASS, which would assert compliance from an + absent answer. Collapsing the two states here would take that + distinction away from it. + """ logger.info("SageMaker - describing notebook instances...") try: regional_client = self.regional_clients[notebook_instance.region] @@ -223,11 +256,13 @@ class SageMaker(AWSService): notebook_instance.root_access = True if "SubnetId" in describe_notebook_instance: notebook_instance.subnet_id = describe_notebook_instance["SubnetId"] - if ( - "DirectInternetAccess" in describe_notebook_instance - and describe_notebook_instance["RootAccess"] == "Enabled" - ): - notebook_instance.direct_internet_access = True + if "DirectInternetAccess" in describe_notebook_instance: + # Assign both states, not just the enabled one. Left as None, "Disabled" + # and "the field was never read" are the same value, and the check + # defaults to PASS -- so an unreadable notebook instance reported clean. + notebook_instance.direct_internet_access = ( + describe_notebook_instance["DirectInternetAccess"] == "Enabled" + ) if "KmsKeyId" in describe_notebook_instance: notebook_instance.kms_key_id = describe_notebook_instance["KmsKeyId"] if "NotebookInstanceLifecycleConfigName" in describe_notebook_instance: @@ -553,7 +588,8 @@ class NotebookInstance(BaseModel): arn: str root_access: bool = None subnet_id: str = None - direct_internet_access: bool = None + # None when DescribeNotebookInstance did not report the field: unknown, not disabled. + direct_internet_access: Optional[bool] = None kms_key_id: str = None lifecycle_config_name: str = None # Decoded lifecycle scripts keyed by "[]" (e.g. "OnStart[0]"), diff --git a/prowler/providers/aws/services/transfer/transfer_server_fips_security_policy_enabled/__init__.py b/prowler/providers/aws/services/transfer/transfer_server_fips_security_policy_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/transfer/transfer_server_fips_security_policy_enabled/transfer_server_fips_security_policy_enabled.metadata.json b/prowler/providers/aws/services/transfer/transfer_server_fips_security_policy_enabled/transfer_server_fips_security_policy_enabled.metadata.json new file mode 100644 index 0000000000..c1d8b48e20 --- /dev/null +++ b/prowler/providers/aws/services/transfer/transfer_server_fips_security_policy_enabled/transfer_server_fips_security_policy_enabled.metadata.json @@ -0,0 +1,42 @@ +{ + "Provider": "aws", + "CheckID": "transfer_server_fips_security_policy_enabled", + "CheckTitle": "AWS Transfer Family server uses a FIPS security policy", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "transfer", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "AwsTransferServer", + "ResourceGroup": "network", + "Description": "**AWS Transfer Family servers** (SFTP, FTPS, AS2) are assessed for use of a **FIPS** security policy (`TransferSecurityPolicy-FIPS-*`, flagged `Fips: true` by AWS), which limits file-transfer sessions to the FIPS-enabled set of SSH and TLS algorithms.", + "Risk": "Servers without a FIPS security policy can negotiate algorithms outside the FIPS-enabled set, which does not meet requirements to protect federal or regulated files and credentials with **NIST CMVP validated cryptography**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/transfer/latest/userguide/security-policies.html", + "https://aws.amazon.com/compliance/fips/" + ], + "Remediation": { + "Code": { + "CLI": "aws transfer update-server --server-id --security-policy-name TransferSecurityPolicy-FIPS-2025-03", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::Transfer::Server\n Properties:\n Protocols:\n - SFTP\n SecurityPolicyName: TransferSecurityPolicy-FIPS-2025-03 # FIX: FIPS security policy\n```", + "Other": "1. In the AWS Console, go to AWS Transfer Family > Servers\n2. Select the server and choose Edit on the Additional details panel\n3. Set Cryptographic algorithm options (Security policy) to a FIPS policy such as TransferSecurityPolicy-FIPS-2025-03\n4. Save the changes", + "Terraform": "```hcl\nresource \"aws_transfer_server\" \"\" {\n protocols = [\"SFTP\"]\n security_policy_name = \"TransferSecurityPolicy-FIPS-2025-03\" # FIX: FIPS security policy\n}\n```" + }, + "Recommendation": { + "Text": "Use a **FIPS** security policy, such as `TransferSecurityPolicy-FIPS-2025-03`, on every Transfer Family server that exchanges federal or regulated data.", + "Url": "https://hub.prowler.com/check/transfer_server_fips_security_policy_enabled" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [ + "transfer_server_in_transit_encryption_enabled", + "transfer_server_pqc_ssh_kex_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/aws/services/transfer/transfer_server_fips_security_policy_enabled/transfer_server_fips_security_policy_enabled.py b/prowler/providers/aws/services/transfer/transfer_server_fips_security_policy_enabled/transfer_server_fips_security_policy_enabled.py new file mode 100644 index 0000000000..99d86097ba --- /dev/null +++ b/prowler/providers/aws/services/transfer/transfer_server_fips_security_policy_enabled/transfer_server_fips_security_policy_enabled.py @@ -0,0 +1,38 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.transfer.transfer_client import transfer_client + + +class transfer_server_fips_security_policy_enabled(Check): + """Ensure every AWS Transfer Family server uses a FIPS security policy.""" + + def execute(self) -> list[Check_Report_AWS]: + """Report whether each Transfer Family server uses a FIPS security policy.""" + findings = [] + unretrieved_servers = [] + for server in transfer_client.servers.values(): + policy = server.security_policy_name + if not policy: + unretrieved_servers.append(server.id) + continue + report = Check_Report_AWS(metadata=self.metadata(), resource=server) + if "FIPS" in policy.split("-"): + report.status = "PASS" + report.status_extended = ( + f"Transfer Server {server.id} uses FIPS security policy {policy}." + ) + else: + report.status = "FAIL" + report.status_extended = f"Transfer Server {server.id} uses security policy {policy}, which is not a FIPS security policy." + findings.append(report) + if unretrieved_servers: + report = Check_Report_AWS(metadata=self.metadata(), resource={}) + report.resource_id = transfer_client.audited_account + report.resource_arn = transfer_client.audited_account_arn + report.region = transfer_client.region + report.status = "MANUAL" + report.status_extended = ( + "Transfer Server security policies could not be retrieved for " + f"{', '.join(unretrieved_servers)}; verify the transfer:DescribeServer permission." + ) + findings.append(report) + return findings diff --git a/prowler/providers/azure/lib/service/service.py b/prowler/providers/azure/lib/service/service.py index 9d63639e94..9647723173 100644 --- a/prowler/providers/azure/lib/service/service.py +++ b/prowler/providers/azure/lib/service/service.py @@ -27,6 +27,7 @@ class AzureService: ) self.subscriptions = provider.identity.subscriptions + self.region_config = provider.region_config self.resource_groups = provider.resource_groups self.locations = provider.locations self.audit_config = provider.audit_config diff --git a/prowler/providers/azure/models.py b/prowler/providers/azure/models.py index 62d03db365..5df80afab6 100644 --- a/prowler/providers/azure/models.py +++ b/prowler/providers/azure/models.py @@ -18,8 +18,8 @@ class AzureIdentityInfo(BaseModel): class AzureRegionConfig(BaseModel): name: str = "" authority: Optional[str] = None - base_url: str = "" - credential_scopes: list = [] + base_url: str = "https://management.azure.com" + credential_scopes: list = ["https://management.azure.com/.default"] graph_host: str = "https://graph.microsoft.com" graph_scope: str = "https://graph.microsoft.com/.default" logs_endpoint: str = "https://api.loganalytics.io" diff --git a/prowler/providers/azure/services/defender/defender_service.py b/prowler/providers/azure/services/defender/defender_service.py index d68d88dc22..17777843e4 100644 --- a/prowler/providers/azure/services/defender/defender_service.py +++ b/prowler/providers/azure/services/defender/defender_service.py @@ -12,7 +12,16 @@ from prowler.providers.azure.lib.service.service import AzureService class Defender(AzureService): + """Microsoft Defender for Cloud service: pricings, settings, assessments, + security contacts, IoT solutions and JIT policies per subscription.""" + def __init__(self, provider: AzureProvider): + """Collect the Defender configuration of every audited subscription. + + Args: + provider: Azure provider supplying the session, subscriptions and + the region config whose endpoints are used for every call. + """ super().__init__(SecurityCenter, provider) self.pricings = self._get_pricings() @@ -21,7 +30,7 @@ class Defender(AzureService): self.settings = self._get_settings() self.security_contact_configurations = self._get_security_contacts( token=provider.session.get_token( - "https://management.azure.com/.default" + *self.region_config.credential_scopes ).token ) self.iot_security_solutions = self._get_iot_security_solutions() @@ -168,7 +177,7 @@ class Defender(AzureService): security_contacts = {} for subscription_id, display_name in self.subscriptions.items(): try: - url = f"https://management.azure.com/subscriptions/{subscription_id}/providers/Microsoft.Security/securityContacts?api-version=2023-12-01-preview" + url = f"{self.region_config.base_url}/subscriptions/{subscription_id}/providers/Microsoft.Security/securityContacts?api-version=2023-12-01-preview" headers = { "Authorization": f"Bearer {token}", "Content-Type": "application/json", diff --git a/prowler/providers/azure/services/entra/entra_global_admin_in_less_than_five_users/entra_global_admin_in_less_than_five_users.py b/prowler/providers/azure/services/entra/entra_global_admin_in_less_than_five_users/entra_global_admin_in_less_than_five_users.py index cfcb3c8514..09701d613e 100644 --- a/prowler/providers/azure/services/entra/entra_global_admin_in_less_than_five_users/entra_global_admin_in_less_than_five_users.py +++ b/prowler/providers/azure/services/entra/entra_global_admin_in_less_than_five_users/entra_global_admin_in_less_than_five_users.py @@ -7,6 +7,21 @@ class entra_global_admin_in_less_than_five_users(Check): findings = [] for tenant_domain, directory_roles in entra_client.directory_roles.items(): + if tenant_domain in entra_client.users_retrieval_errors: + report = Check_Report_Azure(metadata=self.metadata(), resource={}) + report.subscription = f"Tenant: {tenant_domain}" + report.resource_name = tenant_domain + report.resource_id = entra_client.tenant_ids[0] + report.status = "MANUAL" + report.status_extended = ( + f"Cannot evaluate the number of global administrators for tenant {tenant_domain}: " + f"Microsoft Graph did not return the tenant's users " + f"({entra_client.users_retrieval_errors[tenant_domain]}). " + f"Retry the scan or review the tenant's global administrators manually." + ) + findings.append(report) + continue + report = Check_Report_Azure( metadata=self.metadata(), resource=directory_roles.get("Global Administrator", {}), diff --git a/prowler/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa.py b/prowler/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa.py index d231a7a6b1..4494a2e99c 100644 --- a/prowler/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa.py +++ b/prowler/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa.py @@ -10,6 +10,21 @@ class entra_non_privileged_user_has_mfa(Check): findings = [] for tenant_domain, users in entra_client.users.items(): + if tenant_domain in entra_client.users_retrieval_errors: + report = Check_Report_Azure(metadata=self.metadata(), resource={}) + report.subscription = f"Tenant: {tenant_domain}" + report.resource_name = tenant_domain + report.resource_id = entra_client.tenant_ids[0] + report.status = "MANUAL" + report.status_extended = ( + f"Cannot evaluate MFA for the tenant's non-privileged users for tenant {tenant_domain}: " + f"Microsoft Graph did not return the tenant's users " + f"({entra_client.users_retrieval_errors[tenant_domain]}). " + f"Retry the scan or review the tenant's users manually." + ) + findings.append(report) + continue + for user in users.values(): if user.account_enabled and not is_privileged_user( user, entra_client.directory_roles[tenant_domain] diff --git a/prowler/providers/azure/services/entra/entra_privileged_user_has_mfa/entra_privileged_user_has_mfa.py b/prowler/providers/azure/services/entra/entra_privileged_user_has_mfa/entra_privileged_user_has_mfa.py index c8c625f927..8499d29982 100644 --- a/prowler/providers/azure/services/entra/entra_privileged_user_has_mfa/entra_privileged_user_has_mfa.py +++ b/prowler/providers/azure/services/entra/entra_privileged_user_has_mfa/entra_privileged_user_has_mfa.py @@ -10,6 +10,21 @@ class entra_privileged_user_has_mfa(Check): findings = [] for tenant_domain, users in entra_client.users.items(): + if tenant_domain in entra_client.users_retrieval_errors: + report = Check_Report_Azure(metadata=self.metadata(), resource={}) + report.subscription = f"Tenant: {tenant_domain}" + report.resource_name = tenant_domain + report.resource_id = entra_client.tenant_ids[0] + report.status = "MANUAL" + report.status_extended = ( + f"Cannot evaluate MFA for the tenant's privileged users for tenant {tenant_domain}: " + f"Microsoft Graph did not return the tenant's users " + f"({entra_client.users_retrieval_errors[tenant_domain]}). " + f"Retry the scan or review the tenant's users manually." + ) + findings.append(report) + continue + for user_domain_name, user in users.items(): if is_privileged_user( user, entra_client.directory_roles[tenant_domain] diff --git a/prowler/providers/azure/services/entra/entra_service.py b/prowler/providers/azure/services/entra/entra_service.py index e23f3cd34a..1c249abe0d 100644 --- a/prowler/providers/azure/services/entra/entra_service.py +++ b/prowler/providers/azure/services/entra/entra_service.py @@ -39,6 +39,18 @@ class Entra(AzureService): "Cannot initialize Entra service while event loop is running" ) + # Tenants (keyed by domain) whose sign-in activity could not be read, + # mapped to the reason. Microsoft Graph rejects the whole /users request + # with a 403 when the tenant lacks Entra ID P1/P2 or the application + # lacks AuditLog.Read.All, so users are re-fetched without + # signInActivity and the tenant is recorded here. + self.sign_in_activity_errors: dict[str, str] = {} + # Tenants (keyed by domain) whose users could not be retrieved at all + # (throttling, 5xx, network failures), mapped to the reason. An empty + # inventory caused by such an error is not evidence that the tenant + # has no users, so the user-based checks report MANUAL instead of + # evaluating it. + self.users_retrieval_errors: dict[str, str] = {} # Get users first alone because it is a dependency for other attributes self.users = loop.run_until_complete(self._get_users()) @@ -69,24 +81,76 @@ class Entra(AzureService): loop.close() async def _get_users(self): + """Retrieve the users of every audited tenant from Microsoft Graph. + + Users are requested with ``signInActivity``. When Graph rejects that + request (the tenant lacks Entra ID P1/P2 or the application lacks + ``AuditLog.Read.All``), the tenant is recorded in + ``self.sign_in_activity_errors`` and the users are fetched again + without ``signInActivity`` so the remaining user checks can still run. + + Any other failure to retrieve the users (throttling, 5xx, network), + including a failure on a later page of the paginated response, is + recorded in ``self.users_retrieval_errors`` so the user-based checks + report MANUAL instead of evaluating an empty or partial inventory. + + Returns: + dict: Tenant domain mapped to a dict of user id -> ``User``. A + tenant whose users could not be retrieved maps to an empty dict + and is recorded in ``self.users_retrieval_errors``. + """ logger.info("Entra - Getting users...") users = {} + base_select = ["id", "displayName", "accountEnabled"] try: - request_configuration = RequestConfiguration( - query_parameters=UsersRequestBuilder.UsersRequestBuilderGetQueryParameters( - select=[ - "id", - "displayName", - "accountEnabled", - "signInActivity", - ] - ) - ) for tenant, client in self.clients.items(): users.update({tenant: {}}) - users_response = await client.users.get( - request_configuration=request_configuration - ) + try: + users_response = await client.users.get( + request_configuration=RequestConfiguration( + query_parameters=UsersRequestBuilder.UsersRequestBuilderGetQueryParameters( + select=base_select + ["signInActivity"] + ) + ) + ) + except Exception as error: + status = getattr(error, "response_status_code", None) + reason = self._describe_graph_error(error) + if status != 403: + # Transient or unexpected failure (throttling, 5xx, + # network): do not blame licensing/permissions, but + # record that the tenant's users are unknown so the + # user-based checks report MANUAL instead of + # evaluating an empty inventory. + self.users_retrieval_errors[tenant] = reason + logger.error( + f"{tenant} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + continue + # A 403 means signInActivity is rejected for the whole + # request (no Entra ID P1/P2 or missing AuditLog.Read.All). + # Record it and retry without the property so the other + # user checks still run. + self.sign_in_activity_errors[tenant] = reason + logger.error( + f"{tenant} -- sign-in activity unavailable, retrying without signInActivity: {reason}" + ) + try: + users_response = await client.users.get( + request_configuration=RequestConfiguration( + query_parameters=UsersRequestBuilder.UsersRequestBuilderGetQueryParameters( + select=base_select + ) + ) + ) + except Exception as retry_error: + self.users_retrieval_errors[tenant] = ( + self._describe_graph_error(retry_error) + ) + logger.error( + f"{tenant} -- {retry_error.__class__.__name__}[{retry_error.__traceback__.tb_lineno}]: {retry_error}" + ) + continue registration_details = await self._get_user_registration_details(client) try: @@ -124,8 +188,15 @@ class Entra(AzureService): users_response = await client.users.with_url(next_link).get() except Exception as error: + # A failed page (throttling, 5xx, network) leaves the + # inventory incomplete: the users retrieved so far must + # not be treated as the whole tenant, so record the error + # and let the user-based checks report MANUAL. + self.users_retrieval_errors[tenant] = self._describe_graph_error( + error + ) logger.error( - f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"{tenant} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) except Exception as error: logger.error( @@ -134,6 +205,21 @@ class Entra(AzureService): return users + @staticmethod + def _describe_graph_error(error: Exception) -> str: + """Return a short, single-line description of a Graph error.""" + code = None + main_error = getattr(error, "error", None) + if main_error is not None: + code = getattr(main_error, "code", None) + status = getattr(error, "response_status_code", None) + parts = [error.__class__.__name__] + if status: + parts.append(f"HTTP {status}") + if code: + parts.append(str(code)) + return " ".join(parts) + async def _get_user_registration_details(self, client): registration_details = {} try: diff --git a/prowler/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in.metadata.json b/prowler/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in.metadata.json index 64ccb383a1..ebc7c508c4 100644 --- a/prowler/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in.metadata.json +++ b/prowler/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in.metadata.json @@ -33,5 +33,5 @@ ], "DependsOn": [], "RelatedTo": [], - "Notes": "The signInActivity resource requires Microsoft Entra ID P1 or P2 license. Tenants without this license will not have sign-in activity data available, and all users will be reported as never having signed in." + "Notes": "The signInActivity resource requires Microsoft Entra ID P1 or P2 license. When Microsoft Graph rejects the sign-in activity request (tenant without Entra ID P1/P2 or missing AuditLog.Read.All), the check reports a single tenant-level MANUAL finding." } diff --git a/prowler/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in.py b/prowler/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in.py index fa6d1af05d..051400611f 100644 --- a/prowler/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in.py +++ b/prowler/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in.py @@ -13,49 +13,56 @@ class entra_user_with_recent_sign_in(Check): This check evaluates each enabled user's last interactive sign-in to detect stale or dormant accounts that should be reviewed or deprovisioned. Sign-in activity requires Entra ID P1/P2 licensing. - PASS: The enabled user signed in within the last 90 days. - - FAIL: The enabled user has not signed in for more than 90 days, or has never signed in. - - FAIL (tenant-level): No sign-in activity data is available for any enabled user, indicating missing P1/P2 licensing or Graph permissions (reported once instead of flagging every user). + - FAIL: The enabled user has not signed in for more than 90 days, or has no recorded sign-in. + - MANUAL (tenant-level): Microsoft Graph refused to return sign-in activity for the tenant (missing Entra ID P1/P2 licensing or the AuditLog.Read.All permission), or the tenant's users could not be retrieved at all, so the check cannot be evaluated; reported once per tenant. """ - def execute(self) -> Check_Report_Azure: + def execute(self) -> list[Check_Report_Azure]: findings = [] for tenant_domain, users in entra_client.users.items(): - enabled_users = {k: v for k, v in users.items() if v.account_enabled} - - if not enabled_users: - continue - - # If all enabled users are missing sign-in data, avoid claiming - # they never signed in. This usually indicates missing telemetry, - # often due to licensing or Graph permission limitations. - all_null = all(u.last_sign_in is None for u in enabled_users.values()) - if all_null: - first_user = next(iter(enabled_users.values())) - report = Check_Report_Azure( - metadata=self.metadata(), resource=first_user - ) + if tenant_domain in entra_client.users_retrieval_errors: + report = Check_Report_Azure(metadata=self.metadata(), resource={}) report.subscription = f"Tenant: {tenant_domain}" - report.resource_name = "Sign-in Activity Data" - count = len(enabled_users) - noun = "user" if count == 1 else "users" - report.status = "FAIL" + report.resource_name = tenant_domain + report.resource_id = entra_client.tenant_ids[0] + report.status = "MANUAL" report.status_extended = ( - f"No sign-in activity data available for any of the " - f"{count} enabled {noun}. This likely means the tenant " - f"is missing Entra ID P1/P2 licensing or the required " - f"Graph permissions to read sign-in activity." + f"Cannot evaluate sign-in activity for tenant {tenant_domain}: " + f"Microsoft Graph did not return the tenant's users " + f"({entra_client.users_retrieval_errors[tenant_domain]}). " + f"Retry the scan or review the tenant's users manually." ) findings.append(report) continue - for user_domain_name, user in enabled_users.items(): + if tenant_domain in entra_client.sign_in_activity_errors: + report = Check_Report_Azure(metadata=self.metadata(), resource={}) + report.subscription = f"Tenant: {tenant_domain}" + report.resource_name = tenant_domain + report.resource_id = entra_client.tenant_ids[0] + report.status = "MANUAL" + report.status_extended = ( + f"Cannot evaluate sign-in activity for tenant {tenant_domain}: " + f"Microsoft Graph did not return sign-in activity " + f"({entra_client.sign_in_activity_errors[tenant_domain]}). " + f"Verify that the tenant has Entra ID P1/P2 licensing and the " + f"scanning application has the AuditLog.Read.All permission." + ) + findings.append(report) + continue + + enabled_users = {k: v for k, v in users.items() if v.account_enabled} + + for user in enabled_users.values(): report = Check_Report_Azure(metadata=self.metadata(), resource=user) report.subscription = f"Tenant: {tenant_domain}" if user.last_sign_in is None: report.status = "FAIL" - report.status_extended = f"User {user.name} has never signed in." + report.status_extended = ( + f"User {user.name} has no recorded sign-in activity." + ) else: last = user.last_sign_in if last.tzinfo is None: diff --git a/prowler/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa.py b/prowler/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa.py index 917200864e..73cd3d143d 100644 --- a/prowler/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa.py +++ b/prowler/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa.py @@ -17,7 +17,22 @@ class entra_user_with_vm_access_has_mfa(Check): findings = [] already_reported = set() - for users in entra_client.users.values(): + for tenant_domain, users in entra_client.users.items(): + if tenant_domain in entra_client.users_retrieval_errors: + report = Check_Report_Azure(metadata=self.metadata(), resource={}) + report.subscription = f"Tenant: {tenant_domain}" + report.resource_name = tenant_domain + report.resource_id = entra_client.tenant_ids[0] + report.status = "MANUAL" + report.status_extended = ( + f"Cannot evaluate MFA for the tenant's users with VM access for tenant {tenant_domain}: " + f"Microsoft Graph did not return the tenant's users " + f"({entra_client.users_retrieval_errors[tenant_domain]}). " + f"Retry the scan or review the tenant's users manually." + ) + findings.append(report) + continue + for user in users.values(): for ( subscription_id, diff --git a/prowler/providers/azure/services/keyvault/keyvault_service.py b/prowler/providers/azure/services/keyvault/keyvault_service.py index e5b2e76427..5e79412f4b 100644 --- a/prowler/providers/azure/services/keyvault/keyvault_service.py +++ b/prowler/providers/azure/services/keyvault/keyvault_service.py @@ -83,6 +83,7 @@ class KeyVault(AzureService): subscription, resource_group, keyvault_name, + getattr(keyvault_properties, "vault_uri", ""), provider, ) secrets_future = executor.submit( @@ -150,7 +151,22 @@ class KeyVault(AzureService): ) return None - def _get_keys(self, subscription, resource_group, keyvault_name, provider): + def _get_keys( + self, subscription, resource_group, keyvault_name, vault_uri, provider + ): + """Get the keys of a Key Vault, enriched with their rotation policies. + + Args: + subscription: Subscription ID the vault belongs to. + resource_group: Resource group name of the vault. + keyvault_name: Vault name, used for the management API and logs. + vault_uri: Data-plane URI of the vault as returned by ARM, valid in + any Azure cloud. When empty, rotation policies are skipped. + provider: Azure provider whose session authenticates the KeyClient. + + Returns: + A list of Key objects; rotation_policy is set when it could be read. + """ logger.info(f"KeyVault - Getting keys for {keyvault_name}...") keys = [] keys_dict = {} @@ -179,10 +195,15 @@ class KeyVault(AzureService): f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + if not vault_uri: + logger.warning( + f"KeyVault {keyvault_name} in {subscription} -- has no vault URI, skipping key rotation policies" + ) + return keys + try: key_client = KeyClient( - vault_url=f"https://{keyvault_name}.vault.azure.net/", - # TODO: review the following line + vault_url=vault_uri, credential=provider.session, ) properties = list(key_client.list_properties_of_keys()) diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py index 2e81bad121..7e23b8de7f 100644 --- a/prowler/providers/common/provider.py +++ b/prowler/providers/common/provider.py @@ -382,6 +382,8 @@ class Provider(ABC): ) provider_class( retries_max_attempts=arguments.aws_retries_max_attempts, + connect_timeout=arguments.aws_connect_timeout, + read_timeout=arguments.aws_read_timeout, role_arn=arguments.role, session_duration=arguments.session_duration, external_id=arguments.external_id, diff --git a/prowler/providers/gcp/lib/service/service.py b/prowler/providers/gcp/lib/service/service.py index 746952542f..5c7ec78ee9 100644 --- a/prowler/providers/gcp/lib/service/service.py +++ b/prowler/providers/gcp/lib/service/service.py @@ -28,6 +28,11 @@ class GCPService: self.client = self.__generate_client__( self.service, api_version, self.credentials ) + # Audited projects where this service's API is definitively DISABLED, + # and projects whose API activation state could not be determined; + # both are excluded from project_ids. + self.api_disabled_project_ids: set = set() + self.api_state_unknown_project_ids: set = set() # Only project ids that have their API enabled will be scanned if provider.skip_api_check: self.project_ids = provider.project_ids @@ -69,10 +74,12 @@ class GCPService: if response.get("state") != "DISABLED": project_ids.append(project_id) else: + self.api_disabled_project_ids.add(project_id) logger.error( f"{self.service} API has not been used in project {project_id} before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/{self.service}.googleapis.com/overview?project={project_id} then retry." ) except Exception as error: + self.api_state_unknown_project_ids.add(project_id) logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) diff --git a/prowler/providers/gcp/services/iam/iam_account_access_approval_enabled/iam_account_access_approval_enabled.py b/prowler/providers/gcp/services/iam/iam_account_access_approval_enabled/iam_account_access_approval_enabled.py index 16d01c3739..4eddf4a22f 100644 --- a/prowler/providers/gcp/services/iam/iam_account_access_approval_enabled/iam_account_access_approval_enabled.py +++ b/prowler/providers/gcp/services/iam/iam_account_access_approval_enabled/iam_account_access_approval_enabled.py @@ -5,9 +5,28 @@ from prowler.providers.gcp.services.iam.accessapproval_client import ( class iam_account_access_approval_enabled(Check): - def execute(self) -> Check_Report_GCP: + """Ensure Access Approval is enabled for every audited project. + + - PASS: The project has Access Approval settings configured. + - FAIL: Access Approval is not configured (404 on the settings read), or + the accessapproval.googleapis.com API is disabled — with the API off, + Access Approval provably cannot be enabled. + - MANUAL: The settings could not be read (permission error) or the API + activation state could not be determined. + """ + + def execute(self) -> list[Check_Report_GCP]: + """Evaluate Access Approval for the audited projects. + + Returns: + list[Check_Report_GCP]: One report per audited project. + """ findings = [] for project_id in accessapproval_client.project_ids: + # Under --skip-api-check a disabled API is detected while reading + # the settings; those projects are reported by the loop below. + if project_id in accessapproval_client.api_disabled_project_ids: + continue report = Check_Report_GCP( metadata=self.metadata(), resource=accessapproval_client.projects[project_id], @@ -18,11 +37,53 @@ class iam_account_access_approval_enabled(Check): report.status_extended = ( f"Project {project_id} has Access Approval enabled." ) - if project_id not in accessapproval_client.settings: + if project_id in accessapproval_client.settings_lookup_failed: + report.status = "MANUAL" + report.status_extended = ( + f"Cannot evaluate Access Approval for project {project_id}: " + "the Access Approval settings could not be read. Verify that " + "the Access Approval API is enabled and the scanning " + "credentials have the accessapproval.settings.get permission." + ) + elif project_id not in accessapproval_client.settings: report.status = "FAIL" report.status_extended = ( f"Project {project_id} does not have Access Approval enabled." ) findings.append(report) + # Projects filtered out by the API-activation precheck never reach + # _get_settings(): report them instead of silently skipping. A + # definitively disabled API means Access Approval cannot be enabled + # (FAIL); an undetermined state is an evidence gap (MANUAL). + for project_id in sorted(accessapproval_client.api_disabled_project_ids): + report = Check_Report_GCP( + metadata=self.metadata(), + resource=accessapproval_client.projects[project_id], + project_id=project_id, + location=accessapproval_client.region, + ) + report.status = "FAIL" + report.status_extended = ( + f"Project {project_id} does not have Access Approval enabled: " + "the accessapproval.googleapis.com API is disabled." + ) + findings.append(report) + + for project_id in sorted(accessapproval_client.api_state_unknown_project_ids): + report = Check_Report_GCP( + metadata=self.metadata(), + resource=accessapproval_client.projects[project_id], + project_id=project_id, + location=accessapproval_client.region, + ) + report.status = "MANUAL" + report.status_extended = ( + f"Cannot evaluate Access Approval for project {project_id}: " + "the activation state of the accessapproval.googleapis.com API " + "could not be determined. Verify that the scanning credentials " + "can call serviceusage.services.get for the project." + ) + findings.append(report) + return findings diff --git a/prowler/providers/gcp/services/iam/iam_service.py b/prowler/providers/gcp/services/iam/iam_service.py index cc2deddefa..beb941f162 100644 --- a/prowler/providers/gcp/services/iam/iam_service.py +++ b/prowler/providers/gcp/services/iam/iam_service.py @@ -1,5 +1,6 @@ from datetime import datetime +from googleapiclient.errors import HttpError from pydantic.v1 import BaseModel from prowler.lib.logger import logger @@ -219,6 +220,10 @@ class AccessApproval(GCPService): def __init__(self, provider: GcpProvider): super().__init__(__class__.__name__, provider) self.settings = {} + # Projects whose Access Approval settings could not be read because of + # a permission or API-availability error (as opposed to a 404, which + # means Access Approval is simply not enabled for the project). + self.settings_lookup_failed: set[str] = set() self._get_settings() def _get_settings(self): @@ -234,7 +239,30 @@ class AccessApproval(GCPService): project_id=project_id, ) + except HttpError as error: + if error.status_code == 404: + # Access Approval is not enabled for this project. + logger.info( + f"{self.region} -- Access Approval settings not found for project {project_id}: {error}" + ) + elif error.status_code == 403 and ( + "SERVICE_DISABLED" in str(error) + or "has not been used" in str(error) + ): + # Under --skip-api-check the API-activation precheck does + # not run; a SERVICE_DISABLED 403 here is the same + # definitive "API disabled" state. + self.api_disabled_project_ids.add(project_id) + logger.info( + f"{self.region} -- Access Approval API disabled for project {project_id}: {error}" + ) + else: + self.settings_lookup_failed.add(project_id) + logger.error( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) except Exception as error: + self.settings_lookup_failed.add(project_id) logger.error( f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) diff --git a/prowler/providers/github/services/repository/repository_service.py b/prowler/providers/github/services/repository/repository_service.py index dc41df2ea5..f81d9781bb 100644 --- a/prowler/providers/github/services/repository/repository_service.py +++ b/prowler/providers/github/services/repository/repository_service.py @@ -10,6 +10,8 @@ from prowler.lib.logger import logger from prowler.providers.github.lib.service.service import GithubService from prowler.providers.github.models import GithubAppIdentityInfo +GITHUB_GRAPHQL_TIMEOUT = (10, 60) + class Repository(GithubService): def __init__(self, provider): @@ -65,41 +67,105 @@ class Repository(GithubService): "Content-Type": "application/json", } query = """ - { + query ($cursor: String) { viewer { - repositories(first: 100, affiliations: [OWNER, ORGANIZATION_MEMBER]) { + repositories( + first: 100 + after: $cursor + affiliations: [OWNER, ORGANIZATION_MEMBER] + ) { nodes { nameWithOwner } + pageInfo { + hasNextPage + endCursor + } } } } """ + repositories = [] + cursor = None + seen_cursors = set() + try: - response = requests.post( - graphql_url, json={"query": query}, headers=headers - ) - response.raise_for_status() - data = response.json() + while True: + response = requests.post( + graphql_url, + json={"query": query, "variables": {"cursor": cursor}}, + headers=headers, + timeout=GITHUB_GRAPHQL_TIMEOUT, + ) + response.raise_for_status() + data = response.json() - if "errors" in data: - logger.error(f"Error in GraphQL query: {data['errors']}") - return [] + errors = data.get("errors") if isinstance(data, dict) else None + repository_connection = ( + ((data.get("data") or {}).get("viewer") or {}).get("repositories") + if isinstance(data, dict) + else None + ) + if not isinstance(repository_connection, dict): + logger.error( + f"Error in GraphQL query: {errors or 'invalid response'}" + ) + return repositories + if errors: + # GitHub returns partial responses: repositories the token + # cannot access (e.g. behind organization SAML enforcement) + # come back as null nodes together with an "errors" entry, + # while the rest of the page is valid. + logger.warning( + f"GitHub GraphQL returned errors while discovering repositories, " + f"some repositories may be skipped: {errors}" + ) - repo_nodes = ( - data.get("data", {}) - .get("viewer", {}) - .get("repositories", {}) - .get("nodes", []) - ) - return [repo["nameWithOwner"] for repo in repo_nodes] + repo_nodes = repository_connection.get("nodes") + page_info = repository_connection.get("pageInfo") + if ( + not isinstance(repo_nodes, list) + or not isinstance(page_info, dict) + or not isinstance(page_info.get("hasNextPage"), bool) + ): + logger.error( + "GitHub GraphQL returned an invalid repositories page; " + "repository discovery may be incomplete." + ) + return repositories - except requests.exceptions.RequestException as error: + for repo_node in repo_nodes: + if not repo_node: + logger.warning( + "Skipping a repository the token cannot access during discovery." + ) + continue + repositories.append(repo_node["nameWithOwner"]) + + if not page_info["hasNextPage"]: + return repositories + + cursor = page_info.get("endCursor") + if not cursor or cursor in seen_cursors: + logger.error( + "GitHub GraphQL pagination returned an invalid cursor; " + "repository discovery may be incomplete." + ) + return repositories + seen_cursors.add(cursor) + + except ( + requests.exceptions.RequestException, + ValueError, + KeyError, + TypeError, + AttributeError, + ) as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) - return [] + return repositories def _default_branch_matches_rule_pattern( self, pattern: str, default_branch: str diff --git a/prowler/providers/googleworkspace/lib/service/service.py b/prowler/providers/googleworkspace/lib/service/service.py index 22454f3c63..35a10454cd 100644 --- a/prowler/providers/googleworkspace/lib/service/service.py +++ b/prowler/providers/googleworkspace/lib/service/service.py @@ -6,6 +6,11 @@ from prowler.providers.googleworkspace.googleworkspace_provider import ( GoogleworkspaceProvider, ) +# How far a Cloud Identity policy reaches. +CUSTOMER_SCOPE = "customer" +OVERRIDE_SCOPE = "override" +UNKNOWN_SCOPE = "unknown" + class GoogleWorkspaceService: def __init__( @@ -42,26 +47,29 @@ class GoogleWorkspaceService: ) return None - def _is_customer_level_policy(self, policy: dict) -> bool: - """Check if a policy applies at the customer (domain-wide) level. + def _policy_scope(self, policy: dict) -> str: + """Return how far a policy reaches: CUSTOMER_SCOPE, OVERRIDE_SCOPE or UNKNOWN_SCOPE. - The Cloud Identity Policy API typically scopes all policies to an OU; - absence of orgUnit is treated as customer-level as a safety net. - The root OU is equivalent to customer-level. This method accepts - policies with no orgUnit or policies targeting the root OU, - and rejects group-targeted and sub-OU policies. + The Cloud Identity Policy API typically scopes every policy to an OU, + and the root OU is equivalent to customer-level, so telling them apart + needs the root OU id. That id is fetched on a best-effort basis, and + without it a root-OU policy is indistinguishable from a sub-OU one: + that is UNKNOWN_SCOPE, which callers must not read as either. """ - policy_query = policy.get("policyQuery", {}) + policy_query = policy.get("policyQuery") or {} if policy_query.get("group"): - return False + return OVERRIDE_SCOPE org_unit = policy_query.get("orgUnit") if not org_unit: - return True - # Accept root OU as customer-level + return CUSTOMER_SCOPE root_id = getattr(self.provider.identity, "root_org_unit_id", None) - if root_id and org_unit == f"orgUnits/{root_id}": - return True - return False + if not root_id: + return UNKNOWN_SCOPE + return CUSTOMER_SCOPE if org_unit == f"orgUnits/{root_id}" else OVERRIDE_SCOPE + + def _is_customer_level_policy(self, policy: dict) -> bool: + """Whether a policy applies to the whole domain.""" + return self._policy_scope(policy) == CUSTOMER_SCOPE def _handle_api_error(self, error, context: str, resource_name: str = ""): """ diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_domain_spoofing_protection_enabled/gmail_domain_spoofing_protection_enabled.metadata.json b/prowler/providers/googleworkspace/services/gmail/gmail_domain_spoofing_protection_enabled/gmail_domain_spoofing_protection_enabled.metadata.json index 7e6be13d58..88035f633f 100644 --- a/prowler/providers/googleworkspace/services/gmail/gmail_domain_spoofing_protection_enabled/gmail_domain_spoofing_protection_enabled.metadata.json +++ b/prowler/providers/googleworkspace/services/gmail/gmail_domain_spoofing_protection_enabled/gmail_domain_spoofing_protection_enabled.metadata.json @@ -9,7 +9,7 @@ "Severity": "medium", "ResourceType": "NotDefined", "ResourceGroup": "collaboration", - "Description": "Verifies that Gmail is configured to take a protective action (such as moving to spam, quarantining, or showing a warning) when emails appear to come from domain names that look similar to the organization's domain. Lookalike domains are a common phishing technique used to trick users into trusting malicious messages.", + "Description": "Verifies that Gmail moves out of the inbox, to spam or quarantine, the emails that appear to come from domain names that look similar to the organization's domain. An action that only shows a warning is not enough. Lookalike domains are a common phishing technique used to trick users into trusting malicious messages.", "Risk": "Without protection against domain spoofing based on similar domain names, users may receive **phishing emails from lookalike domains** (e.g., examp1e.com instead of example.com) that appear legitimate. This enables **credential theft, malware delivery, and business email compromise** attacks.", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_domain_spoofing_protection_enabled/gmail_domain_spoofing_protection_enabled.py b/prowler/providers/googleworkspace/services/gmail/gmail_domain_spoofing_protection_enabled/gmail_domain_spoofing_protection_enabled.py index d3a6bc94c1..5d674b60a0 100644 --- a/prowler/providers/googleworkspace/services/gmail/gmail_domain_spoofing_protection_enabled/gmail_domain_spoofing_protection_enabled.py +++ b/prowler/providers/googleworkspace/services/gmail/gmail_domain_spoofing_protection_enabled/gmail_domain_spoofing_protection_enabled.py @@ -2,6 +2,10 @@ from typing import List from prowler.lib.check.models import Check, CheckReportGoogleWorkspace from prowler.providers.googleworkspace.services.gmail.gmail_client import gmail_client +from prowler.providers.googleworkspace.services.gmail.lib.spoofing import ( + describe_consequence, + is_protective, +) class gmail_domain_spoofing_protection_enabled(Check): @@ -9,7 +13,9 @@ class gmail_domain_spoofing_protection_enabled(Check): This check verifies that Gmail is configured to take action on emails that appear to come from similar-looking domain names, - helping prevent phishing via domain impersonation. + helping prevent phishing via domain impersonation. CIS requires the + configured action to move the message to spam or quarantine it, so an action + that only shows a warning is reported as a failure. """ def execute(self) -> List[CheckReportGoogleWorkspace]: @@ -26,38 +32,30 @@ class gmail_domain_spoofing_protection_enabled(Check): enabled = gmail_client.policies.detect_domain_name_spoofing consequence = gmail_client.policies.domain_spoofing_consequence + domain = gmail_client.provider.identity.domain if enabled is False: report.status = "FAIL" report.status_extended = ( - f"Protection against domain spoofing based on similar " - f"domain names is disabled in domain " - f"{gmail_client.provider.identity.domain}. " - f"Enable the protection and configure a protective action." + f"Protection against domain spoofing based on similar domain names " + f"is disabled in domain {domain}. " + f"Enable the protection and set the action to move the " + f"email to spam." ) - elif consequence == "NO_ACTION": + elif not is_protective(consequence): report.status = "FAIL" report.status_extended = ( - f"Protection against domain spoofing based on similar " - f"domain names is set to take no action in domain " - f"{gmail_client.provider.identity.domain}. " - f"A protective action should be configured." - ) - elif consequence is None: - report.status = "PASS" - report.status_extended = ( - f"Protection against domain spoofing based on similar " - f"domain names uses Google's secure default configuration " - f"(enabled) in domain " - f"{gmail_client.provider.identity.domain}." + f"Protection against domain spoofing based on similar domain names " + f"{describe_consequence(consequence)} in domain {domain}. " + f"The action should move the email to spam." ) else: report.status = "PASS" + state = "is enabled" if enabled else "uses Google's default (enabled)" report.status_extended = ( - f"Protection against domain spoofing based on similar " - f"domain names is enabled with consequence " - f"'{consequence}' in domain " - f"{gmail_client.provider.identity.domain}." + f"Protection against domain spoofing based on similar domain names " + f"{state} with action '{consequence}' in domain " + f"{domain}." ) findings.append(report) diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_employee_name_spoofing_protection_enabled/gmail_employee_name_spoofing_protection_enabled.metadata.json b/prowler/providers/googleworkspace/services/gmail/gmail_employee_name_spoofing_protection_enabled/gmail_employee_name_spoofing_protection_enabled.metadata.json index d6d107f360..103d09ea1e 100644 --- a/prowler/providers/googleworkspace/services/gmail/gmail_employee_name_spoofing_protection_enabled/gmail_employee_name_spoofing_protection_enabled.metadata.json +++ b/prowler/providers/googleworkspace/services/gmail/gmail_employee_name_spoofing_protection_enabled/gmail_employee_name_spoofing_protection_enabled.metadata.json @@ -9,7 +9,7 @@ "Severity": "medium", "ResourceType": "NotDefined", "ResourceGroup": "collaboration", - "Description": "Verifies that Gmail is configured to take a protective action (such as moving to spam, quarantining, or showing a warning) when the sender's display name matches an employee's name but the email comes from an external address. This is a common social engineering technique where attackers impersonate colleagues or executives.", + "Description": "Verifies that Gmail moves out of the inbox, to spam or quarantine, the emails whose sender display name matches an employee's name but come from an external address. An action that only shows a warning is not enough. This is a common social engineering technique where attackers impersonate colleagues or executives.", "Risk": "Without protection against employee name spoofing, users may receive **emails that appear to come from colleagues or executives** but are actually from external attackers. This enables **business email compromise (BEC)**, **wire fraud**, and **social engineering attacks** that exploit trust relationships.", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_employee_name_spoofing_protection_enabled/gmail_employee_name_spoofing_protection_enabled.py b/prowler/providers/googleworkspace/services/gmail/gmail_employee_name_spoofing_protection_enabled/gmail_employee_name_spoofing_protection_enabled.py index ea6283c940..8f33bf4d6d 100644 --- a/prowler/providers/googleworkspace/services/gmail/gmail_employee_name_spoofing_protection_enabled/gmail_employee_name_spoofing_protection_enabled.py +++ b/prowler/providers/googleworkspace/services/gmail/gmail_employee_name_spoofing_protection_enabled/gmail_employee_name_spoofing_protection_enabled.py @@ -2,6 +2,10 @@ from typing import List from prowler.lib.check.models import Check, CheckReportGoogleWorkspace from prowler.providers.googleworkspace.services.gmail.gmail_client import gmail_client +from prowler.providers.googleworkspace.services.gmail.lib.spoofing import ( + describe_consequence, + is_protective, +) class gmail_employee_name_spoofing_protection_enabled(Check): @@ -9,7 +13,9 @@ class gmail_employee_name_spoofing_protection_enabled(Check): This check verifies that Gmail is configured to take action on emails where the sender name matches an employee name but comes - from an external address, helping prevent social engineering attacks. + from an external address, helping prevent social engineering attacks. CIS requires the configured + action to move the message to spam or quarantine it, so an action that + only shows a warning is reported as a failure. """ def execute(self) -> List[CheckReportGoogleWorkspace]: @@ -26,36 +32,30 @@ class gmail_employee_name_spoofing_protection_enabled(Check): enabled = gmail_client.policies.detect_employee_name_spoofing consequence = gmail_client.policies.employee_name_spoofing_consequence + domain = gmail_client.provider.identity.domain if enabled is False: report.status = "FAIL" report.status_extended = ( - f"Protection against spoofing of employee names is " - f"disabled in domain " - f"{gmail_client.provider.identity.domain}. " - f"Enable the protection and configure a protective action." + f"Protection against spoofing of employee names " + f"is disabled in domain {domain}. " + f"Enable the protection and set the action to move the " + f"email to spam." ) - elif consequence == "NO_ACTION": + elif not is_protective(consequence): report.status = "FAIL" report.status_extended = ( - f"Protection against spoofing of employee names is set " - f"to take no action in domain " - f"{gmail_client.provider.identity.domain}. " - f"A protective action should be configured." - ) - elif consequence is None: - report.status = "PASS" - report.status_extended = ( - f"Protection against spoofing of employee names uses " - f"Google's secure default configuration (enabled) " - f"in domain {gmail_client.provider.identity.domain}." + f"Protection against spoofing of employee names " + f"{describe_consequence(consequence)} in domain {domain}. " + f"The action should move the email to spam." ) else: report.status = "PASS" + state = "is enabled" if enabled else "uses Google's default (enabled)" report.status_extended = ( - f"Protection against spoofing of employee names is " - f"enabled with consequence '{consequence}' in domain " - f"{gmail_client.provider.identity.domain}." + f"Protection against spoofing of employee names " + f"{state} with action '{consequence}' in domain " + f"{domain}." ) findings.append(report) diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_groups_spoofing_protection_enabled/gmail_groups_spoofing_protection_enabled.metadata.json b/prowler/providers/googleworkspace/services/gmail/gmail_groups_spoofing_protection_enabled/gmail_groups_spoofing_protection_enabled.metadata.json index c5f5ee61a9..0a8214370e 100644 --- a/prowler/providers/googleworkspace/services/gmail/gmail_groups_spoofing_protection_enabled/gmail_groups_spoofing_protection_enabled.metadata.json +++ b/prowler/providers/googleworkspace/services/gmail/gmail_groups_spoofing_protection_enabled/gmail_groups_spoofing_protection_enabled.metadata.json @@ -9,7 +9,7 @@ "Severity": "medium", "ResourceType": "NotDefined", "ResourceGroup": "collaboration", - "Description": "Verifies that Gmail is configured to take a protective action (such as moving to spam, quarantining, or showing a warning) when groups receive inbound emails that spoof the organization's domain. Google Groups are a high-value target because a single spoofed message can reach many recipients at once.", + "Description": "Verifies that Gmail moves out of the inbox, to spam or quarantine, the inbound emails to groups that spoof the organization's domain. An action that only shows a warning is not enough. Google Groups are a high-value target because a single spoofed message can reach many recipients at once.", "Risk": "Without protection of groups from domain-spoofing emails, attackers can send **spoofed messages to group mailboxes** that appear to originate from the organization. Since groups distribute to many recipients, a single spoofed email can enable **mass phishing, social engineering, or misinformation** campaigns across the organization.", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_groups_spoofing_protection_enabled/gmail_groups_spoofing_protection_enabled.py b/prowler/providers/googleworkspace/services/gmail/gmail_groups_spoofing_protection_enabled/gmail_groups_spoofing_protection_enabled.py index fd4238239f..c4fb628e83 100644 --- a/prowler/providers/googleworkspace/services/gmail/gmail_groups_spoofing_protection_enabled/gmail_groups_spoofing_protection_enabled.py +++ b/prowler/providers/googleworkspace/services/gmail/gmail_groups_spoofing_protection_enabled/gmail_groups_spoofing_protection_enabled.py @@ -2,6 +2,10 @@ from typing import List from prowler.lib.check.models import Check, CheckReportGoogleWorkspace from prowler.providers.googleworkspace.services.gmail.gmail_client import gmail_client +from prowler.providers.googleworkspace.services.gmail.lib.spoofing import ( + describe_consequence, + is_protective, +) class gmail_groups_spoofing_protection_enabled(Check): @@ -10,6 +14,9 @@ class gmail_groups_spoofing_protection_enabled(Check): This check verifies that Gmail is configured to take action on inbound emails to groups that spoof the organization's domain, helping prevent impersonation attacks targeting group mailboxes. + CIS requires the configured action to move the message to spam or + quarantine it, so an action that only shows a warning is reported as a + failure. """ def execute(self) -> List[CheckReportGoogleWorkspace]: @@ -27,56 +34,44 @@ class gmail_groups_spoofing_protection_enabled(Check): enabled = gmail_client.policies.detect_groups_spoofing consequence = gmail_client.policies.groups_spoofing_consequence visibility_type = gmail_client.policies.groups_spoofing_visibility_type + domain = gmail_client.provider.identity.domain + scope = ( + "private groups only" + if visibility_type == "PRIVATE_GROUPS_ONLY" + else "all groups" + ) if enabled is False: report.status = "FAIL" report.status_extended = ( f"Protection of groups from inbound emails spoofing your " - f"domain is disabled in domain " - f"{gmail_client.provider.identity.domain}. " - f"Enable the protection and configure a protective action." + f"domain is disabled in domain {domain}. " + f"Enable the protection and set the action to move the " + f"email to spam." ) elif enabled is None: report.status = "FAIL" report.status_extended = ( f"Protection of groups from inbound emails spoofing your " f"domain is not configured and uses Google's insecure " - f"default (disabled) in domain " - f"{gmail_client.provider.identity.domain}. " - f"Enable the protection and configure a protective action." + f"default (disabled) in domain {domain}. " + f"Enable the protection and set the action to move the " + f"email to spam." ) - elif consequence == "NO_ACTION": + elif not is_protective(consequence): report.status = "FAIL" report.status_extended = ( f"Protection of groups from inbound emails spoofing your " - f"domain is set to take no action in domain " - f"{gmail_client.provider.identity.domain}. " - f"A protective action should be configured." - ) - elif consequence is None: - report.status = "PASS" - scope = ( - "private groups only" - if visibility_type == "PRIVATE_GROUPS_ONLY" - else "all groups" - ) - report.status_extended = ( - f"Protection of groups from inbound emails spoofing your " - f"domain is enabled for {scope} in domain " - f"{gmail_client.provider.identity.domain}." + f"domain is enabled for {scope} but " + f"{describe_consequence(consequence)} in domain {domain}. " + f"The action should move the email to spam." ) else: report.status = "PASS" - scope = ( - "private groups only" - if visibility_type == "PRIVATE_GROUPS_ONLY" - else "all groups" - ) report.status_extended = ( f"Protection of groups from inbound emails spoofing your " - f"domain is enabled for {scope} with consequence " - f"'{consequence}' in domain " - f"{gmail_client.provider.identity.domain}." + f"domain is enabled for {scope} with action " + f"'{consequence}' in domain {domain}." ) findings.append(report) diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_inbound_domain_spoofing_protection_enabled/gmail_inbound_domain_spoofing_protection_enabled.metadata.json b/prowler/providers/googleworkspace/services/gmail/gmail_inbound_domain_spoofing_protection_enabled/gmail_inbound_domain_spoofing_protection_enabled.metadata.json index a049a7ede5..fef0f77322 100644 --- a/prowler/providers/googleworkspace/services/gmail/gmail_inbound_domain_spoofing_protection_enabled/gmail_inbound_domain_spoofing_protection_enabled.metadata.json +++ b/prowler/providers/googleworkspace/services/gmail/gmail_inbound_domain_spoofing_protection_enabled/gmail_inbound_domain_spoofing_protection_enabled.metadata.json @@ -9,7 +9,7 @@ "Severity": "medium", "ResourceType": "NotDefined", "ResourceGroup": "collaboration", - "Description": "Verifies that Gmail is configured to take a protective action (such as moving to spam, quarantining, or showing a warning) when inbound emails spoof the organization's own domain. This protects against attackers sending emails that appear to originate from within the organization but are actually external.", + "Description": "Verifies that Gmail moves out of the inbox, to spam or quarantine, the inbound emails that spoof the organization's own domain. An action that only shows a warning is not enough. This protects against attackers sending emails that appear to originate from within the organization but are actually external.", "Risk": "Without protection against inbound domain spoofing, users may receive **emails that appear to come from their own organization** but are sent by external attackers. This enables **internal impersonation**, **phishing**, and **business email compromise** attacks that exploit trust in internal communications.", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/googleworkspace/services/gmail/gmail_inbound_domain_spoofing_protection_enabled/gmail_inbound_domain_spoofing_protection_enabled.py b/prowler/providers/googleworkspace/services/gmail/gmail_inbound_domain_spoofing_protection_enabled/gmail_inbound_domain_spoofing_protection_enabled.py index b9a22cadc6..822c445595 100644 --- a/prowler/providers/googleworkspace/services/gmail/gmail_inbound_domain_spoofing_protection_enabled/gmail_inbound_domain_spoofing_protection_enabled.py +++ b/prowler/providers/googleworkspace/services/gmail/gmail_inbound_domain_spoofing_protection_enabled/gmail_inbound_domain_spoofing_protection_enabled.py @@ -2,6 +2,10 @@ from typing import List from prowler.lib.check.models import Check, CheckReportGoogleWorkspace from prowler.providers.googleworkspace.services.gmail.gmail_client import gmail_client +from prowler.providers.googleworkspace.services.gmail.lib.spoofing import ( + describe_consequence, + is_protective, +) class gmail_inbound_domain_spoofing_protection_enabled(Check): @@ -9,7 +13,9 @@ class gmail_inbound_domain_spoofing_protection_enabled(Check): This check verifies that Gmail is configured to take action on inbound emails that spoof the organization's own domain, helping - prevent impersonation of internal senders. + prevent impersonation of internal senders. CIS requires the configured + action to move the message to spam or quarantine it, so an action that + only shows a warning is reported as a failure. """ def execute(self) -> List[CheckReportGoogleWorkspace]: @@ -26,36 +32,30 @@ class gmail_inbound_domain_spoofing_protection_enabled(Check): enabled = gmail_client.policies.detect_inbound_domain_spoofing consequence = gmail_client.policies.inbound_domain_spoofing_consequence + domain = gmail_client.provider.identity.domain if enabled is False: report.status = "FAIL" report.status_extended = ( f"Protection against inbound emails spoofing your domain " - f"is disabled in domain " - f"{gmail_client.provider.identity.domain}. " - f"Enable the protection and configure a protective action." + f"is disabled in domain {domain}. " + f"Enable the protection and set the action to move the " + f"email to spam." ) - elif consequence == "NO_ACTION": + elif not is_protective(consequence): report.status = "FAIL" report.status_extended = ( f"Protection against inbound emails spoofing your domain " - f"is set to take no action in domain " - f"{gmail_client.provider.identity.domain}. " - f"A protective action should be configured." - ) - elif consequence is None: - report.status = "PASS" - report.status_extended = ( - f"Protection against inbound emails spoofing your domain " - f"uses Google's secure default configuration (enabled) " - f"in domain {gmail_client.provider.identity.domain}." + f"{describe_consequence(consequence)} in domain {domain}. " + f"The action should move the email to spam." ) else: report.status = "PASS" + state = "is enabled" if enabled else "uses Google's default (enabled)" report.status_extended = ( f"Protection against inbound emails spoofing your domain " - f"is enabled with consequence '{consequence}' " - f"in domain {gmail_client.provider.identity.domain}." + f"{state} with action '{consequence}' in domain " + f"{domain}." ) findings.append(report) diff --git a/prowler/providers/googleworkspace/services/gmail/lib/__init__.py b/prowler/providers/googleworkspace/services/gmail/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/gmail/lib/spoofing.py b/prowler/providers/googleworkspace/services/gmail/lib/spoofing.py new file mode 100644 index 0000000000..7c3b86a352 --- /dev/null +++ b/prowler/providers/googleworkspace/services/gmail/lib/spoofing.py @@ -0,0 +1,33 @@ +"""Helpers to evaluate the action configured for Gmail spoofing protections.""" + +from typing import Optional + +# Actions that actually keep the message away from the inbox. CIS Google +# Workspace 3.1.3.4.3.1, 3.1.3.4.3.2, 3.1.3.4.3.3 and 3.1.3.4.3.5 all require +# the action to be "Move email to spam"; quarantining is stricter and also +# satisfies the recommendation. +PROTECTIVE_CONSEQUENCES = {"SPAM_FOLDER", "QUARANTINE"} + +# Google leaves these protections enabled but set to "Keep email in inbox and +# show warning", which the benchmark does not accept, so an unset action is +# evaluated as the insecure default rather than as a secure one. +UNSET_CONSEQUENCE_DESCRIPTION = ( + "uses Google's default action (keep email in inbox and show a warning)" +) + +CONSEQUENCE_DESCRIPTIONS = { + "NO_ACTION": "is set to take no action", + "WARNING": "is set to keep the email in the inbox and show a warning", +} + + +def describe_consequence(consequence: Optional[str]) -> str: + """Return a human-readable description of a non-protective action.""" + if consequence is None: + return UNSET_CONSEQUENCE_DESCRIPTION + return CONSEQUENCE_DESCRIPTIONS.get(consequence, f"is set to '{consequence}'") + + +def is_protective(consequence: Optional[str]) -> bool: + """Whether the configured action moves the message out of the inbox.""" + return consequence in PROTECTIVE_CONSEQUENCES diff --git a/prowler/providers/googleworkspace/services/rules/lib/__init__.py b/prowler/providers/googleworkspace/services/rules/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/rules/lib/alerts.py b/prowler/providers/googleworkspace/services/rules/lib/alerts.py new file mode 100644 index 0000000000..c3c3754b67 --- /dev/null +++ b/prowler/providers/googleworkspace/services/rules/lib/alerts.py @@ -0,0 +1,127 @@ +"""Shared evaluation of the system-defined alert rules audited by CIS section 6.""" + +from typing import TYPE_CHECKING, List + +from prowler.lib.check.models import CheckReportGoogleWorkspace + +if TYPE_CHECKING: + from prowler.providers.googleworkspace.services.rules.rules_service import Rules + +# A rule classified above what the benchmark asks for is stricter, not weaker, +# so severities are compared by rank instead of by equality. +SEVERITY_RANK = {"LOW": 1, "MEDIUM": 2, "HIGH": 3} + +# The rule can be active while its delivery to the alert center is switched +# off, which is what the audit's "Ensure that Alerts is set to On" checks. +ALERT_CENTER_DISABLED = "DISABLED" + + +def _severity_issue(severity: str, minimum_severity: str) -> str: + """Return why a severity does not meet the benchmark, or an empty string.""" + if severity is None: + return f"severity is not configured (should be at least {minimum_severity})" + rank = SEVERITY_RANK.get(severity) + if rank is None: + return ( + f"severity is {severity}, which is not one of " + f"{', '.join(SEVERITY_RANK)}, so it could not be compared against " + f"the {minimum_severity} the benchmark asks for" + ) + if rank < SEVERITY_RANK[minimum_severity]: + return f"severity is {severity} (should be at least {minimum_severity})" + return "" + + +def evaluate_system_defined_alert( + client: "Rules", + metadata: dict, + rule_name: str, + minimum_severity: str, +) -> List[CheckReportGoogleWorkspace]: + """Report on one system-defined alert rule against the CIS audit procedure. + + Every recommendation in CIS section 6 asks for the rule to be on, to notify + by email, to include all super administrators as recipients and to carry a + minimum severity. Returns no finding at all when the policies could not be + fetched or the rule is not among the ones the client collected. + """ + findings = [] + + if not client.policies_fetched: + return findings + + for alert in client.system_defined_alerts: + if alert.display_name != rule_name: + continue + + domain = client.provider.identity.domain + report = CheckReportGoogleWorkspace( + metadata=metadata, + resource=alert, + resource_id=f"systemDefinedAlert/{rule_name}", + resource_name=rule_name, + customer_id=client.provider.identity.customer_id, + ) + + if alert.from_default: + # Nothing was observed: the state below is Google's documented + # default and the severity has no documented default at all. + if alert.state != "ACTIVE": + report.status = "FAIL" + report.status_extended = ( + f"System-defined alert rule '{rule_name}' was not returned " + f"by the API in domain {domain} and Google's default for it " + f"is OFF." + ) + else: + report.status = "MANUAL" + report.status_extended = ( + f"System-defined alert rule '{rule_name}' was not returned " + f"by the API in domain {domain}, so its configuration could " + f"not be verified. Review it in the Admin console: it should " + f"be ON, notify all super administrators by email and be set " + f"to {minimum_severity} severity or higher." + ) + findings.append(report) + continue + + issues = [] + + if alert.state != "ACTIVE": + issues.append("alert is OFF") + + # Only an explicit DISABLED fails. The API does not return this field + # even for a rule that is ON and has a severity set, and a severity + # cannot be configured for the alert center while delivery is off, so + # treating its absence as unverified would leave every one of these + # checks permanently MANUAL. + if alert.alert_center_status == ALERT_CENTER_DISABLED: + issues.append("the alert is not sent to the alert center") + + if not alert.email_notifications_enabled: + issues.append("email notifications are disabled") + elif not alert.all_super_admins: + issues.append("email recipients do not include all super administrators") + + severity = _severity_issue(alert.severity, minimum_severity) + if severity: + issues.append(severity) + + if issues: + report.status = "FAIL" + report.status_extended = ( + f"System-defined alert rule '{rule_name}' is not properly " + f"configured in domain {domain}: {', '.join(issues)}." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"System-defined alert rule '{rule_name}' is properly " + f"configured in domain {domain}: alert is ON, email " + f"notifications are enabled, recipients include all super " + f"administrators and severity is {alert.severity}." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/googleworkspace/services/rules/rules_admin_privilege_granted_alert_configured/rules_admin_privilege_granted_alert_configured.metadata.json b/prowler/providers/googleworkspace/services/rules/rules_admin_privilege_granted_alert_configured/rules_admin_privilege_granted_alert_configured.metadata.json index a93b4fb737..84a665b8c6 100644 --- a/prowler/providers/googleworkspace/services/rules/rules_admin_privilege_granted_alert_configured/rules_admin_privilege_granted_alert_configured.metadata.json +++ b/prowler/providers/googleworkspace/services/rules/rules_admin_privilege_granted_alert_configured/rules_admin_privilege_granted_alert_configured.metadata.json @@ -9,7 +9,7 @@ "Severity": "medium", "ResourceType": "NotDefined", "ResourceGroup": "monitoring", - "Description": "The **User granted Admin privilege** system-defined alert rule should be enabled with alerts sent to the alert center, email notifications turned on, and recipients set to all super administrators. This ensures administrators are notified when a user is given elevated admin privileges.", + "Description": "The **User granted Admin privilege** system-defined alert rule should be enabled with alerts sent to the alert center, email notifications turned on, recipients set to all super administrators, and severity set to Medium or higher. This ensures administrators are notified when a user is given elevated admin privileges.", "Risk": "Without this alert enabled, administrators will not be notified when users receive **elevated admin privileges**. Unauthorized privilege escalation could indicate account compromise or insider threats and requires immediate verification.", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/googleworkspace/services/rules/rules_admin_privilege_granted_alert_configured/rules_admin_privilege_granted_alert_configured.py b/prowler/providers/googleworkspace/services/rules/rules_admin_privilege_granted_alert_configured/rules_admin_privilege_granted_alert_configured.py index 55b9a685bf..30ebec64e4 100644 --- a/prowler/providers/googleworkspace/services/rules/rules_admin_privilege_granted_alert_configured/rules_admin_privilege_granted_alert_configured.py +++ b/prowler/providers/googleworkspace/services/rules/rules_admin_privilege_granted_alert_configured/rules_admin_privilege_granted_alert_configured.py @@ -1,61 +1,25 @@ from typing import List from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.rules.lib.alerts import ( + evaluate_system_defined_alert, +) from prowler.providers.googleworkspace.services.rules.rules_client import ( rules_client, ) RULE_NAME = "User granted Admin privilege" +MINIMUM_SEVERITY = "MEDIUM" class rules_admin_privilege_granted_alert_configured(Check): - """Check that the User granted Admin privilege system-defined alert rule is fully configured.""" + """Check that the User granted Admin privilege system-defined alert rule is fully configured. + + CIS 6.4 requires the rule to be on, to notify by email, to include all + super administrators as recipients and to be set to MEDIUM severity or higher. + """ def execute(self) -> List[CheckReportGoogleWorkspace]: - findings = [] - - if rules_client.policies_fetched: - for alert in rules_client.system_defined_alerts: - if alert.display_name != RULE_NAME: - continue - - domain = rules_client.provider.identity.domain - report = CheckReportGoogleWorkspace( - metadata=self.metadata(), - resource=alert, - resource_id=f"systemDefinedAlert/{RULE_NAME}", - resource_name=RULE_NAME, - customer_id=rules_client.provider.identity.customer_id, - ) - - is_active = alert.state == "ACTIVE" - has_recipients = alert.email_notifications_enabled - all_super_admins = alert.all_super_admins - - if is_active and has_recipients and all_super_admins: - report.status = "PASS" - report.status_extended = ( - f"System-defined alert rule '{RULE_NAME}' is properly " - f"configured in domain {domain}: alert is ON, email " - f"notifications are enabled, and recipients include " - f"all super administrators." - ) - else: - report.status = "FAIL" - issues = [] - if not is_active: - issues.append("alert is OFF") - if not has_recipients: - issues.append("email notifications are disabled") - elif not all_super_admins: - issues.append( - "email recipients do not include all super administrators" - ) - report.status_extended = ( - f"System-defined alert rule '{RULE_NAME}' is not properly " - f"configured in domain {domain}: {', '.join(issues)}." - ) - - findings.append(report) - - return findings + return evaluate_system_defined_alert( + rules_client, self.metadata(), RULE_NAME, MINIMUM_SEVERITY + ) diff --git a/prowler/providers/googleworkspace/services/rules/rules_gmail_employee_spoofing_alert_configured/rules_gmail_employee_spoofing_alert_configured.metadata.json b/prowler/providers/googleworkspace/services/rules/rules_gmail_employee_spoofing_alert_configured/rules_gmail_employee_spoofing_alert_configured.metadata.json index 34885cb605..1966208719 100644 --- a/prowler/providers/googleworkspace/services/rules/rules_gmail_employee_spoofing_alert_configured/rules_gmail_employee_spoofing_alert_configured.metadata.json +++ b/prowler/providers/googleworkspace/services/rules/rules_gmail_employee_spoofing_alert_configured/rules_gmail_employee_spoofing_alert_configured.metadata.json @@ -9,7 +9,7 @@ "Severity": "medium", "ResourceType": "NotDefined", "ResourceGroup": "monitoring", - "Description": "The **Gmail potential employee spoofing** system-defined alert rule should be enabled with alerts sent to the alert center, email notifications turned on, and recipients set to all super administrators. This ensures administrators are notified when incoming messages have a sender name matching the directory but from an external domain.", + "Description": "The **Gmail potential employee spoofing** system-defined alert rule should be enabled with alerts sent to the alert center, email notifications turned on, recipients set to all super administrators, and severity set to Medium or higher. This ensures administrators are notified when incoming messages have a sender name matching the directory but from an external domain.", "Risk": "Without this alert enabled, administrators will not be notified of potential **employee spoofing via email**. Attackers may impersonate internal employees using external email addresses to conduct phishing attacks against the organization.", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/googleworkspace/services/rules/rules_gmail_employee_spoofing_alert_configured/rules_gmail_employee_spoofing_alert_configured.py b/prowler/providers/googleworkspace/services/rules/rules_gmail_employee_spoofing_alert_configured/rules_gmail_employee_spoofing_alert_configured.py index 0993f72d3d..4c648a43d6 100644 --- a/prowler/providers/googleworkspace/services/rules/rules_gmail_employee_spoofing_alert_configured/rules_gmail_employee_spoofing_alert_configured.py +++ b/prowler/providers/googleworkspace/services/rules/rules_gmail_employee_spoofing_alert_configured/rules_gmail_employee_spoofing_alert_configured.py @@ -1,61 +1,25 @@ from typing import List from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.rules.lib.alerts import ( + evaluate_system_defined_alert, +) from prowler.providers.googleworkspace.services.rules.rules_client import ( rules_client, ) RULE_NAME = "Gmail potential employee spoofing" +MINIMUM_SEVERITY = "MEDIUM" class rules_gmail_employee_spoofing_alert_configured(Check): - """Check that the Gmail potential employee spoofing system-defined alert rule is fully configured.""" + """Check that the Gmail potential employee spoofing system-defined alert rule is fully configured. + + CIS 6.8 requires the rule to be on, to notify by email, to include all + super administrators as recipients and to be set to MEDIUM severity or higher. + """ def execute(self) -> List[CheckReportGoogleWorkspace]: - findings = [] - - if rules_client.policies_fetched: - for alert in rules_client.system_defined_alerts: - if alert.display_name != RULE_NAME: - continue - - domain = rules_client.provider.identity.domain - report = CheckReportGoogleWorkspace( - metadata=self.metadata(), - resource=alert, - resource_id=f"systemDefinedAlert/{RULE_NAME}", - resource_name=RULE_NAME, - customer_id=rules_client.provider.identity.customer_id, - ) - - is_active = alert.state == "ACTIVE" - has_recipients = alert.email_notifications_enabled - all_super_admins = alert.all_super_admins - - if is_active and has_recipients and all_super_admins: - report.status = "PASS" - report.status_extended = ( - f"System-defined alert rule '{RULE_NAME}' is properly " - f"configured in domain {domain}: alert is ON, email " - f"notifications are enabled, and recipients include " - f"all super administrators." - ) - else: - report.status = "FAIL" - issues = [] - if not is_active: - issues.append("alert is OFF") - if not has_recipients: - issues.append("email notifications are disabled") - elif not all_super_admins: - issues.append( - "email recipients do not include all super administrators" - ) - report.status_extended = ( - f"System-defined alert rule '{RULE_NAME}' is not properly " - f"configured in domain {domain}: {', '.join(issues)}." - ) - - findings.append(report) - - return findings + return evaluate_system_defined_alert( + rules_client, self.metadata(), RULE_NAME, MINIMUM_SEVERITY + ) diff --git a/prowler/providers/googleworkspace/services/rules/rules_government_backed_attacks_alert_configured/rules_government_backed_attacks_alert_configured.metadata.json b/prowler/providers/googleworkspace/services/rules/rules_government_backed_attacks_alert_configured/rules_government_backed_attacks_alert_configured.metadata.json index 35fa25f248..3769f7b132 100644 --- a/prowler/providers/googleworkspace/services/rules/rules_government_backed_attacks_alert_configured/rules_government_backed_attacks_alert_configured.metadata.json +++ b/prowler/providers/googleworkspace/services/rules/rules_government_backed_attacks_alert_configured/rules_government_backed_attacks_alert_configured.metadata.json @@ -9,7 +9,7 @@ "Severity": "high", "ResourceType": "NotDefined", "ResourceGroup": "monitoring", - "Description": "The **Government-backed attacks** system-defined alert rule should be enabled with alerts sent to the alert center, email notifications turned on, and recipients set to all super administrators. This ensures administrators are notified when Google believes users are being targeted by a government-backed attacker.", + "Description": "The **Government-backed attacks** system-defined alert rule should be enabled with alerts sent to the alert center, email notifications turned on, recipients set to all super administrators, and severity set to High or higher. This ensures administrators are notified when Google believes users are being targeted by a government-backed attacker.", "Risk": "Without this alert enabled, administrators will not be notified of potential **government-backed attacks** targeting their users. These attacks are sophisticated and require immediate response to protect affected accounts and investigate the threat.", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/googleworkspace/services/rules/rules_government_backed_attacks_alert_configured/rules_government_backed_attacks_alert_configured.py b/prowler/providers/googleworkspace/services/rules/rules_government_backed_attacks_alert_configured/rules_government_backed_attacks_alert_configured.py index b566d99e0c..aa8eec5ca0 100644 --- a/prowler/providers/googleworkspace/services/rules/rules_government_backed_attacks_alert_configured/rules_government_backed_attacks_alert_configured.py +++ b/prowler/providers/googleworkspace/services/rules/rules_government_backed_attacks_alert_configured/rules_government_backed_attacks_alert_configured.py @@ -1,61 +1,25 @@ from typing import List from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.rules.lib.alerts import ( + evaluate_system_defined_alert, +) from prowler.providers.googleworkspace.services.rules.rules_client import ( rules_client, ) RULE_NAME = "Government-backed attacks" +MINIMUM_SEVERITY = "HIGH" class rules_government_backed_attacks_alert_configured(Check): - """Check that the Government-backed attacks system-defined alert rule is fully configured.""" + """Check that the Government-backed attacks system-defined alert rule is fully configured. + + CIS 6.2 requires the rule to be on, to notify by email, to include all + super administrators as recipients and to be set to HIGH severity or higher. + """ def execute(self) -> List[CheckReportGoogleWorkspace]: - findings = [] - - if rules_client.policies_fetched: - for alert in rules_client.system_defined_alerts: - if alert.display_name != RULE_NAME: - continue - - domain = rules_client.provider.identity.domain - report = CheckReportGoogleWorkspace( - metadata=self.metadata(), - resource=alert, - resource_id=f"systemDefinedAlert/{RULE_NAME}", - resource_name=RULE_NAME, - customer_id=rules_client.provider.identity.customer_id, - ) - - is_active = alert.state == "ACTIVE" - has_recipients = alert.email_notifications_enabled - all_super_admins = alert.all_super_admins - - if is_active and has_recipients and all_super_admins: - report.status = "PASS" - report.status_extended = ( - f"System-defined alert rule '{RULE_NAME}' is properly " - f"configured in domain {domain}: alert is ON, email " - f"notifications are enabled, and recipients include " - f"all super administrators." - ) - else: - report.status = "FAIL" - issues = [] - if not is_active: - issues.append("alert is OFF") - if not has_recipients: - issues.append("email notifications are disabled") - elif not all_super_admins: - issues.append( - "email recipients do not include all super administrators" - ) - report.status_extended = ( - f"System-defined alert rule '{RULE_NAME}' is not properly " - f"configured in domain {domain}: {', '.join(issues)}." - ) - - findings.append(report) - - return findings + return evaluate_system_defined_alert( + rules_client, self.metadata(), RULE_NAME, MINIMUM_SEVERITY + ) diff --git a/prowler/providers/googleworkspace/services/rules/rules_leaked_password_alert_configured/rules_leaked_password_alert_configured.metadata.json b/prowler/providers/googleworkspace/services/rules/rules_leaked_password_alert_configured/rules_leaked_password_alert_configured.metadata.json index 870144a873..8866d76e02 100644 --- a/prowler/providers/googleworkspace/services/rules/rules_leaked_password_alert_configured/rules_leaked_password_alert_configured.metadata.json +++ b/prowler/providers/googleworkspace/services/rules/rules_leaked_password_alert_configured/rules_leaked_password_alert_configured.metadata.json @@ -9,7 +9,7 @@ "Severity": "medium", "ResourceType": "NotDefined", "ResourceGroup": "monitoring", - "Description": "The **Leaked password** system-defined alert rule should be enabled with alerts sent to the alert center, email notifications turned on, and recipients set to all super administrators. This ensures administrators are notified when Google detects compromised credentials requiring a password reset.", + "Description": "The **Leaked password** system-defined alert rule should be enabled with alerts sent to the alert center, email notifications turned on, recipients set to all super administrators, and severity set to Medium or higher. This ensures administrators are notified when Google detects compromised credentials requiring a password reset.", "Risk": "Without this alert enabled, administrators will not be notified when Google detects that a user's **credentials have been compromised** in a publicized breach. The user likely reused their password at another site that was breached, and their account requires an immediate password change.", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/googleworkspace/services/rules/rules_leaked_password_alert_configured/rules_leaked_password_alert_configured.py b/prowler/providers/googleworkspace/services/rules/rules_leaked_password_alert_configured/rules_leaked_password_alert_configured.py index 797bed4f71..8134661eeb 100644 --- a/prowler/providers/googleworkspace/services/rules/rules_leaked_password_alert_configured/rules_leaked_password_alert_configured.py +++ b/prowler/providers/googleworkspace/services/rules/rules_leaked_password_alert_configured/rules_leaked_password_alert_configured.py @@ -1,61 +1,25 @@ from typing import List from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.rules.lib.alerts import ( + evaluate_system_defined_alert, +) from prowler.providers.googleworkspace.services.rules.rules_client import ( rules_client, ) RULE_NAME = "Leaked password" +MINIMUM_SEVERITY = "MEDIUM" class rules_leaked_password_alert_configured(Check): - """Check that the Leaked password system-defined alert rule is fully configured.""" + """Check that the Leaked password system-defined alert rule is fully configured. + + CIS 6.7 requires the rule to be on, to notify by email, to include all + super administrators as recipients and to be set to MEDIUM severity or higher. + """ def execute(self) -> List[CheckReportGoogleWorkspace]: - findings = [] - - if rules_client.policies_fetched: - for alert in rules_client.system_defined_alerts: - if alert.display_name != RULE_NAME: - continue - - domain = rules_client.provider.identity.domain - report = CheckReportGoogleWorkspace( - metadata=self.metadata(), - resource=alert, - resource_id=f"systemDefinedAlert/{RULE_NAME}", - resource_name=RULE_NAME, - customer_id=rules_client.provider.identity.customer_id, - ) - - is_active = alert.state == "ACTIVE" - has_recipients = alert.email_notifications_enabled - all_super_admins = alert.all_super_admins - - if is_active and has_recipients and all_super_admins: - report.status = "PASS" - report.status_extended = ( - f"System-defined alert rule '{RULE_NAME}' is properly " - f"configured in domain {domain}: alert is ON, email " - f"notifications are enabled, and recipients include " - f"all super administrators." - ) - else: - report.status = "FAIL" - issues = [] - if not is_active: - issues.append("alert is OFF") - if not has_recipients: - issues.append("email notifications are disabled") - elif not all_super_admins: - issues.append( - "email recipients do not include all super administrators" - ) - report.status_extended = ( - f"System-defined alert rule '{RULE_NAME}' is not properly " - f"configured in domain {domain}: {', '.join(issues)}." - ) - - findings.append(report) - - return findings + return evaluate_system_defined_alert( + rules_client, self.metadata(), RULE_NAME, MINIMUM_SEVERITY + ) diff --git a/prowler/providers/googleworkspace/services/rules/rules_password_changed_alert_configured/rules_password_changed_alert_configured.metadata.json b/prowler/providers/googleworkspace/services/rules/rules_password_changed_alert_configured/rules_password_changed_alert_configured.metadata.json index f1f4fbc622..bdd6a84d5f 100644 --- a/prowler/providers/googleworkspace/services/rules/rules_password_changed_alert_configured/rules_password_changed_alert_configured.metadata.json +++ b/prowler/providers/googleworkspace/services/rules/rules_password_changed_alert_configured/rules_password_changed_alert_configured.metadata.json @@ -9,7 +9,7 @@ "Severity": "medium", "ResourceType": "NotDefined", "ResourceGroup": "monitoring", - "Description": "The **User's password changed** system-defined alert rule should be enabled with alerts sent to the alert center, email notifications turned on, and recipients set to all super administrators. This ensures administrators are promptly notified when user passwords are changed.", + "Description": "The **User's password changed** system-defined alert rule should be enabled with alerts sent to the alert center, email notifications turned on, recipients set to all super administrators, and severity set to Medium or higher. This ensures administrators are promptly notified when user passwords are changed.", "Risk": "Without this alert enabled, administrators will not be notified when user passwords are changed. This could allow **credential compromise and account takeover** to go undetected, giving attackers time to establish persistence.", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/googleworkspace/services/rules/rules_password_changed_alert_configured/rules_password_changed_alert_configured.py b/prowler/providers/googleworkspace/services/rules/rules_password_changed_alert_configured/rules_password_changed_alert_configured.py index fb6382caf8..c61c6e9d76 100644 --- a/prowler/providers/googleworkspace/services/rules/rules_password_changed_alert_configured/rules_password_changed_alert_configured.py +++ b/prowler/providers/googleworkspace/services/rules/rules_password_changed_alert_configured/rules_password_changed_alert_configured.py @@ -1,61 +1,25 @@ from typing import List from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.rules.lib.alerts import ( + evaluate_system_defined_alert, +) from prowler.providers.googleworkspace.services.rules.rules_client import ( rules_client, ) RULE_NAME = "User's password changed" +MINIMUM_SEVERITY = "MEDIUM" class rules_password_changed_alert_configured(Check): - """Check that the User's password changed system-defined alert rule is fully configured.""" + """Check that the User's password changed system-defined alert rule is fully configured. + + CIS 6.1 requires the rule to be on, to notify by email, to include all + super administrators as recipients and to be set to MEDIUM severity or higher. + """ def execute(self) -> List[CheckReportGoogleWorkspace]: - findings = [] - - if rules_client.policies_fetched: - for alert in rules_client.system_defined_alerts: - if alert.display_name != RULE_NAME: - continue - - domain = rules_client.provider.identity.domain - report = CheckReportGoogleWorkspace( - metadata=self.metadata(), - resource=alert, - resource_id=f"systemDefinedAlert/{RULE_NAME}", - resource_name=RULE_NAME, - customer_id=rules_client.provider.identity.customer_id, - ) - - is_active = alert.state == "ACTIVE" - has_recipients = alert.email_notifications_enabled - all_super_admins = alert.all_super_admins - - if is_active and has_recipients and all_super_admins: - report.status = "PASS" - report.status_extended = ( - f"System-defined alert rule '{RULE_NAME}' is properly " - f"configured in domain {domain}: alert is ON, email " - f"notifications are enabled, and recipients include " - f"all super administrators." - ) - else: - report.status = "FAIL" - issues = [] - if not is_active: - issues.append("alert is OFF") - if not has_recipients: - issues.append("email notifications are disabled") - elif not all_super_admins: - issues.append( - "email recipients do not include all super administrators" - ) - report.status_extended = ( - f"System-defined alert rule '{RULE_NAME}' is not properly " - f"configured in domain {domain}: {', '.join(issues)}." - ) - - findings.append(report) - - return findings + return evaluate_system_defined_alert( + rules_client, self.metadata(), RULE_NAME, MINIMUM_SEVERITY + ) diff --git a/prowler/providers/googleworkspace/services/rules/rules_service.py b/prowler/providers/googleworkspace/services/rules/rules_service.py index 76b0b0df7a..962548d7b4 100644 --- a/prowler/providers/googleworkspace/services/rules/rules_service.py +++ b/prowler/providers/googleworkspace/services/rules/rules_service.py @@ -90,6 +90,7 @@ class Rules(GoogleWorkspaceService): state=default_state, email_notifications_enabled=is_active_default, all_super_admins=is_active_default, + from_default=True, ) logger.debug( f"System-defined alert rule (default): {rule_name} " @@ -119,7 +120,12 @@ class Rules(GoogleWorkspaceService): state = value.get("state", "INACTIVE") alert_center_action = value.get("action", {}).get("alertCenterAction", {}) - severity = alert_center_action.get("alertCenterConfig", {}).get("severity") + alert_center_config = alert_center_action.get("alertCenterConfig", {}) + severity = alert_center_config.get("severity") + # CIS remediation step 6: "Select Send to alert center (This will result + # in the alert being set to On)", so this is the toggle the audit's + # "Ensure that Alerts is set to On" refers to. + alert_center_status = alert_center_config.get("status") recipients = alert_center_action.get("recipients", []) all_super_admins = any(r.get("allSuperAdmins") is True for r in recipients) @@ -128,6 +134,7 @@ class Rules(GoogleWorkspaceService): display_name=display_name, state=state, severity=severity, + alert_center_status=alert_center_status, email_notifications_enabled=len(recipients) > 0, all_super_admins=all_super_admins, ) @@ -139,5 +146,10 @@ class SystemDefinedAlert(BaseModel): display_name: str state: str = "INACTIVE" severity: Optional[str] = None + # rule.system_defined_alerts action.alertCenterAction.alertCenterConfig.status + alert_center_status: Optional[str] = None email_notifications_enabled: bool = False all_super_admins: bool = False + # True when the API returned no policy for the rule and the values above + # were inferred from Google's documented defaults rather than observed. + from_default: bool = False diff --git a/prowler/providers/googleworkspace/services/rules/rules_suspicious_activity_suspension_alert_configured/rules_suspicious_activity_suspension_alert_configured.metadata.json b/prowler/providers/googleworkspace/services/rules/rules_suspicious_activity_suspension_alert_configured/rules_suspicious_activity_suspension_alert_configured.metadata.json index b79120abde..af11bb2e0d 100644 --- a/prowler/providers/googleworkspace/services/rules/rules_suspicious_activity_suspension_alert_configured/rules_suspicious_activity_suspension_alert_configured.metadata.json +++ b/prowler/providers/googleworkspace/services/rules/rules_suspicious_activity_suspension_alert_configured/rules_suspicious_activity_suspension_alert_configured.metadata.json @@ -9,7 +9,7 @@ "Severity": "high", "ResourceType": "NotDefined", "ResourceGroup": "monitoring", - "Description": "The **User suspended due to suspicious activity** system-defined alert rule should be enabled with alerts sent to the alert center, email notifications turned on, and recipients set to all super administrators. This ensures administrators are notified when Google suspends an account due to a potential compromise.", + "Description": "The **User suspended due to suspicious activity** system-defined alert rule should be enabled with alerts sent to the alert center, email notifications turned on, recipients set to all super administrators, and severity set to High or higher. This ensures administrators are notified when Google suspends an account due to a potential compromise.", "Risk": "Without this alert enabled, administrators will not be promptly notified when Google **suspends a user account** due to detected compromise. The suspended user cannot work, and the underlying security incident requires immediate investigation.", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/googleworkspace/services/rules/rules_suspicious_activity_suspension_alert_configured/rules_suspicious_activity_suspension_alert_configured.py b/prowler/providers/googleworkspace/services/rules/rules_suspicious_activity_suspension_alert_configured/rules_suspicious_activity_suspension_alert_configured.py index cd243be8e3..f9f4c2cabe 100644 --- a/prowler/providers/googleworkspace/services/rules/rules_suspicious_activity_suspension_alert_configured/rules_suspicious_activity_suspension_alert_configured.py +++ b/prowler/providers/googleworkspace/services/rules/rules_suspicious_activity_suspension_alert_configured/rules_suspicious_activity_suspension_alert_configured.py @@ -1,61 +1,25 @@ from typing import List from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.rules.lib.alerts import ( + evaluate_system_defined_alert, +) from prowler.providers.googleworkspace.services.rules.rules_client import ( rules_client, ) RULE_NAME = "User suspended due to suspicious activity" +MINIMUM_SEVERITY = "HIGH" class rules_suspicious_activity_suspension_alert_configured(Check): - """Check that the User suspended due to suspicious activity system-defined alert rule is fully configured.""" + """Check that the User suspended due to suspicious activity system-defined alert rule is fully configured. + + CIS 6.3 requires the rule to be on, to notify by email, to include all + super administrators as recipients and to be set to HIGH severity or higher. + """ def execute(self) -> List[CheckReportGoogleWorkspace]: - findings = [] - - if rules_client.policies_fetched: - for alert in rules_client.system_defined_alerts: - if alert.display_name != RULE_NAME: - continue - - domain = rules_client.provider.identity.domain - report = CheckReportGoogleWorkspace( - metadata=self.metadata(), - resource=alert, - resource_id=f"systemDefinedAlert/{RULE_NAME}", - resource_name=RULE_NAME, - customer_id=rules_client.provider.identity.customer_id, - ) - - is_active = alert.state == "ACTIVE" - has_recipients = alert.email_notifications_enabled - all_super_admins = alert.all_super_admins - - if is_active and has_recipients and all_super_admins: - report.status = "PASS" - report.status_extended = ( - f"System-defined alert rule '{RULE_NAME}' is properly " - f"configured in domain {domain}: alert is ON, email " - f"notifications are enabled, and recipients include " - f"all super administrators." - ) - else: - report.status = "FAIL" - issues = [] - if not is_active: - issues.append("alert is OFF") - if not has_recipients: - issues.append("email notifications are disabled") - elif not all_super_admins: - issues.append( - "email recipients do not include all super administrators" - ) - report.status_extended = ( - f"System-defined alert rule '{RULE_NAME}' is not properly " - f"configured in domain {domain}: {', '.join(issues)}." - ) - - findings.append(report) - - return findings + return evaluate_system_defined_alert( + rules_client, self.metadata(), RULE_NAME, MINIMUM_SEVERITY + ) diff --git a/prowler/providers/googleworkspace/services/rules/rules_suspicious_login_alert_configured/rules_suspicious_login_alert_configured.metadata.json b/prowler/providers/googleworkspace/services/rules/rules_suspicious_login_alert_configured/rules_suspicious_login_alert_configured.metadata.json index 93af99b565..132a3fd3e2 100644 --- a/prowler/providers/googleworkspace/services/rules/rules_suspicious_login_alert_configured/rules_suspicious_login_alert_configured.metadata.json +++ b/prowler/providers/googleworkspace/services/rules/rules_suspicious_login_alert_configured/rules_suspicious_login_alert_configured.metadata.json @@ -9,7 +9,7 @@ "Severity": "low", "ResourceType": "NotDefined", "ResourceGroup": "monitoring", - "Description": "The **Suspicious login** system-defined alert rule should be enabled with alerts sent to the alert center, email notifications turned on, and recipients set to all super administrators. This ensures administrators are notified when Google detects a sign-in attempt that does not match a user's normal behavior.", + "Description": "The **Suspicious login** system-defined alert rule should be enabled with alerts sent to the alert center, email notifications turned on, recipients set to all super administrators, and severity set to Low or higher. This ensures administrators are notified when Google detects a sign-in attempt that does not match a user's normal behavior.", "Risk": "Without this alert enabled, administrators will not be notified of **suspicious login attempts** such as sign-ins from unusual locations. This could indicate an active attack using previously obtained credentials.", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/googleworkspace/services/rules/rules_suspicious_login_alert_configured/rules_suspicious_login_alert_configured.py b/prowler/providers/googleworkspace/services/rules/rules_suspicious_login_alert_configured/rules_suspicious_login_alert_configured.py index eee7844c43..951015c0e8 100644 --- a/prowler/providers/googleworkspace/services/rules/rules_suspicious_login_alert_configured/rules_suspicious_login_alert_configured.py +++ b/prowler/providers/googleworkspace/services/rules/rules_suspicious_login_alert_configured/rules_suspicious_login_alert_configured.py @@ -1,61 +1,25 @@ from typing import List from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.rules.lib.alerts import ( + evaluate_system_defined_alert, +) from prowler.providers.googleworkspace.services.rules.rules_client import ( rules_client, ) RULE_NAME = "Suspicious login" +MINIMUM_SEVERITY = "LOW" class rules_suspicious_login_alert_configured(Check): - """Check that the Suspicious login system-defined alert rule is fully configured.""" + """Check that the Suspicious login system-defined alert rule is fully configured. + + CIS 6.6 requires the rule to be on, to notify by email, to include all + super administrators as recipients and to be set to LOW severity or higher. + """ def execute(self) -> List[CheckReportGoogleWorkspace]: - findings = [] - - if rules_client.policies_fetched: - for alert in rules_client.system_defined_alerts: - if alert.display_name != RULE_NAME: - continue - - domain = rules_client.provider.identity.domain - report = CheckReportGoogleWorkspace( - metadata=self.metadata(), - resource=alert, - resource_id=f"systemDefinedAlert/{RULE_NAME}", - resource_name=RULE_NAME, - customer_id=rules_client.provider.identity.customer_id, - ) - - is_active = alert.state == "ACTIVE" - has_recipients = alert.email_notifications_enabled - all_super_admins = alert.all_super_admins - - if is_active and has_recipients and all_super_admins: - report.status = "PASS" - report.status_extended = ( - f"System-defined alert rule '{RULE_NAME}' is properly " - f"configured in domain {domain}: alert is ON, email " - f"notifications are enabled, and recipients include " - f"all super administrators." - ) - else: - report.status = "FAIL" - issues = [] - if not is_active: - issues.append("alert is OFF") - if not has_recipients: - issues.append("email notifications are disabled") - elif not all_super_admins: - issues.append( - "email recipients do not include all super administrators" - ) - report.status_extended = ( - f"System-defined alert rule '{RULE_NAME}' is not properly " - f"configured in domain {domain}: {', '.join(issues)}." - ) - - findings.append(report) - - return findings + return evaluate_system_defined_alert( + rules_client, self.metadata(), RULE_NAME, MINIMUM_SEVERITY + ) diff --git a/prowler/providers/googleworkspace/services/rules/rules_suspicious_programmatic_login_alert_configured/rules_suspicious_programmatic_login_alert_configured.metadata.json b/prowler/providers/googleworkspace/services/rules/rules_suspicious_programmatic_login_alert_configured/rules_suspicious_programmatic_login_alert_configured.metadata.json index 202df2adad..e4979b65d3 100644 --- a/prowler/providers/googleworkspace/services/rules/rules_suspicious_programmatic_login_alert_configured/rules_suspicious_programmatic_login_alert_configured.metadata.json +++ b/prowler/providers/googleworkspace/services/rules/rules_suspicious_programmatic_login_alert_configured/rules_suspicious_programmatic_login_alert_configured.metadata.json @@ -9,7 +9,7 @@ "Severity": "low", "ResourceType": "NotDefined", "ResourceGroup": "monitoring", - "Description": "The **Suspicious programmatic login** system-defined alert rule should be enabled with alerts sent to the alert center, email notifications turned on, and recipients set to all super administrators. This ensures administrators are notified when Google detects suspicious login attempts from applications or programs.", + "Description": "The **Suspicious programmatic login** system-defined alert rule should be enabled with alerts sent to the alert center, email notifications turned on, recipients set to all super administrators, and severity set to Low or higher. This ensures administrators are notified when Google detects suspicious login attempts from applications or programs.", "Risk": "Without this alert enabled, administrators will not be notified of **suspicious programmatic login attempts**. This could indicate automated credential stuffing or unauthorized API access using compromised credentials.", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/googleworkspace/services/rules/rules_suspicious_programmatic_login_alert_configured/rules_suspicious_programmatic_login_alert_configured.py b/prowler/providers/googleworkspace/services/rules/rules_suspicious_programmatic_login_alert_configured/rules_suspicious_programmatic_login_alert_configured.py index 0d609a99e1..377da9e1c9 100644 --- a/prowler/providers/googleworkspace/services/rules/rules_suspicious_programmatic_login_alert_configured/rules_suspicious_programmatic_login_alert_configured.py +++ b/prowler/providers/googleworkspace/services/rules/rules_suspicious_programmatic_login_alert_configured/rules_suspicious_programmatic_login_alert_configured.py @@ -1,61 +1,25 @@ from typing import List from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.rules.lib.alerts import ( + evaluate_system_defined_alert, +) from prowler.providers.googleworkspace.services.rules.rules_client import ( rules_client, ) RULE_NAME = "Suspicious programmatic login" +MINIMUM_SEVERITY = "LOW" class rules_suspicious_programmatic_login_alert_configured(Check): - """Check that the Suspicious programmatic login system-defined alert rule is fully configured.""" + """Check that the Suspicious programmatic login system-defined alert rule is fully configured. + + CIS 6.5 requires the rule to be on, to notify by email, to include all + super administrators as recipients and to be set to LOW severity or higher. + """ def execute(self) -> List[CheckReportGoogleWorkspace]: - findings = [] - - if rules_client.policies_fetched: - for alert in rules_client.system_defined_alerts: - if alert.display_name != RULE_NAME: - continue - - domain = rules_client.provider.identity.domain - report = CheckReportGoogleWorkspace( - metadata=self.metadata(), - resource=alert, - resource_id=f"systemDefinedAlert/{RULE_NAME}", - resource_name=RULE_NAME, - customer_id=rules_client.provider.identity.customer_id, - ) - - is_active = alert.state == "ACTIVE" - has_recipients = alert.email_notifications_enabled - all_super_admins = alert.all_super_admins - - if is_active and has_recipients and all_super_admins: - report.status = "PASS" - report.status_extended = ( - f"System-defined alert rule '{RULE_NAME}' is properly " - f"configured in domain {domain}: alert is ON, email " - f"notifications are enabled, and recipients include " - f"all super administrators." - ) - else: - report.status = "FAIL" - issues = [] - if not is_active: - issues.append("alert is OFF") - if not has_recipients: - issues.append("email notifications are disabled") - elif not all_super_admins: - issues.append( - "email recipients do not include all super administrators" - ) - report.status_extended = ( - f"System-defined alert rule '{RULE_NAME}' is not properly " - f"configured in domain {domain}: {', '.join(issues)}." - ) - - findings.append(report) - - return findings + return evaluate_system_defined_alert( + rules_client, self.metadata(), RULE_NAME, MINIMUM_SEVERITY + ) diff --git a/prowler/providers/googleworkspace/services/security/lib/__init__.py b/prowler/providers/googleworkspace/services/security/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/googleworkspace/services/security/lib/durations.py b/prowler/providers/googleworkspace/services/security/lib/durations.py new file mode 100644 index 0000000000..93736e9b7d --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/lib/durations.py @@ -0,0 +1,89 @@ +"""Helpers for the duration and timestamp values of the Cloud Identity security policies.""" + +import re +from datetime import datetime, timezone +from typing import Optional + +from dateutil import parser as date_parser + +_DURATION = re.compile(r"^(\d+(?:\.\d+)?)s$") + +ONE_HOUR_SECONDS = 3600 +ONE_DAY_SECONDS = 86400 +TWO_WEEKS_SECONDS = 1209600 +ONE_YEAR_SECONDS = 31536000 + +# The API reports enforcement being OFF as the protobuf zero-value Timestamp +# rather than as a null or an empty string. +_ENFORCEMENT_OFF_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc) + + +def parse_duration_seconds(value: Optional[str]) -> Optional[int]: + """Return the seconds in a protobuf duration string such as "1209600s". + + Returns None when the value is missing or not a duration Prowler knows how + to read, so callers can tell "not configured" apart from a real length. + """ + if not value or not isinstance(value, str): + return None + match = _DURATION.match(value.strip()) + if not match: + return None + return int(float(match.group(1))) + + +def format_duration(value: Optional[str]) -> str: + """Render a duration string in the largest whole unit, for a finding message.""" + seconds = parse_duration_seconds(value) + if seconds is None: + return "not configured" + if seconds == 0: + return "none" + for unit_seconds, name in ( + (ONE_DAY_SECONDS, "day"), + (ONE_HOUR_SECONDS, "hour"), + ): + units = seconds / unit_seconds + if units.is_integer(): + return f"{int(units)} {name}(s)" + return f"{seconds} second(s)" + + +def _parse_timestamp(value: Optional[str]) -> Optional[datetime]: + """Parse an API timestamp, tolerating any fractional-second precision. + + protobuf emits up to nanosecond precision, which `datetime.fromisoformat` + rejects before Python 3.11, so the shared dateutil parser is used instead. + """ + if not value or not isinstance(value, str): + return None + try: + parsed = date_parser.isoparse(value) + except (ValueError, OverflowError): + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed + + +def enforcement_issue( + enforced_from: Optional[str], + allow_scheduled: bool = False, + now: Optional[datetime] = None, +) -> Optional[str]: + """Return why 2-Step Verification enforcement is not in effect, or None. + + Google accepts a future start date, which means the policy is scheduled but + not yet applied to anyone. CIS 4.1.1.2 accepts "On from " explicitly + while 4.1.1.1 and 4.1.1.3 ask for plain "On", hence `allow_scheduled`. + """ + if not enforced_from: + return "enforcement is not configured and defaults to OFF" + parsed = _parse_timestamp(enforced_from) + if parsed is None: + return f"the enforcement start date '{enforced_from}' could not be read" + if parsed <= _ENFORCEMENT_OFF_EPOCH: + return "enforcement is set to OFF" + if not allow_scheduled and parsed > (now or datetime.now(timezone.utc)): + return f"enforcement does not start until {enforced_from}" + return None diff --git a/prowler/providers/googleworkspace/services/security/lib/scope.py b/prowler/providers/googleworkspace/services/security/lib/scope.py new file mode 100644 index 0000000000..0ef08d2808 --- /dev/null +++ b/prowler/providers/googleworkspace/services/security/lib/scope.py @@ -0,0 +1,46 @@ +"""Whether the domain-wide policy values describe what every user actually gets.""" + +from typing import FrozenSet, List, Optional + + +def _listing(settings: List[str]) -> str: + return f"{', '.join(settings)} {'are' if len(settings) > 1 else 'is'}" + + +def unevaluable_reason(policies, evaluated_settings: FrozenSet[str]) -> Optional[str]: + """Return why the domain-wide values cannot be judged at all, or None. + + In both cases the values a check would read were never reported, so every + condition it evaluates would be built on Prowler's own defaults. + """ + if policies.unresolved_scope: + return ( + "the root organizational unit could not be resolved, so the " + "domain-wide policies could not be told apart from the ones scoped " + "to an organizational unit" + ) + unobserved = sorted(set(policies.unobserved_settings) & evaluated_settings) + if unobserved: + return ( + f"{_listing(unobserved)} only configured for a group or an " + f"organizational unit, so no domain-wide value was reported" + ) + return None + + +def failures_shadowed_by_overrides(policies, failing_settings: FrozenSet[str]) -> bool: + """Whether every failing setting is also overridden below the domain.""" + return bool(failing_settings) and failing_settings <= set( + policies.overridden_settings + ) + + +def override_caveat(policies, evaluated_settings: FrozenSet[str]) -> str: + """Return what a group or an OU also overrides on top of the domain, or an empty string.""" + overridden = sorted(set(policies.overridden_settings) & evaluated_settings) + if not overridden: + return "" + return ( + f"{_listing(overridden)} also overridden for at least one group or " + f"organizational unit, so this does not describe every user" + ) diff --git a/prowler/providers/googleworkspace/services/security/security_2sv_enforced/security_2sv_enforced.metadata.json b/prowler/providers/googleworkspace/services/security/security_2sv_enforced/security_2sv_enforced.metadata.json index 3a2c0b4566..c9e790a7b8 100644 --- a/prowler/providers/googleworkspace/services/security/security_2sv_enforced/security_2sv_enforced.metadata.json +++ b/prowler/providers/googleworkspace/services/security/security_2sv_enforced/security_2sv_enforced.metadata.json @@ -9,7 +9,7 @@ "Severity": "high", "ResourceType": "NotDefined", "ResourceGroup": "IAM", - "Description": "The domain-level policy **enforces 2-Step Verification (Multi-Factor Authentication)** for all users. 2-Step Verification requires users to present a second form of authentication beyond their password, significantly reducing the risk of account compromise.", + "Description": "The domain-level policy **enforces 2-Step Verification** for all users, allows users to turn it on, keeps the new user enrollment period at two weeks or less, disables device trust and excludes verification codes via text or phone call from the accepted methods.", "Risk": "Without 2-Step Verification enforcement, users can access their accounts with **only a password**. If credentials are compromised through phishing, credential stuffing, or data breaches, attackers gain **immediate access** to the user's account and organizational data without any additional verification.", "RelatedUrl": "", "AdditionalURLs": [ @@ -35,5 +35,5 @@ "RelatedTo": [ "security_2sv_hardware_keys_admins" ], - "Notes": "" + "Notes": "CIS 4.1.1.1 audits the group holding every admin role, but the Cloud Identity Policy API returns domain-wide policies only. This check evaluates the customer-level policy, which applies to administrators as well, so it cannot confirm a separate admin-role group is configured." } diff --git a/prowler/providers/googleworkspace/services/security/security_2sv_enforced/security_2sv_enforced.py b/prowler/providers/googleworkspace/services/security/security_2sv_enforced/security_2sv_enforced.py index 7cf17b348e..3c12091f81 100644 --- a/prowler/providers/googleworkspace/services/security/security_2sv_enforced/security_2sv_enforced.py +++ b/prowler/providers/googleworkspace/services/security/security_2sv_enforced/security_2sv_enforced.py @@ -1,17 +1,53 @@ from typing import List from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.security.lib.durations import ( + TWO_WEEKS_SECONDS, + enforcement_issue, + format_duration, + parse_duration_seconds, +) +from prowler.providers.googleworkspace.services.security.lib.scope import ( + override_caveat, + unevaluable_reason, +) from prowler.providers.googleworkspace.services.security.security_client import ( security_client, ) +# "Methods: Any except verification codes via text, phone call". Listed as an +# allow list so a value Prowler does not know cannot pass by not being "ALL". +TELEPHONY_FREE_FACTOR_SETS = { + "NO_TELEPHONY", + "PASSKEY_ONLY", + "PASSKEY_PLUS_SECURITY_CODE", + "PASSKEY_PLUS_IP_BOUND_SECURITY_CODE", +} + +# The settings this check reads, to tell whether an override reaches it. +EVALUATED_SETTINGS = frozenset( + { + "security.two_step_verification_enrollment", + "security.two_step_verification_enforcement", + "security.two_step_verification_enforcement_factor", + "security.two_step_verification_device_trust", + "security.two_step_verification_grace_period", + } +) + class security_2sv_enforced(Check): - """Check that 2-Step Verification is enforced for all users. + """Check that 2-Step Verification is enforced following the CIS audit steps. - This check verifies that the domain-level policy enforces 2-Step - Verification (Multi-Factor Authentication) for all users, reducing - the risk of account compromise through stolen credentials. + CIS 4.1.1.1 and 4.1.1.3 ask for more than enforcement being on: users must + be allowed to turn 2-Step Verification on, the new user enrollment period + must not exceed two weeks, device trust must be off and verification codes + via text or phone call must not be an accepted method. Each of those is + evaluated here so the requirement cannot pass on enforcement alone. + + Note: 4.1.1.1 audits the group holding every admin role, but the Cloud + Identity Policy API returns domain-wide policies only. This check evaluates + the customer-level policy, which applies to administrators too. """ def execute(self) -> List[CheckReportGoogleWorkspace]: @@ -26,33 +62,111 @@ class security_2sv_enforced(Check): customer_id=security_client.provider.identity.customer_id, ) - enforced_from = security_client.policies.two_sv_enforced_from - # The API returns "1970-01-01T00:00:00Z" (protobuf zero-value - # Timestamp) when enforcement is OFF, not null or empty. - enforcement_off_epoch = "1970-01-01T00:00:00Z" + policies = security_client.policies + domain = security_client.provider.identity.domain - if enforced_from and enforced_from != enforcement_off_epoch: - report.status = "PASS" + unevaluable = unevaluable_reason(policies, EVALUATED_SETTINGS) + if unevaluable: + report.status = "MANUAL" report.status_extended = ( - f"2-Step Verification enforcement is active " - f"(enforced from {enforced_from}) " - f"in domain {security_client.provider.identity.domain}." + f"2-Step Verification could not be evaluated in domain " + f"{domain}: {unevaluable}. Review it in the Admin console." + ) + findings.append(report) + return findings + + caveat = override_caveat(policies, EVALUATED_SETTINGS) + issues = [] # (setting, why it fails) + + enforced_from = policies.two_sv_enforced_from + enforcement = enforcement_issue(enforced_from) + if enforcement: + issues.append( + ("security.two_step_verification_enforcement", enforcement) + ) + + if policies.two_sv_allow_enrollment is False: + issues.append( + ( + "security.two_step_verification_enrollment", + "users are not allowed to turn on 2-Step Verification", + ) + ) + + # Google's default is no enrollment period, which is stricter than + # the two weeks the benchmark asks for, so only longer periods fail. + # A value Prowler cannot read fails closed rather than being skipped. + raw_grace_period = policies.two_sv_enrollment_grace_period + grace_period = parse_duration_seconds(raw_grace_period) + if raw_grace_period and grace_period is None: + issues.append( + ( + "security.two_step_verification_grace_period", + f"the new user enrollment period '{raw_grace_period}' " + f"could not be read", + ) + ) + elif grace_period is not None and grace_period > TWO_WEEKS_SECONDS: + issues.append( + ( + "security.two_step_verification_grace_period", + f"the new user enrollment period is " + f"{format_duration(policies.two_sv_enrollment_grace_period)} " + f"(should not exceed 2 weeks)", + ) + ) + + if policies.two_sv_allow_trusting_device is not False: + issues.append( + ( + "security.two_step_verification_device_trust", + ( + "users are allowed to trust their device" + if policies.two_sv_allow_trusting_device + else "device trust is not configured and defaults to allowed" + ), + ) + ) + + factor_set = policies.two_sv_allowed_factor_set + if factor_set not in TELEPHONY_FREE_FACTOR_SETS: + issues.append( + ( + "security.two_step_verification_enforcement_factor", + ( + "the allowed methods are not configured and default to " + "any method, including verification codes via text and " + "phone call" + if factor_set is None + else f"the allowed methods are {factor_set}, which does " + f"not exclude verification codes via text and phone call" + ), + ) + ) + + reasons = "; ".join(text for _, text in issues) + + if issues: + report.status = "FAIL" + report.status_extended = ( + f"2-Step Verification is not enforced as required in domain " + f"{domain}: {reasons}." + (f" Note: {caveat}." if caveat else "") + ) + elif caveat: + report.status = "MANUAL" + report.status_extended = ( + f"2-Step Verification meets the benchmark in the domain-wide " + f"policy of {domain}, but {caveat}. Review those overrides " + f"in the Admin console." ) else: - report.status = "FAIL" - if enforced_from is None: - report.status_extended = ( - f"2-Step Verification enforcement is not configured " - f"in domain {security_client.provider.identity.domain}. " - f"The default is OFF. 2-Step Verification should be " - f"enforced for all users." - ) - else: - report.status_extended = ( - f"2-Step Verification enforcement is set to OFF " - f"in domain {security_client.provider.identity.domain}. " - f"2-Step Verification should be enforced for all users." - ) + report.status = "PASS" + report.status_extended = ( + f"2-Step Verification is enforced in domain {domain} " + f"(enforced from {enforced_from}), device trust is disabled " + f"and verification codes via text or phone call are not an " + f"accepted method." + ) findings.append(report) diff --git a/prowler/providers/googleworkspace/services/security/security_2sv_hardware_keys_admins/security_2sv_hardware_keys_admins.metadata.json b/prowler/providers/googleworkspace/services/security/security_2sv_hardware_keys_admins/security_2sv_hardware_keys_admins.metadata.json index 4aae5840a5..cae39072dc 100644 --- a/prowler/providers/googleworkspace/services/security/security_2sv_hardware_keys_admins/security_2sv_hardware_keys_admins.metadata.json +++ b/prowler/providers/googleworkspace/services/security/security_2sv_hardware_keys_admins/security_2sv_hardware_keys_admins.metadata.json @@ -9,7 +9,7 @@ "Severity": "high", "ResourceType": "NotDefined", "ResourceGroup": "IAM", - "Description": "The domain-level 2-Step Verification policy requires **hardware security keys only** as the allowed sign-in factor, providing the strongest phishing-resistant authentication. **Note**: the Policy API returns domain-wide policies only and cannot verify admin role-specific enforcement.", + "Description": "The domain-level 2-Step Verification policy requires **hardware security keys only** as the allowed sign-in factor, with enforcement on or scheduled and a policy suspension grace period of at most one day. **Note**: the Policy API returns domain-wide policies only and cannot verify admin role-specific enforcement.", "Risk": "When 2SV methods include **SMS, phone calls, or software-based authenticators**, users are vulnerable to **SIM swapping, SS7 attacks, and real-time phishing proxies** that can intercept one-time codes. Hardware security keys are resistant to all known remote phishing techniques.", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/googleworkspace/services/security/security_2sv_hardware_keys_admins/security_2sv_hardware_keys_admins.py b/prowler/providers/googleworkspace/services/security/security_2sv_hardware_keys_admins/security_2sv_hardware_keys_admins.py index 5913f448e1..c95c67a72f 100644 --- a/prowler/providers/googleworkspace/services/security/security_2sv_hardware_keys_admins/security_2sv_hardware_keys_admins.py +++ b/prowler/providers/googleworkspace/services/security/security_2sv_hardware_keys_admins/security_2sv_hardware_keys_admins.py @@ -1,20 +1,50 @@ from typing import List from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.security.lib.durations import ( + ONE_DAY_SECONDS, + enforcement_issue, + format_duration, + parse_duration_seconds, +) +from prowler.providers.googleworkspace.services.security.lib.scope import ( + failures_shadowed_by_overrides, + override_caveat, + unevaluable_reason, +) from prowler.providers.googleworkspace.services.security.security_client import ( security_client, ) +# "Methods: Only security key". The values that also accept security codes are +# PASSKEY_PLUS_SECURITY_CODE and PASSKEY_PLUS_IP_BOUND_SECURITY_CODE, so +# requiring this one covers the benchmark's "don't allow users to generate +# security codes" step as well. +SECURITY_KEYS_ONLY = "PASSKEY_ONLY" + +# The settings this check reads, to tell whether an override reaches it. +EVALUATED_SETTINGS = frozenset( + { + "security.two_step_verification_enrollment", + "security.two_step_verification_enforcement", + "security.two_step_verification_enforcement_factor", + "security.two_step_verification_sign_in_code", + } +) + class security_2sv_hardware_keys_admins(Check): """Check that 2SV enforcement requires hardware security keys. - This check verifies that the domain-level 2-Step Verification enforcement - factor is set to security keys only, providing the strongest protection - against phishing attacks. Note: the Cloud Identity Policy API returns - domain-wide policies — it cannot verify enforcement for admin roles - specifically. This check evaluates the customer-level policy which - applies to all users including administrators. + CIS 4.1.1.2 asks for security keys to be the only accepted method, with + enrollment allowed, enforcement on or scheduled, and a policy suspension + grace period of at most one day, so the requirement cannot pass on the + accepted method alone. + + Note: the Cloud Identity Policy API returns domain-wide policies, it cannot + verify enforcement for admin roles specifically. This check evaluates the + customer-level policy, which applies to all users including administrators, + so a passing domain-wide policy also covers the administrative accounts. """ def execute(self) -> List[CheckReportGoogleWorkspace]: @@ -29,35 +59,119 @@ class security_2sv_hardware_keys_admins(Check): customer_id=security_client.provider.identity.customer_id, ) - factor_set = security_client.policies.two_sv_allowed_factor_set + policies = security_client.policies + domain = security_client.provider.identity.domain - if factor_set == "PASSKEY_ONLY": - report.status = "PASS" + unevaluable = unevaluable_reason(policies, EVALUATED_SETTINGS) + if unevaluable: + report.status = "MANUAL" report.status_extended = ( - f"2-Step Verification enforcement requires security keys only " - f"in domain {security_client.provider.identity.domain}." + f"2-Step Verification could not be evaluated in domain " + f"{domain}: {unevaluable}. Review it in the Admin console." + ) + findings.append(report) + return findings + + caveat = override_caveat(policies, EVALUATED_SETTINGS) + issues = [] # (setting, why it fails) + + factor_set = policies.two_sv_allowed_factor_set + if factor_set != SECURITY_KEYS_ONLY: + issues.append( + ( + "security.two_step_verification_enforcement_factor", + ( + "the accepted method is not configured and defaults to " + "any method, including SMS and phone call" + if factor_set is None + else f"the accepted method is {factor_set} " + f"(should be {SECURITY_KEYS_ONLY})" + ), + ) + ) + + # 4.1.1.2 accepts "On from ", unlike 4.1.1.1 and 4.1.1.3. + enforcement = enforcement_issue( + policies.two_sv_enforced_from, allow_scheduled=True + ) + if enforcement: + issues.append( + ("security.two_step_verification_enforcement", enforcement) + ) + + if policies.two_sv_allow_enrollment is False: + issues.append( + ( + "security.two_step_verification_enrollment", + "users are not allowed to turn on 2-Step Verification", + ) + ) + + # Google's default is no suspension grace period, which is stricter + # than the one day the benchmark asks for, so only longer periods + # fail. A value Prowler cannot read fails closed. + raw_grace_period = policies.two_sv_backup_code_exception_period + grace_period = parse_duration_seconds(raw_grace_period) + if raw_grace_period and grace_period is None: + issues.append( + ( + "security.two_step_verification_sign_in_code", + f"the 2-Step Verification policy suspension grace period " + f"'{raw_grace_period}' could not be read", + ) + ) + elif grace_period is not None and grace_period > ONE_DAY_SECONDS: + issues.append( + ( + "security.two_step_verification_sign_in_code", + f"the 2-Step Verification policy suspension grace period " + f"is {format_duration(raw_grace_period)} " + f"(should not exceed 1 day)", + ) + ) + + failing_settings = frozenset(setting for setting, _ in issues) + reasons = "; ".join(text for _, text in issues) + + if issues and failures_shadowed_by_overrides(policies, failing_settings): + # The admin group of 4.1.1.2 may get the overriding value, + # which the Policy API does not expose, so the domain-wide + # failure cannot be confirmed for it. + report.status = "MANUAL" + report.status_extended = ( + f"2-Step Verification does not require security keys in the " + f"domain-wide policy of {domain}: {reasons}. However, every " + f"failing setting is also overridden for at least one group " + f"or organizational unit, so the administrative accounts may " + f"be configured correctly. Review those overrides in the " + f"Admin console." + ) + elif issues: + report.status = "FAIL" + report.status_extended = ( + f"2-Step Verification does not require security keys as " + f"configured in domain {domain}: {reasons}. " + + (f"Note: {caveat}. " if caveat else "") + + "Note: this check evaluates the domain-wide policy, the " + "Policy API does not expose role-specific 2SV enforcement." + ) + elif caveat: + report.status = "MANUAL" + report.status_extended = ( + f"2-Step Verification meets the benchmark in the domain-wide " + f"policy of {domain}, but {caveat}. Review those overrides " + f"in the Admin console." ) else: - report.status = "FAIL" - if factor_set is None: - report.status_extended = ( - f"2-Step Verification enforcement factor is not configured " - f"in domain {security_client.provider.identity.domain}. " - f"The default allows all methods including SMS and phone call. " - f"Security keys should be required for administrative accounts. " - f"Note: this check evaluates the domain-wide policy, the Policy " - f"API does not expose role-specific 2SV enforcement." - ) - else: - report.status_extended = ( - f"2-Step Verification enforcement factor is set to " - f"{factor_set} " - f"in domain {security_client.provider.identity.domain}. " - f"Only security keys (PASSKEY_ONLY) should be allowed for " - f"administrative accounts. " - f"Note: this check evaluates the domain-wide policy, the Policy " - f"API does not expose role-specific 2SV enforcement." - ) + report.status = "PASS" + report.status_extended = ( + f"2-Step Verification requires security keys only in domain " + f"{domain}, enforcement is on or scheduled and the policy " + f"suspension grace period is " + f"{format_duration(policies.two_sv_backup_code_exception_period)}. " + f"Note: this check evaluates the domain-wide policy, the " + f"Policy API does not expose role-specific 2SV enforcement." + ) findings.append(report) diff --git a/prowler/providers/googleworkspace/services/security/security_login_challenges_configured/security_login_challenges_configured.metadata.json b/prowler/providers/googleworkspace/services/security/security_login_challenges_configured/security_login_challenges_configured.metadata.json index ec945474fc..c02ab1a669 100644 --- a/prowler/providers/googleworkspace/services/security/security_login_challenges_configured/security_login_challenges_configured.metadata.json +++ b/prowler/providers/googleworkspace/services/security/security_login_challenges_configured/security_login_challenges_configured.metadata.json @@ -9,7 +9,7 @@ "Severity": "medium", "ResourceType": "NotDefined", "ResourceGroup": "IAM", - "Description": "The domain-level login challenges configuration has the **employee ID challenge disabled**. CIS 4.1.4.1 also requires Post-SSO verification to be enabled, but that setting is **not exposed by the Cloud Identity Policy API**. This check only covers the employee ID challenge portion of the control.", + "Description": "The domain-level login challenges configuration has the **employee ID challenge disabled**. This check is **not mapped to CIS 4.1.4.1** because that recommendation also requires Post-SSO verification, a setting **not exposed by the Cloud Identity Policy API**, so the requirement cannot be evaluated end to end.", "Risk": "When the employee ID login challenge is enabled without proper configuration, it may create a **false sense of security** or interfere with the login flow. The employee ID challenge is a supplementary verification method that should only be used when specifically required by the organization.", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/googleworkspace/services/security/security_login_challenges_configured/security_login_challenges_configured.py b/prowler/providers/googleworkspace/services/security/security_login_challenges_configured/security_login_challenges_configured.py index e3732053b8..68de612b65 100644 --- a/prowler/providers/googleworkspace/services/security/security_login_challenges_configured/security_login_challenges_configured.py +++ b/prowler/providers/googleworkspace/services/security/security_login_challenges_configured/security_login_challenges_configured.py @@ -9,11 +9,11 @@ from prowler.providers.googleworkspace.services.security.security_client import class security_login_challenges_configured(Check): """Check that login challenges are configured correctly. - This check verifies that the employee ID login challenge is disabled, - as recommended by CIS. Note: CIS 4.1.4.1 also requires Post-SSO - verification to be enabled, but that setting is not exposed by the - Cloud Identity Policy API. This check only covers the employee ID - challenge portion of the control. + This check verifies that the employee ID login challenge is disabled. + + It is not mapped to CIS 4.1.4.1: that recommendation also requires Post-SSO + verification, a setting the Cloud Identity Policy API does not expose, so + the requirement cannot be evaluated end to end and is reported as manual. """ def execute(self) -> List[CheckReportGoogleWorkspace]: diff --git a/prowler/providers/googleworkspace/services/security/security_password_policy_strong/security_password_policy_strong.py b/prowler/providers/googleworkspace/services/security/security_password_policy_strong/security_password_policy_strong.py index 33448aa536..82118537e0 100644 --- a/prowler/providers/googleworkspace/services/security/security_password_policy_strong/security_password_policy_strong.py +++ b/prowler/providers/googleworkspace/services/security/security_password_policy_strong/security_password_policy_strong.py @@ -1,6 +1,11 @@ from typing import List from prowler.lib.check.models import Check, CheckReportGoogleWorkspace +from prowler.providers.googleworkspace.services.security.lib.durations import ( + ONE_YEAR_SECONDS, + format_duration, + parse_duration_seconds, +) from prowler.providers.googleworkspace.services.security.security_client import ( security_client, ) @@ -11,8 +16,9 @@ class security_password_policy_strong(Check): This check verifies that the domain-level password policy meets CIS requirements: minimum length of 14 characters, strong passwords enforced, - password reuse disallowed, enforcement at next sign-in, and password - expiration configured. + password reuse disallowed, enforcement at next sign-in, and a password + reset frequency of 365 days or less. Shorter periods are more restrictive + than the benchmark asks for, so only longer ones fail. """ def execute(self) -> List[CheckReportGoogleWorkspace]: @@ -39,12 +45,11 @@ class security_password_policy_strong(Check): else f"minimum length is {min_length} (requires 14+)" ) - if policies.password_allowed_strength != "STRONG": - issues.append( - "password strength is not configured (requires STRONG)" - if policies.password_allowed_strength is None - else f"password strength is {policies.password_allowed_strength} (requires STRONG)" - ) + # Google enforces strong passwords by default, so an unset value is + # the secure default rather than a missing configuration. + strength = policies.password_allowed_strength + if strength is not None and strength != "STRONG": + issues.append(f"password strength is {strength} (requires STRONG)") if policies.password_allow_reuse is True: issues.append("password reuse is allowed") @@ -52,9 +57,22 @@ class security_password_policy_strong(Check): if policies.password_enforce_at_login is not True: issues.append("password policy is not enforced at next sign-in") - expiration = policies.password_expiration_duration - if expiration is None or expiration == "0s": + raw_expiration = policies.password_expiration_duration + expiration = parse_duration_seconds(raw_expiration) + if raw_expiration and expiration is None: + issues.append( + f"password expiration '{raw_expiration}' could not be read" + ) + elif expiration is None: issues.append("password expiration is not configured") + elif expiration == 0: + issues.append("passwords are set to never expire") + elif expiration > ONE_YEAR_SECONDS: + issues.append( + f"password expiration is " + f"{format_duration(policies.password_expiration_duration)} " + f"(requires 365 days or less)" + ) if not issues: report.status = "PASS" @@ -62,7 +80,8 @@ class security_password_policy_strong(Check): f"Password policy meets CIS requirements " f"in domain {domain}: minimum length {min_length}, " f"strong passwords enforced, reuse disallowed, " - f"enforced at next sign-in, expiration configured." + f"enforced at next sign-in, expiration " + f"{format_duration(policies.password_expiration_duration)}." ) else: report.status = "FAIL" diff --git a/prowler/providers/googleworkspace/services/security/security_service.py b/prowler/providers/googleworkspace/services/security/security_service.py index 96f24061f8..dd0cbff557 100644 --- a/prowler/providers/googleworkspace/services/security/security_service.py +++ b/prowler/providers/googleworkspace/services/security/security_service.py @@ -1,9 +1,13 @@ -from typing import Optional +from typing import List, Optional -from pydantic import BaseModel +from pydantic import BaseModel, Field from prowler.lib.logger import logger -from prowler.providers.googleworkspace.lib.service.service import GoogleWorkspaceService +from prowler.providers.googleworkspace.lib.service.service import ( + CUSTOMER_SCOPE, + UNKNOWN_SCOPE, + GoogleWorkspaceService, +) class Security(GoogleWorkspaceService): @@ -18,6 +22,8 @@ class Security(GoogleWorkspaceService): super().__init__(provider) self.policies = SecurityPolicies() self.policies_fetched = False + self._overridden = set() + self._observed = set() self._fetch_security_policies() def _fetch_security_policies(self): @@ -48,6 +54,10 @@ class Security(GoogleWorkspaceService): service, 'setting.type.matches("rule.dlp")', fetch_succeeded ) + self.policies.overridden_settings = sorted(self._overridden) + self.policies.unobserved_settings = sorted( + self._overridden - self._observed + ) self.policies_fetched = fetch_succeeded if fetch_succeeded: @@ -79,11 +89,18 @@ class Security(GoogleWorkspaceService): response = request.execute() for policy in response.get("policies", []): - if not self._is_customer_level_policy(policy): - continue - setting = policy.get("setting", {}) setting_type = setting.get("type", "").removeprefix("settings/") + + scope = self._policy_scope(policy) + if scope != CUSTOMER_SCOPE: + if scope == UNKNOWN_SCOPE: + self.policies.unresolved_scope = True + elif setting_type: + self._overridden.add(setting_type) + continue + + self._observed.add(setting_type) value = setting.get("value", {}) self._process_setting(setting_type, value) @@ -239,6 +256,17 @@ class Security(GoogleWorkspaceService): class SecurityPolicies(BaseModel): """Model for domain-level Security policy settings.""" + # Setting types that a group or a sub-OU overrides. Sorted lists rather than + # sets: a set reaches the OCSF output as its Python repr, in a different + # order on every scan. + overridden_settings: List[str] = Field(default_factory=list) + # Overridden settings with no domain-wide policy of their own, so the values + # below are Prowler's defaults and not something the domain reported. + unobserved_settings: List[str] = Field(default_factory=list) + # True when a policy's scope could not be determined because the root + # organizational unit id is unknown, which blanks the values below. + unresolved_scope: bool = False + # security.two_step_verification_enrollment two_sv_allow_enrollment: Optional[bool] = None # security.two_step_verification_enforcement diff --git a/prowler/providers/huaweicloud/models.py b/prowler/providers/huaweicloud/models.py index 38cb1acaab..627714a8c9 100644 --- a/prowler/providers/huaweicloud/models.py +++ b/prowler/providers/huaweicloud/models.py @@ -384,6 +384,19 @@ class HuaweiCloudSession: .build() ) + elif service == "smn": + from huaweicloudsdksmn.v2 import SmnClient + from huaweicloudsdksmn.v2.region.smn_region import SmnRegion + + client_region = region or self._region + return ( + SmnClient.new_builder() + .with_credentials(self._get_basic_credentials(client_region)) + .with_http_config(self._http_config()) + .with_region(_aligned_region(SmnRegion, client_region)) + .build() + ) + else: raise HuaweiCloudServiceError( message=f"Huawei Cloud service '{service}' is not supported" diff --git a/prowler/providers/huaweicloud/services/smn/__init__.py b/prowler/providers/huaweicloud/services/smn/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/huaweicloud/services/smn/smn_client.py b/prowler/providers/huaweicloud/services/smn/smn_client.py new file mode 100644 index 0000000000..a5345dfc1a --- /dev/null +++ b/prowler/providers/huaweicloud/services/smn/smn_client.py @@ -0,0 +1,4 @@ +from prowler.providers.huaweicloud.services.smn.smn_service import SMN +from prowler.providers.common.provider import Provider + +smn_client = SMN(Provider.get_global_provider()) diff --git a/prowler/providers/huaweicloud/services/smn/smn_service.py b/prowler/providers/huaweicloud/services/smn/smn_service.py new file mode 100644 index 0000000000..c614e5149b --- /dev/null +++ b/prowler/providers/huaweicloud/services/smn/smn_service.py @@ -0,0 +1,116 @@ +from typing import List + +from pydantic.v1 import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.huaweicloud.lib.service.service import HuaweiCloudService + +SMN_PAGE_SIZE = 100 + + +class SMN(HuaweiCloudService): + """ + SMN (Simple Message Notification) service class for Huawei Cloud. + + This class provides methods to interact with Huawei Cloud SMN service + to retrieve notification topics and their subscription counts. + """ + + def __init__(self, provider): + super().__init__(__class__.__name__, provider) + + self.topics: List[SMNTopic] = [] + self._list_topics() + + def _list_topics(self): + """List all SMN topics across regions and get their subscription counts.""" + if not self.regional_clients: + return + + for region, client in self.regional_clients.items(): + logger.info(f"SMN - Listing Topics in {region}...") + + try: + from huaweicloudsdksmn.v2 import ( + ListSubscriptionsByTopicRequest, + ListTopicsRequest, + ) + + offset = 0 + while True: + request = ListTopicsRequest(offset=offset, limit=SMN_PAGE_SIZE) + response = self._call_with_retries(client.list_topics, request) + topics = getattr(response, "topics", None) or [] + + for topic in topics: + topic_urn = getattr(topic, "topic_urn", "") or "" + topic_id = getattr(topic, "topic_id", "") or "" + name = getattr(topic, "name", "") or "" + display_name = getattr(topic, "display_name", "") or "" + push_policy = getattr(topic, "push_policy", None) + + try: + confirmed_subscription_count = 0 + subscription_offset = 0 + while True: + sub_request = ListSubscriptionsByTopicRequest( + topic_urn=topic_urn, + offset=subscription_offset, + limit=SMN_PAGE_SIZE, + ) + sub_response = self._call_with_retries( + client.list_subscriptions_by_topic, sub_request + ) + subscriptions = ( + getattr(sub_response, "subscriptions", None) or [] + ) + confirmed_subscription_count += sum( + getattr(subscription, "status", None) == 1 + for subscription in subscriptions + ) + subscription_count = ( + getattr(sub_response, "subscription_count", 0) or 0 + ) + subscription_offset += SMN_PAGE_SIZE + if subscription_offset >= subscription_count: + break + except Exception as sub_error: + logger.error( + f"{region} -- {sub_error.__class__.__name__}" + f"[{sub_error.__traceback__.tb_lineno}]: {sub_error}" + ) + continue + + self.topics.append( + SMNTopic( + topic_urn=topic_urn, + topic_id=topic_id, + name=name, + display_name=display_name, + push_policy=push_policy, + confirmed_subscription_count=confirmed_subscription_count, + region=region, + ) + ) + + offset += SMN_PAGE_SIZE + topic_count = getattr(response, "topic_count", 0) or 0 + if offset >= topic_count: + break + + except Exception as error: + logger.error( + f"{region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class SMNTopic(BaseModel): + """SMN topic model.""" + + topic_urn: str + topic_id: str = "" + name: str = "" + display_name: str = "" + push_policy: int = None + confirmed_subscription_count: int = 0 + region: str = "" diff --git a/prowler/providers/huaweicloud/services/smn/smn_topic_subscriptions/__init__.py b/prowler/providers/huaweicloud/services/smn/smn_topic_subscriptions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/huaweicloud/services/smn/smn_topic_subscriptions/smn_topic_subscriptions.metadata.json b/prowler/providers/huaweicloud/services/smn/smn_topic_subscriptions/smn_topic_subscriptions.metadata.json new file mode 100644 index 0000000000..35d03ac953 --- /dev/null +++ b/prowler/providers/huaweicloud/services/smn/smn_topic_subscriptions/smn_topic_subscriptions.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "huaweicloud", + "CheckID": "smn_topic_subscriptions", + "CheckTitle": "SMN topics have at least one confirmed subscription", + "CheckType": [], + "ServiceName": "smn", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "HUAWEICLOUD::SMN::Topic", + "ResourceGroup": "messaging", + "Description": "Ensure that SMN notification topics have at least one confirmed subscription so alerts can be delivered to recipients.", + "Risk": "Topics without confirmed subscriptions cannot deliver notifications, meaning critical alerts may go unnoticed by operations and security teams.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://support.huaweicloud.com/intl/en-us/api-smn/ListSubscriptionsByTopic.html", + "https://support.huaweicloud.com/intl/en-us/api-smn/AddSubscription.html" + ], + "Remediation": { + "Code": { + "CLI": "hcloud SMN AddSubscription --topic_urn= --protocol= --endpoint=", + "NativeIaC": "", + "Other": "1. Log on to the Huawei Cloud console.\n2. Navigate to Simple Message Notification (SMN).\n3. Select the topic without subscriptions.\n4. Click Add Subscription.\n5. Configure the subscription protocol and endpoint.\n6. Confirm the subscription.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Add and confirm at least one subscription for each SMN topic so notifications can be delivered.", + "Url": "https://hub.prowler.com/check/smn_topic_subscriptions" + } + }, + "Categories": [ + "logging" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Only confirmed subscriptions (status 1) satisfy this check. Unconfirmed or canceled subscriptions cannot provide notification coverage." +} diff --git a/prowler/providers/huaweicloud/services/smn/smn_topic_subscriptions/smn_topic_subscriptions.py b/prowler/providers/huaweicloud/services/smn/smn_topic_subscriptions/smn_topic_subscriptions.py new file mode 100644 index 0000000000..867286bcc5 --- /dev/null +++ b/prowler/providers/huaweicloud/services/smn/smn_topic_subscriptions/smn_topic_subscriptions.py @@ -0,0 +1,36 @@ +from prowler.lib.check.models import Check, CheckReportHuaweiCloud +from prowler.providers.huaweicloud.services.smn.smn_client import smn_client + + +class smn_topic_subscriptions(Check): + """Check if SMN topics have at least one subscription configured.""" + + def execute(self) -> list[CheckReportHuaweiCloud]: + findings = [] + + for topic in smn_client.topics: + report = CheckReportHuaweiCloud( + metadata=self.metadata(), + resource=topic, + ) + report.region = topic.region + report.resource_id = topic.topic_id + report.resource_name = topic.name + report.resource_arn = topic.topic_urn + + if topic.confirmed_subscription_count > 0: + report.status = "PASS" + report.status_extended = ( + f"SMN topic '{topic.name}' ({topic.topic_id}) has " + f"{topic.confirmed_subscription_count} confirmed subscription(s)." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"SMN topic '{topic.name}' ({topic.topic_id}) has no confirmed " + "subscriptions. Notifications will not be delivered." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/image/exceptions/exceptions.py b/prowler/providers/image/exceptions/exceptions.py index 387b443ce3..72e362a72a 100644 --- a/prowler/providers/image/exceptions/exceptions.py +++ b/prowler/providers/image/exceptions/exceptions.py @@ -70,6 +70,10 @@ class ImageBaseException(ProwlerException): "message": "Invalid regex filter pattern.", "remediation": "Check the regex syntax for --image-filter or --tag-filter.", }, + (11019, "ImageInvalidAllowedNetworksError"): { + "message": "Malformed private-network allowlist.", + "remediation": "PROWLER_IMAGE_PROVIDER_ALLOWED_PRIVATE_NETWORKS must be a comma-separated list of IPs or CIDRs (e.g. 192.168.65.254/32,10.20.0.0/16).", + }, } def __init__(self, code, file=None, original_exception=None, message=None): @@ -227,3 +231,12 @@ class ImageInvalidFilterError(ImageBaseException): super().__init__( 11017, file=file, original_exception=original_exception, message=message ) + + +class ImageInvalidAllowedNetworksError(ImageBaseException): + """Exception raised when the private-network allowlist is malformed.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 11019, file=file, original_exception=original_exception, message=message + ) diff --git a/prowler/providers/image/image_provider.py b/prowler/providers/image/image_provider.py index 7a245e4125..2709003bf1 100644 --- a/prowler/providers/image/image_provider.py +++ b/prowler/providers/image/image_provider.py @@ -6,6 +6,7 @@ import re import subprocess import sys import tempfile +from concurrent.futures import ThreadPoolExecutor from typing import Generator from alive_progress import alive_bar @@ -49,6 +50,13 @@ from prowler.providers.image.lib.arguments.arguments import ( from prowler.providers.image.lib.registry.dockerhub_adapter import DockerHubAdapter from prowler.providers.image.lib.registry.factory import create_registry_adapter +# Cosign companion tags (signatures, attestations, SBOMs) — skippable by name +# without fetching the manifest. +COSIGN_TAG_PATTERN = re.compile(r"^sha256-[0-9a-f]{64}\.(sig|att|sbom)$") + +# Concurrency for registry enumeration (tag listing + manifest inspection). +_ENUMERATION_WORKERS = 10 + class ImageProvider(Provider): """ @@ -107,10 +115,15 @@ class ImageProvider(Provider): self._session = None self._identity = "prowler" self._listing_only = False - self._trivy_cache_dir_obj = tempfile.TemporaryDirectory( - prefix="prowler-trivy-cache-" - ) - self._trivy_cache_dir = self._trivy_cache_dir_obj.name + # A supplied cache dir is never deleted: it may hold a DB we cannot refetch + configured_cache_dir = os.environ.get("TRIVY_CACHE_DIR", "").strip() + if configured_cache_dir: + self._trivy_cache_dir = configured_cache_dir + else: + self._trivy_cache_dir_obj = tempfile.TemporaryDirectory( + prefix="prowler-trivy-cache-" + ) + self._trivy_cache_dir = self._trivy_cache_dir_obj.name # Registry authentication (follows IaC pattern: explicit params, env vars internal) self.registry_username = registry_username or os.environ.get( @@ -164,6 +177,10 @@ class ImageProvider(Provider): if image_list_file: self._load_images_from_file(image_list_file) + # Images discovered via registry enumeration get per-image error + # degradation; explicitly requested images keep failing hard. + self._registry_discovered: set[str] = set() + # Registry scan mode: enumerate images from registry if self.registry: self._enumerate_registry() @@ -550,8 +567,15 @@ class ImageProvider(Provider): for batch in self._scan_single_image(image): image_findings.extend(batch) yield (image, image_findings) - except (ImageScanError, ImageTrivyBinaryNotFoundError): + except ImageTrivyBinaryNotFoundError: raise + except ImageScanError as error: + if image not in self._registry_discovered: + raise + logger.warning( + f"Skipping registry-discovered image {image}: {error}" + ) + yield (image, []) except Exception as error: logger.error(f"Error scanning image {image}: {error}") yield (image, []) @@ -568,8 +592,13 @@ class ImageProvider(Provider): for image in self.images: try: yield from self._scan_single_image(image) - except (ImageScanError, ImageTrivyBinaryNotFoundError): + except ImageTrivyBinaryNotFoundError: raise + except ImageScanError as error: + if image not in self._registry_discovered: + raise + logger.warning(f"Skipping registry-discovered image {image}: {error}") + continue except Exception as error: logger.error(f"Error scanning image {image}: {error}") continue @@ -718,6 +747,8 @@ class ImageProvider(Provider): env["TRIVY_PASSWORD"] = self.registry_password elif self.registry_token: env["TRIVY_REGISTRY_TOKEN"] = self.registry_token + if self.registry_insecure: + env["TRIVY_INSECURE"] = "true" return env def _execute_trivy(self, command: list, image: str) -> subprocess.CompletedProcess: @@ -809,6 +840,16 @@ class ImageProvider(Provider): return f"Rate limited — wait or authenticate: {error_msg}" if any(kw in lower for kw in ("timeout", "connection refused", "no such host")): return f"Network issue — check connectivity: {error_msg}" + if any( + kw in lower + for kw in ( + "unsupported mediatype", + "unsupported media type", + "unsupported artifact", + "invalid image", + ) + ): + return f"Not a container image — trivy image cannot scan this OCI artifact: {error_msg}" return error_msg @@ -844,29 +885,49 @@ class ImageProvider(Provider): # Determine if this is a Docker Hub adapter (for image reference format) is_dockerhub = isinstance(adapter, DockerHubAdapter) + skipped_artifacts = 0 + with ThreadPoolExecutor( + max_workers=min(_ENUMERATION_WORKERS, len(repositories)) + ) as pool: + all_tags = list(pool.map(adapter.list_tags, repositories)) + + candidates: list[tuple[str, str]] = [] + for repo, tags in zip(repositories, all_tags): + if self._tag_filter_re: + tags = [t for t in tags if self._tag_filter_re.search(t)] + for tag in tags: + # Cosign companions are skippable by name — no manifest fetch + if COSIGN_TAG_PATTERN.match(tag): + skipped_artifacts += 1 + continue + candidates.append((repo, tag)) + + # Drop non-image OCI artifacts (Helm charts, signatures, SBOMs...) + # that trivy image cannot scan; one manifest fetch per tag, in parallel. + verdicts = list( + pool.map(lambda rt: adapter.is_container_image(*rt), candidates) + ) + discovered_images = [] repos_tags: dict[str, list[str]] = {} - for repo in repositories: - tags = adapter.list_tags(repo) + for (repo, tag), is_image in zip(candidates, verdicts): + if not is_image: + skipped_artifacts += 1 + continue + repos_tags.setdefault(repo, []).append(tag) + if is_dockerhub: + # Docker Hub images don't need a host prefix + image_ref = f"{repo}:{tag}" + else: + # OCI registries need the full host/repo:tag reference + registry_host = ImageProvider._strip_scheme(self.registry.rstrip("/")) + image_ref = f"{registry_host}/{repo}:{tag}" + discovered_images.append(image_ref) - # Apply tag filter - if self._tag_filter_re: - tags = [t for t in tags if self._tag_filter_re.search(t)] - - if tags: - repos_tags[repo] = tags - - for tag in tags: - if is_dockerhub: - # Docker Hub images don't need a host prefix - image_ref = f"{repo}:{tag}" - else: - # OCI registries need the full host/repo:tag reference - registry_host = ImageProvider._strip_scheme( - self.registry.rstrip("/") - ) - image_ref = f"{registry_host}/{repo}:{tag}" - discovered_images.append(image_ref) + if skipped_artifacts: + logger.info( + f"Skipped {skipped_artifacts} non-image OCI artifacts (Helm charts, signatures, SBOMs...) from registry {self.registry}" + ) # Registry list mode: print listing and return early if self.registry_list_images: @@ -887,6 +948,9 @@ class ImageProvider(Provider): if img not in existing: self.images.append(img) existing.add(img) + # Only enumeration-added images get error degradation; an image + # the user also requested explicitly keeps failing hard. + self._registry_discovered.add(img) logger.info( f"Discovered {len(discovered_images)} images from registry {self.registry} " diff --git a/prowler/providers/image/lib/registry/base.py b/prowler/providers/image/lib/registry/base.py index 298583620f..7341128cbb 100644 --- a/prowler/providers/image/lib/registry/base.py +++ b/prowler/providers/image/lib/registry/base.py @@ -3,6 +3,7 @@ from __future__ import annotations import ipaddress +import os import re import socket import time @@ -15,6 +16,7 @@ import tldextract from prowler.config.config import prowler_version from prowler.lib.logger import logger from prowler.providers.image.exceptions.exceptions import ( + ImageInvalidAllowedNetworksError, ImageRegistryAuthError, ImageRegistryNetworkError, ) @@ -48,6 +50,30 @@ def _registrable_domain(host: str) -> str | None: return f"{ext.domain}.{ext.suffix}" +ALLOWED_PRIVATE_NETWORKS_ENV = "PROWLER_IMAGE_PROVIDER_ALLOWED_PRIVATE_NETWORKS" + + +def _parse_allowed_private_networks( + raw: str | None, +) -> tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...]: + """Parse the comma-separated IP/CIDR allowlist; malformed entries fail loudly.""" + if raw is None 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 exc: + raise ImageInvalidAllowedNetworksError( + file=__file__, + message=f"Malformed entry {entry!r} in {ALLOWED_PRIVATE_NETWORKS_ENV}: {exc}", + ) + return tuple(networks) + + class RegistryAdapter(ABC): """Abstract base class for registry adapters.""" @@ -64,6 +90,14 @@ class RegistryAdapter(ABC): self._password = password self._token = token self.verify_ssl = verify_ssl + self._allowed_private_networks = _parse_allowed_private_networks( + os.environ.get(ALLOWED_PRIVATE_NETWORKS_ENV) + ) + if self._allowed_private_networks: + logger.warning( + f"{ALLOWED_PRIVATE_NETWORKS_ENV} is set — SSRF protection relaxed for private networks: " + + ", ".join(str(net) for net in self._allowed_private_networks) + ) @property def password(self) -> str | None: @@ -98,6 +132,15 @@ class RegistryAdapter(ABC): """Enumerate all tags for a repository.""" ... + def is_container_image(self, repository: str, tag: str) -> bool: + """Whether repository:tag points to a scannable container image. + + Registries that store arbitrary OCI artifacts (Helm charts, cosign + signatures, SBOMs...) override this; by default everything is assumed + to be an image. + """ + return True + def _origin_url(self) -> str: """The URL whose host the validator compares against when enforce_origin=True. @@ -106,6 +149,30 @@ class RegistryAdapter(ABC): """ return self.registry_url + def _ip_is_allowed(self, ip_str: str) -> bool: + """Whether ip_str falls inside an operator-allowlisted private network.""" + try: + addr = ipaddress.ip_address(ip_str) + except ValueError: + return False + return any( + addr.version == network.version and addr in network + for network in self._allowed_private_networks + ) + + def _host_in_allowed_networks(self, host: str) -> bool: + """Whether host is a literal allowlisted IP or resolves only to allowlisted IPs.""" + try: + ipaddress.ip_address(host) + except ValueError: + try: + infos = socket.getaddrinfo(host, None) + except socket.gaierror: + return False + ips = {sockaddr[0] for *_, sockaddr in infos} + return bool(ips) and all(self._ip_is_allowed(ip) for ip in ips) + return self._ip_is_allowed(host) + def _validate_outbound_url( self, url: str, @@ -119,10 +186,12 @@ class RegistryAdapter(ABC): - canonicalise via ``requests.PreparedRequest`` so validator and connector parse the same string the same way; - reject schemes other than http/https; - - reject literal non-public IPs (private, loopback, link-local, ...); - - reject hostnames whose A/AAAA records resolve to non-public IPs; + - reject literal non-public IPs (private, loopback, link-local, ...) + unless inside PROWLER_IMAGE_PROVIDER_ALLOWED_PRIVATE_NETWORKS; + - reject hostnames whose A/AAAA records resolve to non-public IPs, + with the same allowlist exception; - when ``enforce_origin=True``, reject hosts that don't share the - registry's registrable domain. + registry's registrable domain or resolve into the allowlist. Returns the canonical URL the caller should pass to ``requests``. """ @@ -165,7 +234,9 @@ class RegistryAdapter(ABC): infos = [] for *_, sockaddr in infos: resolved_ip = sockaddr[0] - if _ip_is_non_public(resolved_ip): + if _ip_is_non_public(resolved_ip) and not self._ip_is_allowed( + resolved_ip + ): raise ImageRegistryAuthError( file=__file__, message=( @@ -174,7 +245,9 @@ class RegistryAdapter(ABC): ), ) else: - if any(getattr(addr, prop) for prop in _NON_PUBLIC_IP_PROPERTIES): + if any( + getattr(addr, prop) for prop in _NON_PUBLIC_IP_PROPERTIES + ) and not self._ip_is_allowed(host): raise ImageRegistryAuthError( file=__file__, message=( @@ -188,7 +261,10 @@ class RegistryAdapter(ABC): if registry_host and host != registry_host: target_d = _registrable_domain(host) registry_d = _registrable_domain(registry_host) - if not (target_d and registry_d and target_d == registry_d): + same_domain = bool(target_d and registry_d and target_d == registry_d) + # Non-public TLDs (.local, .internal, bare hostnames) have no + # registrable domain; fall back to the operator allowlist. + if not same_domain and not self._host_in_allowed_networks(host): raise ImageRegistryAuthError( file=__file__, message=( diff --git a/prowler/providers/image/lib/registry/oci_adapter.py b/prowler/providers/image/lib/registry/oci_adapter.py index 878525bd04..3d2aaf6e58 100644 --- a/prowler/providers/image/lib/registry/oci_adapter.py +++ b/prowler/providers/image/lib/registry/oci_adapter.py @@ -4,6 +4,7 @@ from __future__ import annotations import base64 import re +import threading from typing import TYPE_CHECKING from urllib.parse import urlparse @@ -19,6 +20,27 @@ if TYPE_CHECKING: import requests +OCI_MANIFEST_MEDIA_TYPE = "application/vnd.oci.image.manifest.v1+json" + +MANIFEST_ACCEPT_TYPES = ( + "application/vnd.docker.distribution.manifest.v2+json", + "application/vnd.docker.distribution.manifest.list.v2+json", + OCI_MANIFEST_MEDIA_TYPE, + "application/vnd.oci.image.index.v1+json", +) + +# Multi-arch indexes: their children are resolved by trivy itself. +INDEX_MEDIA_TYPES = ( + "application/vnd.docker.distribution.manifest.list.v2+json", + "application/vnd.oci.image.index.v1+json", +) + +IMAGE_CONFIG_MEDIA_TYPES = ( + "application/vnd.oci.image.config.v1+json", + "application/vnd.docker.container.image.v1+json", +) + + class OciRegistryAdapter(RegistryAdapter): """Adapter for registries implementing OCI Distribution Spec.""" @@ -34,6 +56,19 @@ class OciRegistryAdapter(RegistryAdapter): self._base_url = self._normalise_url(registry_url) self._bearer_token: str | None = None self._basic_auth_verified = False + self._anonymous_verified = False + # Enumeration inspects manifests from a thread pool; serialise token + # exchanges so N concurrent 401s don't trigger N auth round-trips. + self._auth_lock = threading.Lock() + + def __getstate__(self) -> dict: + state = super().__getstate__() + del state["_auth_lock"] + return state + + def __setstate__(self, state: dict) -> None: + self.__dict__.update(state) + self._auth_lock = threading.Lock() @staticmethod def _normalise_url(url: str) -> str: @@ -45,6 +80,22 @@ class OciRegistryAdapter(RegistryAdapter): def _origin_url(self) -> str: return self._base_url + @staticmethod + def _find_challenge(www_authenticate: str, scheme: str) -> str | None: + """Extract one scheme's challenge from a (possibly multi-challenge) header. + + RFC 7235 allows several comma-separated challenges in any order, e.g. + ``Basic realm="registry", Bearer realm="...",service="..."``. + """ + match = re.search( + rf'(?:^|,)\s*{scheme}\b((?:\s*[\w-]+="[^"]*"\s*,?)*)', + www_authenticate, + re.IGNORECASE, + ) + if not match: + return None + return f"{scheme} {match.group(1).strip().rstrip(',')}" + def list_repositories(self) -> list[str]: self._ensure_auth() repositories: list[str] = [] @@ -78,22 +129,84 @@ class OciRegistryAdapter(RegistryAdapter): params = {} return tags + def is_container_image(self, repository: str, tag: str) -> bool: + """Inspect the manifest to tell container images apart from other OCI artifacts. + + Uncertainty (network error, unparseable manifest) resolves to True so a + transient failure never silently drops a real image — trivy gives the + final verdict. + """ + url = f"{self._base_url}/v2/{repository}/manifests/{tag}" + try: + self._ensure_auth(repository=repository) + resp = self._authed_request( + "GET", url, headers={"Accept": ", ".join(MANIFEST_ACCEPT_TYPES)} + ) + except Exception as error: + logger.warning( + f"Could not fetch manifest for {repository}:{tag}, assuming image: {error}" + ) + return True + if resp.status_code != 200: + logger.warning( + f"Manifest request for {repository}:{tag} returned HTTP {resp.status_code}, assuming image" + ) + return True + + # RFC 9110: media type tokens are case-insensitive + content_type = ( + resp.headers.get("Content-Type", "").split(";")[0].strip().lower() + ) + if content_type == "application/vnd.docker.distribution.manifest.v2+json": + return True + if content_type in INDEX_MEDIA_TYPES or content_type == OCI_MANIFEST_MEDIA_TYPE: + # Helm charts, cosign signatures, SBOMs... reuse the OCI manifest + # media type, and since image-spec v1.1 an index can also represent + # a non-image artifact; only artifactType (or, for manifests, + # config.mediaType) tells them apart. + try: + manifest = resp.json() + except ValueError: + return True + artifact_type = (manifest.get("artifactType") or "").lower() + if artifact_type: + return artifact_type in IMAGE_CONFIG_MEDIA_TYPES + if content_type in INDEX_MEDIA_TYPES: + # Plain multi-arch index + return True + config_type = (manifest.get("config", {}).get("mediaType") or "").lower() + return config_type in IMAGE_CONFIG_MEDIA_TYPES + logger.info( + f"Skipping {repository}:{tag} — manifest media type {content_type or 'unknown'} is not a container image" + ) + return False + def _ensure_auth(self, repository: str | None = None) -> None: - if self._bearer_token: - return - if self._basic_auth_verified: + if self._bearer_token or self._basic_auth_verified or self._anonymous_verified: return + with self._auth_lock: + if ( + self._bearer_token + or self._basic_auth_verified + or self._anonymous_verified + ): + return + self._authenticate(repository=repository) + + def _authenticate(self, repository: str | None = None) -> None: if self.token: self._bearer_token = self.token return ping_url = f"{self._base_url}/v2/" resp = self._request_with_retry("GET", ping_url) if resp.status_code == 200: + self._anonymous_verified = True return if resp.status_code == 401: www_auth = resp.headers.get("Www-Authenticate", "") - if not www_auth.lower().startswith("bearer"): + bearer_challenge = self._find_challenge(www_auth, "Bearer") + if not bearer_challenge: # Basic auth challenge (e.g., AWS ECR) if self.username and self.password: self._basic_auth_verified = True @@ -108,7 +221,7 @@ class OciRegistryAdapter(RegistryAdapter): ) # Bearer token exchange (standard OCI flow) - self._bearer_token = self._obtain_bearer_token(www_auth, repository) + self._bearer_token = self._obtain_bearer_token(bearer_challenge, repository) return if resp.status_code == 403: raise ImageRegistryAuthError( @@ -130,7 +243,18 @@ class OciRegistryAdapter(RegistryAdapter): message=f"Cannot parse token endpoint from registry {self.registry_url}. Www-Authenticate: {www_authenticate[:200]}", ) realm = self._validate_outbound_url(match.group(1)) - if urlparse(realm).scheme == "http": + realm_is_http = urlparse(realm).scheme == "http" + if realm_is_http and urlparse(self._base_url).scheme == "https": + # Transport downgrade: an on-path attacker could read or replace + # the token. An all-HTTP registry is an explicit operator choice. + raise ImageRegistryAuthError( + file=__file__, + message=( + f"Registry {self.registry_url} uses HTTPS but its token realm " + f"{realm} uses HTTP; refusing to exchange a token over cleartext." + ), + ) + if realm_is_http: logger.warning(f"Bearer token realm uses HTTP (not HTTPS): {realm}") params: dict = {} service_match = re.search(r'service="([^"]+)"', www_authenticate) @@ -178,19 +302,73 @@ class OciRegistryAdapter(RegistryAdapter): def _authed_request(self, method: str, url: str, **kwargs) -> requests.Response: resp = self._do_authed_request(method, url, **kwargs) if resp.status_code == 401 and self._bearer_token: - logger.debug( - f"Bearer token rejected (HTTP 401), re-authenticating to {self.registry_url}" + challenge = self._find_challenge( + resp.headers.get("Www-Authenticate", ""), "Bearer" ) - self._bearer_token = None - self._ensure_auth() - resp = self._do_authed_request(method, url, **kwargs) + if challenge and self._is_same_origin_as_registry(url): + # The cached token may be scoped to another repository; the + # response challenge names the exact scope this endpoint needs. + logger.debug( + f"Bearer token rejected (HTTP 401), re-authenticating with response challenge scope for {url}" + ) + fresh_token = self._obtain_bearer_token(challenge) + self._bearer_token = fresh_token + else: + logger.debug( + f"Bearer token rejected (HTTP 401), re-authenticating to {self.registry_url}" + ) + self._bearer_token = None + self._ensure_auth() + fresh_token = self._bearer_token + # Retry with the token this request obtained: a concurrent worker + # may have already replaced the shared one with another scope. + resp = self._do_authed_request( + method, url, bearer_token=fresh_token, **kwargs + ) + if ( + resp.status_code == 401 + and self._bearer_token + and self.username + and self.password + and self._is_same_origin_as_registry(url) + and self._find_challenge(resp.headers.get("Www-Authenticate", ""), "Basic") + ): + # Registries like Harbor guard some endpoints (e.g. /_catalog) with + # Basic even when /v2/ negotiates Bearer. + logger.debug( + f"Bearer token not accepted for {url}, retrying with Basic auth" + ) + user, pwd = self._resolve_basic_credentials() + resp = self._request_with_retry(method, url, auth=(user, pwd), **kwargs) + if resp.ok: + # Stay in Basic mode so later requests (e.g. catalog pages) + # skip the doomed Bearer round-trips. + self._basic_auth_verified = True + self._bearer_token = None + if resp.status_code == 401 and not self._bearer_token: + bearer_challenge = self._find_challenge( + resp.headers.get("Www-Authenticate", ""), "Bearer" + ) + if bearer_challenge and self._is_same_origin_as_registry(url): + # The inverse switch: /v2/ negotiated Basic (or anonymous) but + # this endpoint demands Bearer. The challenge carries the right + # scope. + logger.debug(f"Basic auth not accepted for {url}, switching to Bearer") + fresh_token = self._obtain_bearer_token(bearer_challenge) + self._bearer_token = fresh_token + resp = self._do_authed_request( + method, url, bearer_token=fresh_token, **kwargs + ) return resp - def _do_authed_request(self, method: str, url: str, **kwargs) -> requests.Response: + def _do_authed_request( + self, method: str, url: str, bearer_token: str | None = None, **kwargs + ) -> requests.Response: headers = kwargs.pop("headers", {}) if self._is_same_origin_as_registry(url): - if self._bearer_token: - headers["Authorization"] = f"Bearer {self._bearer_token}" + token = bearer_token or self._bearer_token + if token: + headers["Authorization"] = f"Bearer {token}" elif self.username and self.password: user, pwd = self._resolve_basic_credentials() kwargs.setdefault("auth", (user, pwd)) diff --git a/prowler/providers/m365/services/defender/defender_antiphishing_policy_configured/defender_antiphishing_policy_configured.py b/prowler/providers/m365/services/defender/defender_antiphishing_policy_configured/defender_antiphishing_policy_configured.py index 61727b9d3c..5c467f941a 100644 --- a/prowler/providers/m365/services/defender/defender_antiphishing_policy_configured/defender_antiphishing_policy_configured.py +++ b/prowler/providers/m365/services/defender/defender_antiphishing_policy_configured/defender_antiphishing_policy_configured.py @@ -55,6 +55,12 @@ class defender_antiphishing_policy_configured(Check): policy_name, policy, ) in defender_client.antiphishing_policies.items(): + # Preset security policies are scoped by protection policy rules, not filter rules + if ( + not policy.default + and policy.name not in defender_client.antiphishing_rules + ): + continue report = CheckReportM365( metadata=self.metadata(), resource=policy, diff --git a/prowler/providers/m365/services/defender/defender_antispam_policy_inbound_no_allowed_domains/defender_antispam_policy_inbound_no_allowed_domains.py b/prowler/providers/m365/services/defender/defender_antispam_policy_inbound_no_allowed_domains/defender_antispam_policy_inbound_no_allowed_domains.py index 67b6643e9e..56616258e0 100644 --- a/prowler/providers/m365/services/defender/defender_antispam_policy_inbound_no_allowed_domains/defender_antispam_policy_inbound_no_allowed_domains.py +++ b/prowler/providers/m365/services/defender/defender_antispam_policy_inbound_no_allowed_domains/defender_antispam_policy_inbound_no_allowed_domains.py @@ -51,6 +51,12 @@ class defender_antispam_policy_inbound_no_allowed_domains(Check): default_policy_well_configured = False for policy in defender_client.inbound_spam_policies: + # Preset security policies are scoped by protection policy rules, not filter rules + if ( + not policy.default + and policy.identity not in defender_client.inbound_spam_rules + ): + continue report = CheckReportM365( metadata=self.metadata(), resource=policy, diff --git a/prowler/providers/m365/services/defender/defender_malware_policy_common_attachments_filter_enabled/defender_malware_policy_common_attachments_filter_enabled.py b/prowler/providers/m365/services/defender/defender_malware_policy_common_attachments_filter_enabled/defender_malware_policy_common_attachments_filter_enabled.py index a0b817cda3..d9a7080295 100644 --- a/prowler/providers/m365/services/defender/defender_malware_policy_common_attachments_filter_enabled/defender_malware_policy_common_attachments_filter_enabled.py +++ b/prowler/providers/m365/services/defender/defender_malware_policy_common_attachments_filter_enabled/defender_malware_policy_common_attachments_filter_enabled.py @@ -51,6 +51,12 @@ class defender_malware_policy_common_attachments_filter_enabled(Check): default_policy_well_configured = False for policy in defender_client.malware_policies: + # Preset security policies are scoped by protection policy rules, not filter rules + if ( + not policy.is_default + and policy.identity not in defender_client.malware_rules + ): + continue report = CheckReportM365( metadata=self.metadata(), resource=policy, diff --git a/prowler/providers/m365/services/defender/defender_malware_policy_comprehensive_attachments_filter_applied/defender_malware_policy_comprehensive_attachments_filter_applied.py b/prowler/providers/m365/services/defender/defender_malware_policy_comprehensive_attachments_filter_applied/defender_malware_policy_comprehensive_attachments_filter_applied.py index 87aa4ab50e..808ece11b9 100644 --- a/prowler/providers/m365/services/defender/defender_malware_policy_comprehensive_attachments_filter_applied/defender_malware_policy_comprehensive_attachments_filter_applied.py +++ b/prowler/providers/m365/services/defender/defender_malware_policy_comprehensive_attachments_filter_applied/defender_malware_policy_comprehensive_attachments_filter_applied.py @@ -114,6 +114,12 @@ class defender_malware_policy_comprehensive_attachments_filter_applied(Check): default_policy_well_configured = False for policy in defender_client.malware_policies: + # Preset security policies are scoped by protection policy rules, not filter rules + if ( + not policy.is_default + and policy.identity not in defender_client.malware_rules + ): + continue report = CheckReportM365( metadata=self.metadata(), resource=policy, diff --git a/prowler/providers/m365/services/defender/defender_malware_policy_notifications_internal_users_malware_enabled/defender_malware_policy_notifications_internal_users_malware_enabled.py b/prowler/providers/m365/services/defender/defender_malware_policy_notifications_internal_users_malware_enabled/defender_malware_policy_notifications_internal_users_malware_enabled.py index 735cc6b9e9..ab5efc8efe 100644 --- a/prowler/providers/m365/services/defender/defender_malware_policy_notifications_internal_users_malware_enabled/defender_malware_policy_notifications_internal_users_malware_enabled.py +++ b/prowler/providers/m365/services/defender/defender_malware_policy_notifications_internal_users_malware_enabled/defender_malware_policy_notifications_internal_users_malware_enabled.py @@ -51,6 +51,12 @@ class defender_malware_policy_notifications_internal_users_malware_enabled(Check default_policy_well_configured = False for policy in defender_client.malware_policies: + # Preset security policies are scoped by protection policy rules, not filter rules + if ( + not policy.is_default + and policy.identity not in defender_client.malware_rules + ): + continue report = CheckReportM365( metadata=self.metadata(), resource=policy, diff --git a/prowler/providers/m365/services/defenderidentity/defenderidentity_health_issues_no_open/defenderidentity_health_issues_no_open.py b/prowler/providers/m365/services/defenderidentity/defenderidentity_health_issues_no_open/defenderidentity_health_issues_no_open.py index 32e1fe2201..d667e77c61 100644 --- a/prowler/providers/m365/services/defenderidentity/defenderidentity_health_issues_no_open/defenderidentity_health_issues_no_open.py +++ b/prowler/providers/m365/services/defenderidentity/defenderidentity_health_issues_no_open/defenderidentity_health_issues_no_open.py @@ -21,6 +21,8 @@ class defenderidentity_health_issues_no_open(Check): - PASS: The health issue has been resolved (status is not open). - FAIL: The health issue is open and requires attention. - FAIL: No sensors are deployed (MDI cannot protect the environment). + - MANUAL: The Defender for Identity APIs could not be queried (missing + permissions), so the check cannot be evaluated. """ def execute(self) -> List[CheckReportM365]: @@ -50,11 +52,12 @@ class defenderidentity_health_issues_no_open(Check): resource_name="Defender for Identity", resource_id="defenderIdentity", ) - report.status = "FAIL" + report.status = "MANUAL" report.status_extended = ( - "Defender for Identity APIs are not accessible. " - "Ensure the Service Principal has SecurityIdentitiesSensors.Read.All and " - "SecurityIdentitiesHealth.Read.All permissions granted." + "Cannot evaluate Defender for Identity health issues: the " + "Defender for Identity APIs are not accessible. Ensure the " + "scanning application has the SecurityIdentitiesSensors.Read.All " + "and SecurityIdentitiesHealth.Read.All permissions granted." ) findings.append(report) return findings @@ -67,11 +70,12 @@ class defenderidentity_health_issues_no_open(Check): resource_name="Defender for Identity", resource_id="defenderIdentity", ) - report.status = "FAIL" + report.status = "MANUAL" report.status_extended = ( - f"Cannot read health issues from Defender for Identity " - f"(found {len(defenderidentity_client.sensors)} sensor(s) deployed). " - "Ensure the Service Principal has SecurityIdentitiesHealth.Read.All permission." + f"Cannot evaluate Defender for Identity health issues " + f"(found {len(defenderidentity_client.sensors)} sensor(s) deployed): " + "the health issues API is not accessible. Ensure the scanning " + "application has the SecurityIdentitiesHealth.Read.All permission granted." ) findings.append(report) return findings @@ -93,7 +97,9 @@ class defenderidentity_health_issues_no_open(Check): findings.append(report) return findings - # If health_issues is empty list - no issues exist, this is compliant + # If health_issues is empty list - no issues exist. This is only + # compliant when sensor deployment could actually be verified: with + # the sensors API failed, an empty issue list cannot be trusted. if not defenderidentity_client.health_issues: report = CheckReportM365( metadata=self.metadata(), @@ -101,10 +107,20 @@ class defenderidentity_health_issues_no_open(Check): resource_name="Defender for Identity", resource_id="defenderIdentity", ) - report.status = "PASS" - report.status_extended = ( - "No open health issues found in Defender for Identity." - ) + if sensors_api_failed: + report.status = "MANUAL" + report.status_extended = ( + "Cannot evaluate Defender for Identity health issues: no " + "open health issues were returned but sensor deployment " + "could not be verified (sensors API not accessible). Ensure " + "the scanning application has the " + "SecurityIdentitiesSensors.Read.All permission granted." + ) + else: + report.status = "PASS" + report.status_extended = ( + "No open health issues found in Defender for Identity." + ) findings.append(report) return findings diff --git a/prowler/providers/m365/services/defenderxdr/defenderxdr_critical_asset_management_pending_approvals/defenderxdr_critical_asset_management_pending_approvals.py b/prowler/providers/m365/services/defenderxdr/defenderxdr_critical_asset_management_pending_approvals/defenderxdr_critical_asset_management_pending_approvals.py index 747ea117a5..a86d4ecdd1 100644 --- a/prowler/providers/m365/services/defenderxdr/defenderxdr_critical_asset_management_pending_approvals/defenderxdr_critical_asset_management_pending_approvals.py +++ b/prowler/providers/m365/services/defenderxdr/defenderxdr_critical_asset_management_pending_approvals/defenderxdr_critical_asset_management_pending_approvals.py @@ -25,6 +25,8 @@ class defenderxdr_critical_asset_management_pending_approvals(Check): Results: - PASS: No pending approvals for Critical Asset Management are found. - FAIL: At least one asset classification has pending approvals. + - MANUAL: Defender XDR could not be queried (missing permission or Security + Exposure Management not available), so the check cannot be evaluated. """ def execute(self) -> List[CheckReportM365]: @@ -47,10 +49,13 @@ class defenderxdr_critical_asset_management_pending_approvals(Check): resource_name="Critical Asset Management", resource_id="criticalAssetManagement", ) - report.status = "FAIL" + report.status = "MANUAL" report.status_extended = ( - "Unable to query Critical Asset Management status. " - "Verify that ThreatHunting.Read.All permission is granted." + "Cannot evaluate Critical Asset Management pending approvals: " + "unable to query Microsoft Defender XDR Advanced Hunting. " + "Verify that the ThreatHunting.Read.All permission is granted " + "to the scanning application and that Security Exposure " + "Management is enabled in the tenant." ) findings.append(report) return findings diff --git a/prowler/providers/m365/services/defenderxdr/defenderxdr_endpoint_privileged_user_exposed_credentials/defenderxdr_endpoint_privileged_user_exposed_credentials.py b/prowler/providers/m365/services/defenderxdr/defenderxdr_endpoint_privileged_user_exposed_credentials/defenderxdr_endpoint_privileged_user_exposed_credentials.py index 031aaacb29..86d4512d41 100644 --- a/prowler/providers/m365/services/defenderxdr/defenderxdr_endpoint_privileged_user_exposed_credentials/defenderxdr_endpoint_privileged_user_exposed_credentials.py +++ b/prowler/providers/m365/services/defenderxdr/defenderxdr_endpoint_privileged_user_exposed_credentials/defenderxdr_endpoint_privileged_user_exposed_credentials.py @@ -25,6 +25,9 @@ class defenderxdr_endpoint_privileged_user_exposed_credentials(Check): Results: - PASS: No exposed credentials found OR MDE enabled but no devices - FAIL: Exposed credentials detected OR MDE not enabled (blind spot) + - MANUAL: Defender XDR could not be queried (missing permission or + Security Exposure Management not available), so the check cannot + be evaluated """ def execute(self) -> list[CheckReportM365]: @@ -46,10 +49,12 @@ class defenderxdr_endpoint_privileged_user_exposed_credentials(Check): resource_name="Defender XDR", resource_id="mdeStatus", ) - report.status = "FAIL" + report.status = "MANUAL" report.status_extended = ( - "Unable to query Microsoft Defender XDR status. " - "Verify that ThreatHunting.Read.All permission is granted." + "Cannot evaluate credential exposure for privileged users: " + "unable to query Microsoft Defender XDR Advanced Hunting. " + "Verify that the ThreatHunting.Read.All permission is granted " + "to the scanning application." ) findings.append(report) return findings @@ -99,11 +104,11 @@ class defenderxdr_endpoint_privileged_user_exposed_credentials(Check): resource_name="Defender XDR", resource_id="exposedCredentials", ) - report.status = "FAIL" + report.status = "MANUAL" report.status_extended = ( - "Unable to query Security Exposure Management for exposed " - "credentials. Verify that Security Exposure Management " - "is enabled." + "Cannot evaluate credential exposure for privileged users: " + "unable to query Security Exposure Management. Verify that " + "Security Exposure Management is enabled in the tenant." ) findings.append(report) return findings diff --git a/prowler/providers/m365/services/defenderxdr/defenderxdr_service.py b/prowler/providers/m365/services/defenderxdr/defenderxdr_service.py index c1b804f126..559c821392 100644 --- a/prowler/providers/m365/services/defenderxdr/defenderxdr_service.py +++ b/prowler/providers/m365/services/defenderxdr/defenderxdr_service.py @@ -116,7 +116,13 @@ class DefenderXDR(M365Service): request_body ) - if not response or not response.results: + if response is None: + # A null response object is not a successful empty query: + # the data could not be retrieved. + logger.error("DefenderXDR - Advanced Hunting returned a null response.") + return None, False + + if not response.results: return [], False results = [ @@ -200,11 +206,20 @@ ExposureGraphEdges TargetCategories = TargetNodeCategories """ - results, _ = await self._run_hunting_query(query) + results, table_not_found = await self._run_hunting_query(query) if results is None: return None + if table_not_found: + # Security Exposure Management tables are not available in this + # tenant: the check cannot be evaluated (not a legitimate empty result). + logger.warning( + "DefenderXDR - Security Exposure Management tables are not " + "available in this tenant; results cannot be evaluated." + ) + return None + return [self._parse_exposed_credential(row) for row in results if row] def _parse_exposed_credential(self, row: Dict) -> "ExposedCredentialPrivilegedUser": @@ -253,11 +268,20 @@ ExposureGraphNodes | sort by Classification asc """ - results, _ = await self._run_hunting_query(query) + results, table_not_found = await self._run_hunting_query(query) if results is None: return None + if table_not_found: + # Security Exposure Management tables are not available in this + # tenant: the check cannot be evaluated (not a legitimate empty result). + logger.warning( + "DefenderXDR - Security Exposure Management tables are not " + "available in this tenant; results cannot be evaluated." + ) + return None + pending_approvals = [] for row in results: if not row: diff --git a/prowler/providers/m365/services/entra/entra_app_registration_no_unused_privileged_permissions/entra_app_registration_no_unused_privileged_permissions.metadata.json b/prowler/providers/m365/services/entra/entra_app_registration_no_unused_privileged_permissions/entra_app_registration_no_unused_privileged_permissions.metadata.json index 4044bef298..b344f39b58 100644 --- a/prowler/providers/m365/services/entra/entra_app_registration_no_unused_privileged_permissions/entra_app_registration_no_unused_privileged_permissions.metadata.json +++ b/prowler/providers/m365/services/entra/entra_app_registration_no_unused_privileged_permissions/entra_app_registration_no_unused_privileged_permissions.metadata.json @@ -34,5 +34,5 @@ ], "DependsOn": [], "RelatedTo": [], - "Notes": "This check requires Microsoft Defender for Cloud Apps with App Governance enabled and ThreatHunting.Read.All permission. If App Governance data is unavailable, the check fails due to missing visibility." + "Notes": "This check requires Microsoft Defender for Cloud Apps with App Governance enabled and ThreatHunting.Read.All permission. If App Governance data is unavailable, the check reports MANUAL because unused permissions cannot be evaluated." } diff --git a/prowler/providers/m365/services/entra/entra_app_registration_no_unused_privileged_permissions/entra_app_registration_no_unused_privileged_permissions.py b/prowler/providers/m365/services/entra/entra_app_registration_no_unused_privileged_permissions/entra_app_registration_no_unused_privileged_permissions.py index f7107827b9..a8889a7a40 100644 --- a/prowler/providers/m365/services/entra/entra_app_registration_no_unused_privileged_permissions/entra_app_registration_no_unused_privileged_permissions.py +++ b/prowler/providers/m365/services/entra/entra_app_registration_no_unused_privileged_permissions/entra_app_registration_no_unused_privileged_permissions.py @@ -15,7 +15,8 @@ class entra_app_registration_no_unused_privileged_permissions(Check): - PASS: The app has no unused privileged permissions. - FAIL: The app has one or more unused privileged permissions that should be revoked. - It also fails when OAuth App Governance data is not available. + - MANUAL: OAuth App Governance data is not available (App Governance not enabled or + missing permission), so the check cannot be evaluated. """ # InUse field values from OAuthAppInfo: @@ -47,11 +48,12 @@ class entra_app_registration_no_unused_privileged_permissions(Check): resource_name="OAuth Applications", resource_id="oauthApps", ) - report.status = "FAIL" + report.status = "MANUAL" report.status_extended = ( - "OAuth App Governance data is unavailable. " - "Enable App Governance in Microsoft Defender for Cloud Apps and " - "grant ThreatHunting.Read.All to evaluate unused privileged permissions." + "Cannot evaluate unused privileged permissions: OAuth App " + "Governance data is unavailable. Enable App Governance in " + "Microsoft Defender for Cloud Apps and grant the " + "ThreatHunting.Read.All permission to the scanning application." ) findings.append(report) return findings diff --git a/prowler/providers/m365/services/entra/entra_break_glass_account_fido2_security_key_registered/entra_break_glass_account_fido2_security_key_registered.py b/prowler/providers/m365/services/entra/entra_break_glass_account_fido2_security_key_registered/entra_break_glass_account_fido2_security_key_registered.py index e90eed0023..4beb9d7e06 100644 --- a/prowler/providers/m365/services/entra/entra_break_glass_account_fido2_security_key_registered/entra_break_glass_account_fido2_security_key_registered.py +++ b/prowler/providers/m365/services/entra/entra_break_glass_account_fido2_security_key_registered/entra_break_glass_account_fido2_security_key_registered.py @@ -16,7 +16,8 @@ class entra_break_glass_account_fido2_security_key_registered(Check): - PASS: The break glass account has a FIDO2 security key (fido2SecurityKey) registered. - MANUAL: The account has a device-bound passkey but it cannot be confirmed as FIDO2, - or no break glass accounts could be identified. + no break glass accounts could be identified, or the users / user + registration details could not be read (insufficient permissions). - FAIL: The break glass account does not have a FIDO2 security key registered. """ @@ -61,6 +62,18 @@ class entra_break_glass_account_fido2_security_key_registered(Check): if count == total_policy_count ] + if entra_client.users_error: + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Break Glass Accounts", + resource_id="breakGlassAccounts", + ) + report.status = "MANUAL" + report.status_extended = f"Cannot verify FIDO2 security key registration for break glass accounts: {entra_client.users_error}." + findings.append(report) + return findings + if not break_glass_user_ids: report = CheckReportM365( metadata=self.metadata(), @@ -73,6 +86,21 @@ class entra_break_glass_account_fido2_security_key_registered(Check): findings.append(report) return findings + if entra_client.user_registration_details_error: + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Break Glass Accounts", + resource_id="breakGlassAccounts", + ) + report.status = "MANUAL" + report.status_extended = ( + "Cannot verify FIDO2 security key registration for break glass " + f"accounts: {entra_client.user_registration_details_error}." + ) + findings.append(report) + return findings + for user_id in break_glass_user_ids: user = entra_client.users.get(user_id) if not user: @@ -85,15 +113,6 @@ class entra_break_glass_account_fido2_security_key_registered(Check): resource_id=user.id, ) - if entra_client.user_registration_details_error: - report.status = "FAIL" - report.status_extended = ( - f"Cannot verify FIDO2 security key registration for break glass account {user.name}: " - f"{entra_client.user_registration_details_error}." - ) - findings.append(report) - continue - auth_methods = set(user.authentication_methods) has_fido2 = "fido2SecurityKey" in auth_methods has_passkey_device_bound = "passKeyDeviceBound" in auth_methods diff --git a/prowler/providers/m365/services/entra/entra_seamless_sso_disabled/entra_seamless_sso_disabled.py b/prowler/providers/m365/services/entra/entra_seamless_sso_disabled/entra_seamless_sso_disabled.py index eb6b633ee9..9098b7c4ed 100644 --- a/prowler/providers/m365/services/entra/entra_seamless_sso_disabled/entra_seamless_sso_disabled.py +++ b/prowler/providers/m365/services/entra/entra_seamless_sso_disabled/entra_seamless_sso_disabled.py @@ -14,7 +14,10 @@ class entra_seamless_sso_disabled(Check): Primary Refresh Token (PRT) support make this feature unnecessary for most organizations. - PASS: Seamless SSO is disabled or on-premises sync is not enabled (cloud-only). - - FAIL: Seamless SSO is enabled in a hybrid deployment, or cannot verify due to insufficient permissions. + - FAIL: Seamless SSO is enabled in a hybrid deployment. + - MANUAL: Hybrid deployment whose directory sync settings could not be read + (insufficient permissions or no settings returned), so the check cannot be + evaluated. """ def execute(self) -> List[CheckReportM365]: @@ -38,9 +41,9 @@ class entra_seamless_sso_disabled(Check): resource_id=organization.id, resource_name=organization.name, ) - # Only FAIL for hybrid orgs; cloud-only orgs don't need this permission + # Only MANUAL for hybrid orgs; cloud-only orgs don't need this permission if organization.on_premises_sync_enabled: - report.status = "FAIL" + report.status = "MANUAL" report.status_extended = f"Cannot verify Seamless SSO status for {organization.name}: {entra_client.directory_sync_error}." else: report.status = "PASS" @@ -66,7 +69,8 @@ class entra_seamless_sso_disabled(Check): findings.append(report) - # If no directory sync settings and no error, it's a cloud-only tenant + # No directory sync settings and no error: cloud-only organizations are + # not applicable; a hybrid organization without settings cannot be verified. if not entra_client.directory_sync_settings: for organization in entra_client.organizations: report = CheckReportM365( @@ -75,8 +79,12 @@ class entra_seamless_sso_disabled(Check): resource_id=organization.id, resource_name=organization.name, ) - report.status = "PASS" - report.status_extended = f"Entra organization {organization.name} is cloud-only (no on-premises sync), Seamless SSO is not applicable." + if organization.on_premises_sync_enabled: + report.status = "MANUAL" + report.status_extended = f"Cannot verify Seamless SSO status for {organization.name}: no directory synchronization settings were returned for this hybrid organization." + else: + report.status = "PASS" + report.status_extended = f"Entra organization {organization.name} is cloud-only (no on-premises sync), Seamless SSO is not applicable." 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 c839216a76..c761834195 100644 --- a/prowler/providers/m365/services/entra/entra_service.py +++ b/prowler/providers/m365/services/entra/entra_service.py @@ -86,6 +86,10 @@ class Entra(M365Service): self.tenant_domain = provider.identity.tenant_domain self.tenant_id = getattr(provider.identity, "tenant_id", None) self.user_registration_details_error: Optional[str] = None + # Set when the Microsoft Graph /users request (or its directory role + # dependencies) fails, so checks can report that users are unavailable + # instead of silently evaluating an empty directory. + self.users_error: Optional[str] = None self.exchange_mailbox_permission_service_principals_error: Optional[str] = None attributes = loop.run_until_complete( gather( @@ -924,7 +928,7 @@ class Entra(M365Service): except ODataError as error: error_code = getattr(error.error, "code", None) if error.error else None if error_code == "Authorization_RequestDenied": - error_message = "Insufficient privileges to read directory sync settings. Required permission: OnPremDirectorySynchronization.Read.All or OnPremDirectorySynchronization.ReadWrite.All" + error_message = "Insufficient privileges to read directory sync settings. Required permission: OnPremDirectorySynchronization.Read.All or OnPremDirectorySynchronization.ReadWrite.All (Microsoft Graph only supports this as a delegated permission for a Global Administrator; application permissions are not supported)" logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error_message}" ) @@ -941,6 +945,19 @@ class Entra(M365Service): return directory_sync_settings, error_message async def _get_users(self): + """Retrieve the tenant users with their directory roles and MFA registration. + + Depends on ``GET /users``, ``GET /directoryRoles`` and the members of + each role. If any of those Graph calls fails, ``self.users_error`` is + set so checks can report that the directory could not be read instead + of evaluating an empty user set. Registration details are fetched via + ``_get_user_registration_details``, which handles its own failures + through ``self.user_registration_details_error``. + + Returns: + dict: User id mapped to ``User``. Empty (or partial, on a + mid-pagination failure) when ``self.users_error`` is set. + """ logger.info("Entra - Getting users...") users = {} try: @@ -1026,7 +1043,17 @@ class Entra(M365Service): if not next_link: break users_response = await self.client.users.with_url(next_link).get() + except ODataError as error: + error_code = getattr(error.error, "code", None) if error.error else None + if error_code == "Authorization_RequestDenied": + self.users_error = "Insufficient privileges to read users and directory roles. Required permissions: User.Read.All, Directory.Read.All or RoleManagement.Read.Directory" + else: + self.users_error = f"Unable to retrieve users from Microsoft Graph ({error_code or error.__class__.__name__})" + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) except Exception as error: + self.users_error = f"Unable to retrieve users from Microsoft Graph ({error.__class__.__name__})" logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) @@ -1124,7 +1151,15 @@ OAuthAppInfo request_body ) - if result and result.results: + if result is None: + # A null response object is not a successful empty query: + # the OAuth app inventory could not be retrieved. + logger.warning( + "Entra - Advanced Hunting returned a null response for OAuthAppInfo." + ) + return None + + if result.results: for row in result.results: row_data = row.additional_data raw_app_id = row_data.get("OAuthAppId", "") diff --git a/prowler/providers/m365/services/entra/entra_users_mfa_capable/entra_users_mfa_capable.py b/prowler/providers/m365/services/entra/entra_users_mfa_capable/entra_users_mfa_capable.py index d3e75cef7f..808d4e7e1e 100644 --- a/prowler/providers/m365/services/entra/entra_users_mfa_capable/entra_users_mfa_capable.py +++ b/prowler/providers/m365/services/entra/entra_users_mfa_capable/entra_users_mfa_capable.py @@ -17,8 +17,9 @@ class entra_users_mfa_capable(Check): evaluation. - PASS: The member user is MFA capable. - - FAIL: The member user is not MFA capable, or MFA capability cannot be - verified due to insufficient permissions to read user registration details. + - FAIL: The member user is not MFA capable. + - MANUAL: Users or their registration details could not be read + (insufficient permissions), so MFA capability cannot be verified. """ def execute(self) -> List[CheckReportM365]: @@ -37,6 +38,23 @@ class entra_users_mfa_capable(Check): """ findings = [] + data_error = ( + entra_client.users_error or entra_client.user_registration_details_error + ) + if data_error: + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Entra Users", + resource_id="users", + ) + report.status = "MANUAL" + report.status_extended = ( + f"Cannot verify MFA capability for member users: {data_error}." + ) + findings.append(report) + return findings + for user in entra_client.users.values(): if user.user_type == "Guest" or not user.account_enabled: continue @@ -57,13 +75,7 @@ class entra_users_mfa_capable(Check): resource_id=user.id, ) - if entra_client.user_registration_details_error: - report.status = "FAIL" - report.status_extended = ( - f"Cannot verify MFA capability for user {user.name}: " - f"{entra_client.user_registration_details_error}." - ) - elif not user.is_mfa_capable: + if not user.is_mfa_capable: report.status = "FAIL" report.status_extended = f"User {user.name} is not MFA capable." else: diff --git a/prowler/providers/m365/services/exchange/exchange_shared_mailbox_sign_in_disabled/exchange_shared_mailbox_sign_in_disabled.py b/prowler/providers/m365/services/exchange/exchange_shared_mailbox_sign_in_disabled/exchange_shared_mailbox_sign_in_disabled.py index 849731f7c8..7e14902a4b 100644 --- a/prowler/providers/m365/services/exchange/exchange_shared_mailbox_sign_in_disabled/exchange_shared_mailbox_sign_in_disabled.py +++ b/prowler/providers/m365/services/exchange/exchange_shared_mailbox_sign_in_disabled/exchange_shared_mailbox_sign_in_disabled.py @@ -15,6 +15,9 @@ class exchange_shared_mailbox_sign_in_disabled(Check): - PASS: Shared mailbox has sign-in blocked (AccountEnabled = False in Entra ID). - FAIL: Shared mailbox has sign-in enabled (AccountEnabled = True in Entra ID). + - MANUAL: The Entra users could not be retrieved (tenant-level), or the + shared mailbox could not be resolved in Entra ID, so its sign-in status + cannot be verified. """ def execute(self) -> List[CheckReportM365]: @@ -30,6 +33,23 @@ class exchange_shared_mailbox_sign_in_disabled(Check): """ findings = [] + # A tenant-wide failure retrieving Entra users would otherwise surface + # as one misleading MANUAL per mailbox: report it once instead. + if exchange_client.shared_mailboxes and entra_client.users_error: + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Shared Mailboxes", + resource_id="sharedMailboxes", + ) + report.status = "MANUAL" + report.status_extended = ( + "Cannot verify sign-in status for shared mailboxes: " + f"{entra_client.users_error}." + ) + findings.append(report) + return findings + for shared_mailbox in exchange_client.shared_mailboxes: report = CheckReportM365( metadata=self.metadata(), @@ -45,8 +65,8 @@ class exchange_shared_mailbox_sign_in_disabled(Check): ) if not entra_user: - report.status = "FAIL" - report.status_extended = f"Shared mailbox {shared_mailbox.user_principal_name} could not be found in Entra ID for verification." + report.status = "MANUAL" + report.status_extended = f"Cannot verify sign-in status for shared mailbox {shared_mailbox.user_principal_name}: the user could not be resolved in Entra ID." elif entra_user.account_enabled: report.status = "FAIL" report.status_extended = f"Shared mailbox {shared_mailbox.user_principal_name} has sign-in enabled." diff --git a/pyproject.toml b/pyproject.toml index 12c599420d..a05fa6a6ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -133,6 +133,7 @@ dependencies = [ "huaweicloudsdkkms==3.1.204", "huaweicloudsdkobs==3.1.204", "huaweicloudsdkrds==3.1.204", + "huaweicloudsdksmn==3.1.204", "huaweicloudsdkvpc==3.1.204", "huaweicloudsdkwaf==3.1.204", "zstandard==0.25.0" @@ -143,7 +144,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"} name = "prowler" readme = "README.md" requires-python = ">=3.10,<3.14" -version = "5.41.0" +version = "5.43.0" [project.scripts] prowler = "prowler.__main__:prowler" @@ -271,6 +272,7 @@ constraint-dependencies = [ "huaweicloudsdkkms==3.1.204", "huaweicloudsdkobs==3.1.204", "huaweicloudsdkrds==3.1.204", + "huaweicloudsdksmn==3.1.204", "huaweicloudsdkvpc==3.1.204", "huaweicloudsdkwaf==3.1.204", "hyperframe==6.1.0", diff --git a/skills/prowler-sdk-check/SKILL.md b/skills/prowler-sdk-check/SKILL.md index 1901e996d4..d3dc759067 100644 --- a/skills/prowler-sdk-check/SKILL.md +++ b/skills/prowler-sdk-check/SKILL.md @@ -181,7 +181,25 @@ Examples: |--------|-------------| | `PASS` | Resource is compliant | | `FAIL` | Resource is non-compliant | -| `MANUAL` | Requires human verification | +| `MANUAL` | Requires human verification, or the data needed to evaluate the resource could not be retrieved | + +### Permission / availability errors are NOT findings + +Never set `FAIL` because an API call failed (missing permission or scope, API not enabled, feature not licensed, data unavailable). That is a scan-configuration problem, not a security issue, and it surfaces as a misleading high-severity finding. + +- Service: log the error and expose it distinctly from an empty result (`None` instead of `[]`, an `*_error` attribute, or a `*_lookup_failed` set). Only treat real access errors this way; a `404`/not-found usually means "not configured" and IS a legitimate `FAIL`, and a definitively disabled API is a legitimate `FAIL` when the API's activation is itself the audited control (e.g. GCP Access Approval). +- Check: emit ONE tenant/account/project/subscription-level `MANUAL` finding (not one per resource) whose `status_extended` says the check cannot be evaluated and names the required permission/API/license. +- Do not touch `report.check_metadata.Severity` to hide it. + +```python +if _client. is None: + report = CheckReport(metadata=self.metadata(), resource={}) + report.resource_name = "" + report.resource_id = "" + report.status = "MANUAL" + report.status_extended = "Cannot evaluate : could not be retrieved. Verify that is granted to the scanning identity." + return [report] +``` --- diff --git a/tests/config/config_test.py b/tests/config/config_test.py index fbff8ade29..4d469ef009 100644 --- a/tests/config/config_test.py +++ b/tests/config/config_test.py @@ -138,6 +138,8 @@ config_aws = { "organizations_enabled_regions": [], "organizations_trusted_delegated_administrators": [], "ecr_repository_vulnerability_minimum_severity": "MEDIUM", + "inspector2_max_days_since_last_scan": 3, + "inspector2_active_finding_max_age_days": 192, "verify_premium_support_plans": True, "threat_detection_privilege_escalation_threshold": 0.2, "threat_detection_privilege_escalation_minutes": 1440, diff --git a/tests/config/fixtures/config.yaml b/tests/config/fixtures/config.yaml index a64e497544..8b02ba2d43 100644 --- a/tests/config/fixtures/config.yaml +++ b/tests/config/fixtures/config.yaml @@ -139,6 +139,14 @@ aws: # MEDIUM ecr_repository_vulnerability_minimum_severity: "MEDIUM" + # AWS Inspector2 + # aws.inspector2_coverage_recently_scanned + # Maximum days since Inspector2 last scanned an actively covered resource + inspector2_max_days_since_last_scan: 3 + # aws.inspector2_active_findings_within_max_age + # Maximum days an Inspector2 finding can stay active since it was first observed + inspector2_active_finding_max_age_days: 192 + # AWS Trusted Advisor # aws.trustedadvisor_premium_support_plan_subscribed verify_premium_support_plans: True diff --git a/tests/config/schema/aws_schema_test.py b/tests/config/schema/aws_schema_test.py index 838853f52a..f7c8e3b485 100644 --- a/tests/config/schema/aws_schema_test.py +++ b/tests/config/schema/aws_schema_test.py @@ -203,6 +203,53 @@ class TestAWSELBv2PQCTLSAllowedPolicies: assert _validate({"elbv2_listener_pqc_tls_allowed_policies": value}) == {} +class TestAWSAgentCoreLogGroupNamePrefixes: + def test_valid_prefix_list_round_trips(self): + prefixes = [ + "/aws/bedrock-agentcore/", + "/aws/vendedlogs/bedrock-agentcore/", + ] + + assert _validate({"agentcore_log_group_name_prefixes": prefixes}) == { + "agentcore_log_group_name_prefixes": prefixes + } + + def test_null_round_trips(self): + """A bare `agentcore_log_group_name_prefixes:` in the config file arrives as None. + + It has to survive validation rather than be dropped, because the check distinguishes it + from an empty list: None means "use the built-in defaults" while [] means "match no log + group". Dropping it would collapse the two. + """ + assert _validate({"agentcore_log_group_name_prefixes": None}) == { + "agentcore_log_group_name_prefixes": None + } + + def test_empty_list_round_trips(self): + """[] is a valid instruction, not a missing value -- see test_null_round_trips.""" + assert _validate({"agentcore_log_group_name_prefixes": []}) == { + "agentcore_log_group_name_prefixes": [] + } + + def test_key_is_exposed_in_scan_config_schema(self): + aws_properties = SCAN_CONFIG_SCHEMA["properties"]["aws"]["properties"] + + assert "agentcore_log_group_name_prefixes" in aws_properties + + @pytest.mark.parametrize( + "value", + [ + # A single prefix written without the list, which is the mistake the YAML invites. + "/aws/bedrock-agentcore/", + ["/aws/bedrock-agentcore/", 123], + {"prefix": "/aws/bedrock-agentcore/"}, + 123, + ], + ) + def test_invalid_prefix_values_are_dropped(self, value): + assert _validate({"agentcore_log_group_name_prefixes": value}) == {} + + class Test_AWS_Secrets_Ignore_Files: def test_valid_file_patterns_round_trip(self): files = ["*.deps.json", "vendor/*.js"] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000000..590a5928c0 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,21 @@ +from unittest import mock + +import pytest + +_MOCK_CLASSES = ( + mock.Mock, + mock.MagicMock, + mock.AsyncMock, + mock.NonCallableMock, + mock.NonCallableMagicMock, +) +_MOCK_CLASS_BASELINE = {cls: frozenset(vars(cls)) for cls in _MOCK_CLASSES} + + +@pytest.fixture(autouse=True) +def _reset_mock_class_attributes(): + """Drop attributes a test sets on the mock classes themselves (`c = mock.MagicMock; c.provider = ...`), so they cannot leak into other tests' instances.""" + yield + for cls, baseline in _MOCK_CLASS_BASELINE.items(): + for name in set(vars(cls)) - baseline: + delattr(cls, name) diff --git a/tests/lib/check/compliance_catalog_integrity_test.py b/tests/lib/check/compliance_catalog_integrity_test.py new file mode 100644 index 0000000000..565cb1c916 --- /dev/null +++ b/tests/lib/check/compliance_catalog_integrity_test.py @@ -0,0 +1,64 @@ +import os +from functools import lru_cache + +import pytest + +from prowler.lib.check.compliance_models import load_compliance_framework_universal +from prowler.lib.check.utils import recover_checks_from_provider + +COMPLIANCE_DIR = os.path.normpath( + os.path.join(os.path.dirname(__file__), "..", "..", "..", "prowler", "compliance") +) + + +def _compliance_jsons() -> list[str]: + paths = [] + for root, _, files in os.walk(COMPLIANCE_DIR): + paths.extend(os.path.join(root, f) for f in files if f.endswith(".json")) + return sorted(paths) + + +@lru_cache +def _check_ids(provider: str) -> frozenset[str]: + return frozenset(name for name, _ in recover_checks_from_provider(provider)) + + +@pytest.mark.parametrize("json_path", _compliance_jsons(), ids=os.path.basename) +class TestComplianceCatalogIntegrity: + def test_requirement_ids_are_unique(self, json_path): + framework = load_compliance_framework_universal(json_path) + assert framework is not None, f"Failed to load {json_path}" + + ids = [requirement.id for requirement in framework.requirements] + duplicated = sorted({rid for rid in ids if ids.count(rid) > 1}) + assert not duplicated, f"Duplicated requirement ids: {duplicated}" + + def test_requirements_do_not_repeat_checks(self, json_path): + framework = load_compliance_framework_universal(json_path) + assert framework is not None, f"Failed to load {json_path}" + + repeated = sorted( + { + (requirement.id, provider, check) + for requirement in framework.requirements + for provider, checks in requirement.checks.items() + for check in checks + if checks.count(check) > 1 + } + ) + assert not repeated, f"Checks listed twice in a requirement: {repeated}" + + def test_referenced_checks_exist_for_provider(self, json_path): + framework = load_compliance_framework_universal(json_path) + assert framework is not None, f"Failed to load {json_path}" + + unknown = sorted( + { + (provider, check) + for requirement in framework.requirements + for provider, checks in requirement.checks.items() + for check in checks + if check not in _check_ids(provider) + } + ) + assert not unknown, f"Checks that do not exist for their provider: {unknown}" diff --git a/tests/lib/cli/parser_test.py b/tests/lib/cli/parser_test.py index da16f437b5..a811186b3b 100644 --- a/tests/lib/cli/parser_test.py +++ b/tests/lib/cli/parser_test.py @@ -1152,6 +1152,35 @@ class Test_Parser: parsed = self.parser.parse(command) assert parsed.aws_retries_max_attempts == int(max_retries) + def test_aws_parser_retries_max_attempts_zero(self): + command = [prowler_command, "--aws-retries-max-attempts", "0"] + parsed = self.parser.parse(command) + assert parsed.aws_retries_max_attempts == 0 + + def test_aws_parser_timeouts_default_to_none(self): + parsed = self.parser.parse([prowler_command]) + assert parsed.aws_connect_timeout is None + assert parsed.aws_read_timeout is None + + @pytest.mark.parametrize( + "argument, attribute", + [ + ("--aws-connect-timeout", "aws_connect_timeout"), + ("--aws-read-timeout", "aws_read_timeout"), + ], + ) + def test_aws_parser_timeouts(self, argument, attribute): + timeout = "5" + command = [prowler_command, argument, timeout] + parsed = self.parser.parse(command) + assert getattr(parsed, attribute) == int(timeout) + + @pytest.mark.parametrize("value", ["0", "-1", "abc"]) + def test_aws_parser_connect_timeout_rejects_non_positive(self, value): + command = [prowler_command, "--aws-connect-timeout", value] + with pytest.raises(SystemExit): + self.parser.parse(command) + def test_aws_parser_scan_unused_services(self): argument = "--scan-unused-services" command = [prowler_command, argument] diff --git a/tests/lib/outputs/jira/jira_test.py b/tests/lib/outputs/jira/jira_test.py index 2b8ac90512..7c5877dc50 100644 --- a/tests/lib/outputs/jira/jira_test.py +++ b/tests/lib/outputs/jira/jira_test.py @@ -1,7 +1,11 @@ import base64 import hashlib +from concurrent.futures import ThreadPoolExecutor from dataclasses import FrozenInstanceError from datetime import datetime, timedelta +from logging import ERROR, WARNING +from threading import Barrier +from time import sleep from types import SimpleNamespace from typing import List, Optional from unittest.mock import MagicMock, PropertyMock, patch @@ -453,6 +457,36 @@ class TestJiraIntegration: assert access_token == "new_access_token" mock_refresh_access_token.assert_called_once() + def test_get_access_token_concurrent_refresh_happens_once(self): + self.jira_integration.auth_expiration = ( + datetime.now() - timedelta(seconds=1) + ).isoformat() + refresh_calls = [] + # All 5 pass the expiry check together, so without a lock every one of + # them would refresh. + barrier = Barrier(5, timeout=5) + + def fake_refresh(): + refresh_calls.append(1) + # Keep the token expired while the other threads check it. + sleep(0.2) + self.jira_integration._access_token = "refreshed_token" + self.jira_integration.auth_expiration = ( + datetime.now() + timedelta(hours=1) + ).isoformat() + return "refreshed_token" + + def get_token(_): + barrier.wait() + return self.jira_integration.get_access_token() + + with patch.object(Jira, "refresh_access_token", side_effect=fake_refresh): + with ThreadPoolExecutor(max_workers=5) as executor: + tokens = list(executor.map(get_token, range(5))) + + assert tokens == ["refreshed_token"] * 5 + assert len(refresh_calls) == 1 + @freeze_time(TEST_DATETIME) @patch("prowler.lib.outputs.jira.jira.requests.post") @patch.object(Jira, "get_cloud_id", return_value="test_cloud_id") @@ -749,6 +783,77 @@ class TestJiraIntegration: domain=self.domain, ) + @patch.object(Jira, "get_auth", return_value=None) + @patch.object( + Jira, + "get_projects", + return_value={"PROJ1": "Project One", "PROJ2": "Project Two"}, + ) + def test_test_connection_partial_issue_types_failure( + self, mock_get_projects, mock_get_auth, caplog + ): + # To disable vulture + mock_get_projects = mock_get_projects + mock_get_auth = mock_get_auth + caplog.set_level(WARNING) + + def fake_issue_types(project_key): + if project_key == "PROJ2": + raise JiraGetAvailableIssueTypesError("no create permission") + return ["Task"] + + with patch.object( + Jira, "get_available_issue_types", side_effect=fake_issue_types + ): + connection = Jira.test_connection( + redirect_uri=self.redirect_uri, + client_id=self.client_id, + client_secret=self.client_secret, + ) + + assert connection.is_connected + assert connection.error is None + assert connection.issue_types == {"PROJ1": ["Task"]} + assert any( + record.levelno == WARNING + and "Failed to get issue types for project PROJ2" in record.message + for record in caplog.records + ) + assert not any(record.levelno >= ERROR for record in caplog.records) + + @patch.object(Jira, "get_auth", return_value=None) + @patch.object( + Jira, + "get_projects", + return_value={f"PROJ{i}": f"Project {i}" for i in range(5)}, + ) + def test_test_connection_fetches_issue_types_concurrently( + self, mock_get_projects, mock_get_auth + ): + # To disable vulture + mock_get_projects = mock_get_projects + mock_get_auth = mock_get_auth + + # Every call blocks until all 5 arrive; a sequential fetch would time out + # at the barrier and surface as a per-project failure instead of a result. + barrier = Barrier(5, timeout=5) + + def fake_issue_types(project_key): + barrier.wait() + return [project_key] + + with patch.object( + Jira, "get_available_issue_types", side_effect=fake_issue_types + ): + connection = Jira.test_connection( + redirect_uri=self.redirect_uri, + client_id=self.client_id, + client_secret=self.client_secret, + ) + + assert connection.is_connected + assert connection.issue_types == {f"PROJ{i}": [f"PROJ{i}"] for i in range(5)} + @patch.object(Jira, "get_auth", return_value=None) @patch.object( Jira, "get_projects", side_effect=JiraNoProjectsError("No projects found") @@ -998,6 +1103,76 @@ class TestJiraIntegration: with pytest.raises(JiraGetAvailableIssueTypesError): self.jira_integration.get_available_issue_types(project_key="TEST") + @patch.object(Jira, "get_access_token", return_value="valid_access_token") + @patch.object( + Jira, "cloud_id", new_callable=PropertyMock, return_value="test_cloud_id" + ) + @patch("prowler.lib.outputs.jira.jira.requests.get") + def test_get_available_issue_types_no_projects_does_not_log( + self, mock_get, mock_cloud_id, mock_get_access_token, caplog + ): + # To disable vulture + mock_cloud_id = mock_cloud_id + mock_get_access_token = mock_get_access_token + caplog.set_level(WARNING) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"projects": []} + mock_get.return_value = mock_response + + with pytest.raises(JiraGetAvailableIssueTypesError): + self.jira_integration.get_available_issue_types(project_key="TEST") + + assert not any(record.levelno >= WARNING for record in caplog.records) + + @patch.object(Jira, "get_auth", return_value=None) + @patch.object( + Jira, + "get_projects", + return_value={"TEST": "Test Project"}, + ) + @patch.object(Jira, "get_access_token", return_value="valid_access_token") + @patch.object( + Jira, "cloud_id", new_callable=PropertyMock, return_value="test_cloud_id" + ) + @patch("prowler.lib.outputs.jira.jira.requests.get") + def test_test_connection_empty_issue_types_logs_one_warning( + self, + mock_get, + mock_cloud_id, + mock_get_access_token, + mock_get_projects, + mock_get_auth, + caplog, + ): + # To disable vulture + mock_cloud_id = mock_cloud_id + mock_get_access_token = mock_get_access_token + mock_get_projects = mock_get_projects + mock_get_auth = mock_get_auth + caplog.set_level(WARNING) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"projects": []} + mock_get.return_value = mock_response + + connection = Jira.test_connection( + redirect_uri=self.redirect_uri, + client_id=self.client_id, + client_secret=self.client_secret, + ) + + warnings = [ + record + for record in caplog.records + if record.levelno == WARNING and "project TEST" in record.message + ] + assert connection.is_connected + assert len(warnings) == 1 + assert not any(record.levelno >= ERROR for record in caplog.records) + @patch.object(Jira, "get_access_token", return_value="valid_access_token") @patch.object( Jira, "cloud_id", new_callable=PropertyMock, return_value="test_cloud_id" diff --git a/tests/providers/aws/aws_provider_test.py b/tests/providers/aws/aws_provider_test.py index f874ca8812..2d759f362a 100644 --- a/tests/providers/aws/aws_provider_test.py +++ b/tests/providers/aws/aws_provider_test.py @@ -16,22 +16,35 @@ from moto import mock_aws from pytest import raises from tzlocal import get_localzone -from prowler.providers.aws.aws_provider import AwsProvider, get_aws_region_for_sts +from prowler.providers.aws.aws_provider import ( + MAX_STS_BOOTSTRAP_ATTEMPTS, + AwsProvider, + get_aws_region_for_sts, + get_env_partition_bootstrap_region, + get_env_partition_regions, + get_partition_bootstrap_candidates, +) from prowler.providers.aws.config import ( AWS_STS_GLOBAL_ENDPOINT_REGION, + BOTO3_CONNECT_TIMEOUT, + BOTO3_READ_TIMEOUT, BOTO3_USER_AGENT_EXTRA, ROLE_SESSION_NAME, + get_boto3_timeout_from_env, get_default_session_config, ) from prowler.providers.aws.exceptions.exceptions import ( + AWSAccessKeyIDInvalidError, AWSArgumentTypeValidationError, AWSIAMRoleARNInvalidResourceTypeError, + AWSInvalidBoto3TimeoutError, AWSInvalidPartitionError, AWSInvalidProviderIdError, AWSNoCredentialsError, ) from prowler.providers.aws.lib.arn.models import ARN from prowler.providers.aws.lib.mutelist.mutelist import AWSMutelist +from prowler.providers.aws.lib.session.aws_set_up_session import AwsSetUpSession from prowler.providers.aws.models import ( AWSAssumeRoleInfo, AWSCallerIdentity, @@ -49,6 +62,7 @@ from tests.providers.aws.utils import ( AWS_EUSC_PARTITION, AWS_GOV_CLOUD_ACCOUNT_ARN, AWS_GOV_CLOUD_PARTITION, + AWS_ISO_B_PARTITION, AWS_ISO_PARTITION, AWS_REGION_CN_NORTH_1, AWS_REGION_CN_NORTHWEST_1, @@ -56,7 +70,10 @@ from tests.providers.aws.utils import ( AWS_REGION_EU_WEST_1, AWS_REGION_EUSC_DE_EAST_1, AWS_REGION_GOV_CLOUD_US_EAST_1, - AWS_REGION_ISO_GLOBAL, + AWS_REGION_GOV_CLOUD_US_WEST_1, + AWS_REGION_ISO_B_EAST_1, + AWS_REGION_ISO_EAST_1, + AWS_REGION_ISO_WEST_1, AWS_REGION_US_EAST_1, AWS_REGION_US_EAST_2, EXAMPLE_AMI_ID, @@ -1181,6 +1198,13 @@ aws: == AWS_REGION_EU_WEST_1 ) + @mock_aws + def test_aws_get_global_region(self): + aws_provider = AwsProvider() + aws_provider._identity.partition = AWS_COMMERCIAL_PARTITION + + assert aws_provider.get_global_region() == AWS_REGION_US_EAST_1 + @mock_aws def test_aws_gov_get_global_region(self): aws_provider = AwsProvider() @@ -1200,7 +1224,21 @@ aws: aws_provider = AwsProvider() aws_provider._identity.partition = AWS_ISO_PARTITION - assert aws_provider.get_global_region() == AWS_REGION_ISO_GLOBAL + assert aws_provider.get_global_region() == AWS_REGION_ISO_EAST_1 + + @mock_aws + def test_aws_iso_b_get_global_region(self): + aws_provider = AwsProvider() + aws_provider._identity.partition = AWS_ISO_B_PARTITION + + assert aws_provider.get_global_region() == AWS_REGION_ISO_B_EAST_1 + + @mock_aws + def test_get_global_region_for_an_unknown_partition(self): + aws_provider = AwsProvider() + aws_provider._identity.partition = "aws-unknown" + + assert aws_provider.get_global_region() == AWS_REGION_US_EAST_1 @mock_aws def test_aws_eusc_get_global_region(self): @@ -1288,6 +1326,88 @@ aws: len(aws_provider.get_available_aws_service_regions("ec2", "aws")) == 17 ) + @mock_aws + def test_get_available_aws_service_regions_commercial_and_gov_cloud(self): + aws_provider = AwsProvider() + + assert AWS_REGION_US_EAST_1 in aws_provider.get_available_aws_service_regions( + "ec2", AWS_COMMERCIAL_PARTITION + ) + assert ( + AWS_REGION_GOV_CLOUD_US_EAST_1 + in aws_provider.get_available_aws_service_regions( + "ec2", AWS_GOV_CLOUD_PARTITION + ) + ) + # A service recorded as unavailable in the partition yields an empty set + assert ( + aws_provider.get_available_aws_service_regions( + "bedrock-agent", AWS_CHINA_PARTITION + ) + == set() + ) + + @mock_aws + def test_get_available_aws_service_regions_iso_partitions(self): + aws_provider = AwsProvider() + + assert aws_provider.get_available_aws_service_regions( + "ec2", AWS_ISO_PARTITION + ) == { + AWS_REGION_ISO_EAST_1, + AWS_REGION_ISO_WEST_1, + } + assert aws_provider.get_available_aws_service_regions( + "guardduty", AWS_ISO_B_PARTITION + ) == {AWS_REGION_ISO_B_EAST_1} + # Every service carries every ISO partition, empty when not available + assert ( + aws_provider.get_available_aws_service_regions( + "bedrock", AWS_ISO_B_PARTITION + ) + == set() + ) + + @mock_aws + def test_get_available_aws_service_regions_unknown_partition(self): + aws_provider = AwsProvider() + + assert ( + aws_provider.get_available_aws_service_regions("ec2", "aws-unknown") + == set() + ) + + @mock_aws + def test_get_available_aws_service_regions_unknown_service(self): + aws_provider = AwsProvider() + + assert ( + aws_provider.get_available_aws_service_regions( + "unknown-service", AWS_COMMERCIAL_PARTITION + ) + == set() + ) + + @mock_aws + def test_generate_regional_clients_service_not_in_partition(self): + aws_provider = AwsProvider() + aws_provider._identity.partition = AWS_ISO_PARTITION + + response = aws_provider.generate_regional_clients("bedrock") + + assert response == {} + + @mock_aws + def test_generate_regional_clients_returns_empty_dict_on_error(self): + aws_provider = AwsProvider() + + with patch.object( + AwsProvider, + "get_available_aws_service_regions", + side_effect=Exception("boom"), + ): + assert aws_provider.generate_regional_clients("ec2") == {} + @mock_aws def test_get_tagged_resources(self): ec2_client = client("ec2", region_name=AWS_REGION_EU_CENTRAL_1) @@ -1466,6 +1586,411 @@ aws: assert get_caller_identity.arn.resource == "test-user" assert get_caller_identity.arn.resource_type == "user" + def test_get_partition_bootstrap_candidates_adds_the_rest_of_the_partition( + self, monkeypatch + ): + monkeypatch.setenv("PROWLER_AWS_PARTITION", AWS_GOV_CLOUD_PARTITION) + + assert get_partition_bootstrap_candidates( + AWS_REGION_GOV_CLOUD_US_EAST_1, AWS_REGION_US_EAST_1 + ) == [AWS_REGION_GOV_CLOUD_US_EAST_1, AWS_REGION_GOV_CLOUD_US_WEST_1] + + def test_get_partition_bootstrap_candidates_without_partition_offers_one_region( + self, monkeypatch + ): + monkeypatch.delenv("PROWLER_AWS_PARTITION", raising=False) + + assert get_partition_bootstrap_candidates(AWS_REGION_EU_WEST_1) == [ + AWS_REGION_EU_WEST_1 + ] + + def test_get_partition_bootstrap_candidates_is_capped(self, monkeypatch): + monkeypatch.setenv("PROWLER_AWS_PARTITION", AWS_COMMERCIAL_PARTITION) + + candidates = get_partition_bootstrap_candidates( + AWS_REGION_EU_WEST_1, AWS_REGION_EU_WEST_1 + ) + + assert len(candidates) == MAX_STS_BOOTSTRAP_ATTEMPTS + assert candidates[0] == AWS_REGION_EU_WEST_1 + + def test_get_partition_bootstrap_candidates_tries_excluded_regions_last( + self, monkeypatch + ): + monkeypatch.setenv("PROWLER_AWS_PARTITION", AWS_COMMERCIAL_PARTITION) + partition_regions = get_env_partition_regions(AWS_REGION_EU_WEST_1) + excluded_regions = set(partition_regions[1:3]) + + candidates = get_partition_bootstrap_candidates( + AWS_REGION_EU_WEST_1, AWS_REGION_EU_WEST_1, excluded_regions + ) + + assert candidates == [AWS_REGION_EU_WEST_1, *partition_regions[3:5]] + + def test_get_partition_bootstrap_candidates_keeps_excluded_regions_as_a_last_resort( + self, monkeypatch + ): + monkeypatch.setenv("PROWLER_AWS_PARTITION", AWS_GOV_CLOUD_PARTITION) + + assert get_partition_bootstrap_candidates( + AWS_REGION_GOV_CLOUD_US_EAST_1, + AWS_REGION_US_EAST_1, + {AWS_REGION_GOV_CLOUD_US_EAST_1, AWS_REGION_GOV_CLOUD_US_WEST_1}, + ) == [AWS_REGION_GOV_CLOUD_US_EAST_1, AWS_REGION_GOV_CLOUD_US_WEST_1] + + def test_validate_credentials_falls_back_to_the_next_partition_region( + self, monkeypatch + ): + monkeypatch.setenv("PROWLER_AWS_PARTITION", AWS_GOV_CLOUD_PARTITION) + # A container may carry a region that belongs to no partition it scans + current_session = session.Session(region_name=AWS_REGION_US_EAST_1) + attempted_regions = [] + + def create_sts_session(session, aws_region): + attempted_regions.append(aws_region) + if aws_region == AWS_REGION_GOV_CLOUD_US_EAST_1: + raise botocore.exceptions.EndpointConnectionError( + endpoint_url=f"https://sts.{aws_region}.amazonaws.com" + ) + sts_client = mock.MagicMock() + sts_client.get_caller_identity.return_value = { + "UserId": "test-user-id", + "Account": AWS_ACCOUNT_NUMBER, + "Arn": AWS_GOV_CLOUD_ACCOUNT_ARN, + } + return sts_client + + with patch( + "prowler.providers.aws.aws_provider.AwsProvider.create_sts_session", + new=create_sts_session, + ): + caller_identity = AwsProvider.validate_credentials( + session=current_session, aws_region=AWS_REGION_GOV_CLOUD_US_EAST_1 + ) + + assert attempted_regions == [ + AWS_REGION_GOV_CLOUD_US_EAST_1, + AWS_REGION_GOV_CLOUD_US_WEST_1, + ] + assert caller_identity.region == AWS_REGION_GOV_CLOUD_US_WEST_1 + + def test_validate_credentials_falls_back_when_a_region_does_not_answer( + self, monkeypatch + ): + monkeypatch.setenv("PROWLER_AWS_PARTITION", AWS_GOV_CLOUD_PARTITION) + current_session = session.Session(region_name=AWS_REGION_US_EAST_1) + attempted_regions = [] + + # The connection is accepted but nothing comes back before the read timeout + def create_sts_session(session, aws_region): + attempted_regions.append(aws_region) + if aws_region == AWS_REGION_GOV_CLOUD_US_EAST_1: + raise botocore.exceptions.ReadTimeoutError( + endpoint_url=f"https://sts.{aws_region}.amazonaws.com" + ) + sts_client = mock.MagicMock() + sts_client.get_caller_identity.return_value = { + "UserId": "test-user-id", + "Account": AWS_ACCOUNT_NUMBER, + "Arn": AWS_GOV_CLOUD_ACCOUNT_ARN, + } + return sts_client + + with patch( + "prowler.providers.aws.aws_provider.AwsProvider.create_sts_session", + new=create_sts_session, + ): + caller_identity = AwsProvider.validate_credentials( + session=current_session, aws_region=AWS_REGION_GOV_CLOUD_US_EAST_1 + ) + + assert attempted_regions == [ + AWS_REGION_GOV_CLOUD_US_EAST_1, + AWS_REGION_GOV_CLOUD_US_WEST_1, + ] + assert caller_identity.region == AWS_REGION_GOV_CLOUD_US_WEST_1 + + def test_validate_credentials_raises_when_no_partition_region_answers( + self, monkeypatch + ): + monkeypatch.setenv("PROWLER_AWS_PARTITION", AWS_GOV_CLOUD_PARTITION) + current_session = session.Session(region_name=AWS_REGION_US_EAST_1) + attempted_regions = [] + + def create_sts_session(session, aws_region): + attempted_regions.append(aws_region) + raise botocore.exceptions.EndpointConnectionError( + endpoint_url=f"https://sts.{aws_region}.amazonaws.com" + ) + + with patch( + "prowler.providers.aws.aws_provider.AwsProvider.create_sts_session", + new=create_sts_session, + ): + with raises(botocore.exceptions.EndpointConnectionError): + AwsProvider.validate_credentials( + session=current_session, aws_region=AWS_REGION_GOV_CLOUD_US_EAST_1 + ) + + assert attempted_regions == [ + AWS_REGION_GOV_CLOUD_US_EAST_1, + AWS_REGION_GOV_CLOUD_US_WEST_1, + ] + + def test_validate_credentials_avoids_an_excluded_region_when_failing_over( + self, monkeypatch + ): + monkeypatch.setenv("PROWLER_AWS_PARTITION", AWS_COMMERCIAL_PARTITION) + current_session = session.Session(region_name=AWS_REGION_EU_WEST_1) + partition_regions = get_env_partition_regions(AWS_REGION_EU_WEST_1) + excluded_region, answering_region = partition_regions[1:3] + attempted_regions = [] + + def create_sts_session(session, aws_region): + attempted_regions.append(aws_region) + if aws_region == AWS_REGION_EU_WEST_1: + raise botocore.exceptions.EndpointConnectionError( + endpoint_url=f"https://sts.{aws_region}.amazonaws.com" + ) + sts_client = mock.MagicMock() + sts_client.get_caller_identity.return_value = { + "UserId": "test-user-id", + "Account": AWS_ACCOUNT_NUMBER, + "Arn": AWS_ACCOUNT_ARN, + } + return sts_client + + with patch( + "prowler.providers.aws.aws_provider.AwsProvider.create_sts_session", + new=create_sts_session, + ): + caller_identity = AwsProvider.validate_credentials( + session=current_session, + aws_region=AWS_REGION_EU_WEST_1, + excluded_regions={excluded_region}, + ) + + assert attempted_regions == [AWS_REGION_EU_WEST_1, answering_region] + assert caller_identity.region == answering_region + + def test_validate_credentials_does_not_retry_a_credential_error(self, monkeypatch): + monkeypatch.setenv("PROWLER_AWS_PARTITION", AWS_GOV_CLOUD_PARTITION) + current_session = session.Session(region_name=AWS_REGION_US_EAST_1) + attempted_regions = [] + + def create_sts_session(session, aws_region): + attempted_regions.append(aws_region) + sts_client = mock.MagicMock() + sts_client.get_caller_identity.side_effect = ( + botocore.exceptions.ClientError( + {"Error": {"Code": "InvalidClientTokenId", "Message": "invalid"}}, + "GetCallerIdentity", + ) + ) + return sts_client + + with patch( + "prowler.providers.aws.aws_provider.AwsProvider.create_sts_session", + new=create_sts_session, + ): + with raises(AWSAccessKeyIDInvalidError): + AwsProvider.validate_credentials( + session=current_session, aws_region=AWS_REGION_GOV_CLOUD_US_EAST_1 + ) + + assert attempted_regions == [AWS_REGION_GOV_CLOUD_US_EAST_1] + + def test_assume_role_falls_back_to_the_next_partition_region(self, monkeypatch): + monkeypatch.setenv("PROWLER_AWS_PARTITION", AWS_GOV_CLOUD_PARTITION) + current_session = session.Session(region_name=AWS_REGION_US_EAST_1) + attempted_regions = [] + + def create_sts_session(session, aws_region): + attempted_regions.append(aws_region) + if aws_region == AWS_REGION_GOV_CLOUD_US_EAST_1: + raise botocore.exceptions.EndpointConnectionError( + endpoint_url=f"https://sts.{aws_region}.amazonaws.com" + ) + sts_client = mock.MagicMock() + sts_client.assume_role.return_value = { + "Credentials": { + "AccessKeyId": "AKIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "secret", + "SessionToken": "token", + "Expiration": datetime.now() + timedelta(seconds=3600), + } + } + return sts_client + + assumed_role_info = AWSAssumeRoleInfo( + role_arn=ARN( + arn=f"arn:{AWS_GOV_CLOUD_PARTITION}:iam::{AWS_ACCOUNT_NUMBER}:role/test-role" + ), + session_duration=3600, + external_id=None, + mfa_enabled=False, + role_session_name=ROLE_SESSION_NAME, + sts_region=AWS_REGION_GOV_CLOUD_US_EAST_1, + ) + + with patch( + "prowler.providers.aws.aws_provider.AwsProvider.create_sts_session", + new=create_sts_session, + ): + credentials = AwsProvider.assume_role(current_session, assumed_role_info) + + assert attempted_regions == [ + AWS_REGION_GOV_CLOUD_US_EAST_1, + AWS_REGION_GOV_CLOUD_US_WEST_1, + ] + assert isinstance(credentials, AWSCredentials) + assert credentials.aws_access_key_id == "AKIAIOSFODNN7EXAMPLE" + + def test_setup_session_mfa_falls_back_to_the_next_partition_region( + self, monkeypatch + ): + monkeypatch.setenv("PROWLER_AWS_PARTITION", AWS_GOV_CLOUD_PARTITION) + monkeypatch.setenv("AWS_DEFAULT_REGION", AWS_REGION_US_EAST_1) + attempted_regions = [] + + def create_sts_session(session, aws_region): + attempted_regions.append(aws_region) + if aws_region == AWS_REGION_GOV_CLOUD_US_EAST_1: + raise botocore.exceptions.EndpointConnectionError( + endpoint_url=f"https://sts.{aws_region}.amazonaws.com" + ) + sts_client = mock.MagicMock() + sts_client.get_session_token.return_value = { + "Credentials": { + "AccessKeyId": "AKIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "secret", + "SessionToken": "token", + } + } + return sts_client + + with ( + patch( + "prowler.providers.aws.aws_provider.AwsProvider.input_role_mfa_token_and_code", + return_value=AWSMFAInfo( + arn=f"arn:{AWS_GOV_CLOUD_PARTITION}:iam::{AWS_ACCOUNT_NUMBER}:mfa/test", + totp="123456", + ), + ), + patch( + "prowler.providers.aws.aws_provider.AwsProvider.create_sts_session", + new=create_sts_session, + ), + ): + mfa_session = AwsProvider.setup_session( + mfa=True, + aws_access_key_id="test-access-key", + aws_secret_access_key="test-secret-key", + ) + + assert attempted_regions == [ + AWS_REGION_GOV_CLOUD_US_EAST_1, + AWS_REGION_GOV_CLOUD_US_WEST_1, + ] + assert mfa_session.get_credentials().access_key == "AKIAIOSFODNN7EXAMPLE" + assert mfa_session.get_credentials().token == "token" + + @mock_aws + def test_aws_provider_hands_excluded_regions_to_credential_validation( + self, monkeypatch + ): + monkeypatch.setenv("PROWLER_AWS_PARTITION", AWS_GOV_CLOUD_PARTITION) + monkeypatch.setenv("AWS_DEFAULT_REGION", AWS_REGION_US_EAST_1) + handed = [] + + class Validated(Exception): + pass + + # Stops at the validation: what it was handed is all this checks + def validate_credentials(session, aws_region, excluded_regions=None): + handed.append((aws_region, set(excluded_regions or ()))) + raise Validated + + with patch( + "prowler.providers.aws.aws_provider.AwsProvider.validate_credentials", + side_effect=validate_credentials, + ): + with raises(Validated): + AwsProvider(excluded_regions={AWS_REGION_GOV_CLOUD_US_EAST_1}) + + assert handed == [ + (AWS_REGION_GOV_CLOUD_US_WEST_1, {AWS_REGION_GOV_CLOUD_US_EAST_1}) + ] + + @mock_aws + def test_aws_provider_assumes_the_role_where_validation_got_an_answer( + self, monkeypatch + ): + monkeypatch.setenv("PROWLER_AWS_PARTITION", AWS_GOV_CLOUD_PARTITION) + # Out of the partition, so the first candidate is botocore's, not this one + monkeypatch.setenv("AWS_DEFAULT_REGION", AWS_REGION_US_EAST_1) + role_arn = ( + f"arn:{AWS_GOV_CLOUD_PARTITION}:iam::{AWS_ACCOUNT_NUMBER}:role/test-role" + ) + answered = AWSCallerIdentity( + user_id="test-user-id", + account=AWS_ACCOUNT_NUMBER, + arn=ARN(AWS_GOV_CLOUD_ACCOUNT_ARN), + region=AWS_REGION_GOV_CLOUD_US_WEST_1, + ) + + with patch( + "prowler.providers.aws.aws_provider.AwsProvider.validate_credentials", + return_value=answered, + ): + aws_provider = AwsProvider(role_arn=role_arn, session_duration=900) + + assert ( + aws_provider._assumed_role_configuration.info.sts_region + == AWS_REGION_GOV_CLOUD_US_WEST_1 + ) + + @mock_aws + def test_aws_provider_assumes_the_organizations_role_where_validation_got_an_answer( + self, monkeypatch + ): + monkeypatch.setenv("PROWLER_AWS_PARTITION", AWS_GOV_CLOUD_PARTITION) + monkeypatch.setenv("AWS_DEFAULT_REGION", AWS_REGION_US_EAST_1) + organizations_role_arn = f"arn:{AWS_GOV_CLOUD_PARTITION}:iam::{AWS_ACCOUNT_NUMBER}:role/organizations-role" + answered = AWSCallerIdentity( + user_id="test-user-id", + account=AWS_ACCOUNT_NUMBER, + arn=ARN(AWS_GOV_CLOUD_ACCOUNT_ARN), + region=AWS_REGION_GOV_CLOUD_US_WEST_1, + ) + sts_regions = [] + + class RoleAssumed(Exception): + pass + + # Stops at the assumption: the region it was handed is all this checks + def assume_role(session, assumed_role_info): + sts_regions.append(assumed_role_info.sts_region) + raise RoleAssumed + + with ( + patch( + "prowler.providers.aws.aws_provider.AwsProvider.validate_credentials", + return_value=answered, + ), + patch( + "prowler.providers.aws.aws_provider.AwsProvider.assume_role", + side_effect=assume_role, + ), + ): + with raises(RoleAssumed): + AwsProvider( + organizations_role_arn=organizations_role_arn, + session_duration=900, + ) + + assert sts_regions == [AWS_REGION_GOV_CLOUD_US_WEST_1] + @mock_aws def test_test_connection_with_env_credentials(self, monkeypatch): # Create a mock IAM user @@ -1783,6 +2308,29 @@ aws: assert sts_session._endpoint._endpoint_prefix == "sts" assert sts_session._endpoint.host == f"https://sts.{aws_region}.amazonaws.eu" + @mock_aws + def test_create_sts_session_empty_endpoint_url(self): + current_session = session.Session() + aws_region = AWS_REGION_US_EAST_1 + with mock.patch.dict(os.environ, {"AWS_ENDPOINT_URL": ""}): + sts_session = AwsProvider.create_sts_session(current_session, aws_region) + + assert sts_session._service_model.service_name == "sts" + assert sts_session._client_config.region_name == aws_region + assert sts_session._endpoint._endpoint_prefix == "sts" + assert sts_session._endpoint.host == f"https://sts.{aws_region}.amazonaws.com" + + @mock_aws + def test_create_sts_session_iso(self): + current_session = session.Session() + aws_region = "us-iso-east-1" + sts_session = AwsProvider.create_sts_session(current_session, aws_region) + + assert sts_session._service_model.service_name == "sts" + assert sts_session._client_config.region_name == aws_region + assert sts_session._endpoint._endpoint_prefix == "sts" + assert sts_session._endpoint.host == f"https://sts.{aws_region}.c2s.ic.gov" + @mock_aws @patch( "prowler.lib.check.utils.recover_checks_from_provider", @@ -2031,7 +2579,8 @@ aws: assert not recovered_regions def test_get_regions_all_count(self): - assert len(AwsProvider.get_regions(partition=None)) == 39 + # 34 aws + 2 aws-cn + 2 aws-us-gov + 1 aws-eusc + 7 ISO regions + assert len(AwsProvider.get_regions(partition=None)) == 46 def test_get_regions_cn_count(self): assert len(AwsProvider.get_regions("aws-cn")) == 2 @@ -2039,6 +2588,12 @@ aws: def test_get_regions_aws_count(self): assert len(AwsProvider.get_regions(partition="aws")) == 34 + def test_get_regions_iso_count(self): + assert AwsProvider.get_regions(AWS_ISO_PARTITION) == { + AWS_REGION_ISO_EAST_1, + AWS_REGION_ISO_WEST_1, + } + def test_get_all_regions(self): with patch( "prowler.providers.aws.aws_provider.read_aws_regions_file", @@ -2219,6 +2774,478 @@ aws: == AWS_REGION_US_EAST_1 ) + def test_get_aws_region_for_sts_env_partition_gov_cloud(self): + with mock.patch.dict( + os.environ, + {"PROWLER_AWS_PARTITION": AWS_GOV_CLOUD_PARTITION}, + clear=False, + ): + assert get_aws_region_for_sts(None, None) == AWS_REGION_GOV_CLOUD_US_EAST_1 + + def test_get_aws_region_for_sts_env_partition_china(self): + with mock.patch.dict( + os.environ, + {"PROWLER_AWS_PARTITION": AWS_CHINA_PARTITION}, + clear=False, + ): + assert get_aws_region_for_sts(None, None) == AWS_REGION_CN_NORTH_1 + + def test_get_aws_region_for_sts_env_partition_eusc(self): + with mock.patch.dict( + os.environ, + {"PROWLER_AWS_PARTITION": AWS_EUSC_PARTITION}, + clear=False, + ): + assert get_aws_region_for_sts(None, None) == AWS_REGION_EUSC_DE_EAST_1 + + def test_get_aws_region_for_sts_env_partition_iso(self): + with mock.patch.dict( + os.environ, + {"PROWLER_AWS_PARTITION": AWS_ISO_PARTITION}, + clear=False, + ): + assert get_aws_region_for_sts(None, None) == "us-iso-east-1" + + def test_get_aws_region_for_sts_env_partition_overrides_session_region(self): + with mock.patch.dict( + os.environ, + {"PROWLER_AWS_PARTITION": AWS_GOV_CLOUD_PARTITION}, + clear=False, + ): + assert ( + get_aws_region_for_sts(AWS_REGION_EU_WEST_1, None) + == AWS_REGION_GOV_CLOUD_US_EAST_1 + ) + + def test_get_aws_region_for_sts_input_regions_take_precedence_over_env_partition( + self, + ): + with mock.patch.dict( + os.environ, + {"PROWLER_AWS_PARTITION": AWS_GOV_CLOUD_PARTITION}, + clear=False, + ): + assert ( + get_aws_region_for_sts(None, {AWS_REGION_EU_WEST_1}) + == AWS_REGION_EU_WEST_1 + ) + + def test_get_aws_region_for_sts_env_partition_invalid_raises(self): + with mock.patch.dict( + os.environ, + {"PROWLER_AWS_PARTITION": "aws-invalid"}, + clear=False, + ): + with pytest.raises(AWSInvalidPartitionError): + get_aws_region_for_sts(None, None) + + @mock_aws + def test_test_connection_uses_env_partition_sts_region(self): + with ( + mock.patch.dict( + os.environ, + {"PROWLER_AWS_PARTITION": AWS_GOV_CLOUD_PARTITION}, + clear=False, + ), + mock.patch.object( + AwsProvider, + "validate_credentials", + return_value=AWSCallerIdentity( + user_id="test-user-id", + account=AWS_ACCOUNT_NUMBER, + arn=ARN(AWS_GOV_CLOUD_ACCOUNT_ARN), + region=AWS_REGION_GOV_CLOUD_US_EAST_1, + ), + ) as mock_validate_credentials, + ): + connection = AwsProvider.test_connection( + aws_access_key_id="test-access-key", + aws_secret_access_key="test-secret-key", + raise_on_exception=False, + ) + + assert connection.is_connected + assert ( + mock_validate_credentials.call_args.args[1] + == AWS_REGION_GOV_CLOUD_US_EAST_1 + ) + + @mock_aws + def test_test_connection_role_uses_env_partition_sts_region(self): + with ( + mock.patch.dict( + os.environ, + {"PROWLER_AWS_PARTITION": AWS_GOV_CLOUD_PARTITION}, + clear=False, + ), + mock.patch.object( + AwsProvider, + "assume_role", + return_value=AWSCredentials( + aws_access_key_id="assumed-access-key", + aws_secret_access_key="assumed-secret-key", + aws_session_token="assumed-session-token", + expiration=datetime.now(), + ), + ) as mock_assume_role, + mock.patch.object( + AwsProvider, + "validate_credentials", + return_value=AWSCallerIdentity( + user_id="test-user-id", + account=AWS_ACCOUNT_NUMBER, + arn=ARN(AWS_GOV_CLOUD_ACCOUNT_ARN), + region=AWS_REGION_GOV_CLOUD_US_EAST_1, + ), + ), + ): + connection = AwsProvider.test_connection( + role_arn=f"arn:{AWS_GOV_CLOUD_PARTITION}:iam::{AWS_ACCOUNT_NUMBER}:role/test-role", + aws_access_key_id="test-access-key", + aws_secret_access_key="test-secret-key", + raise_on_exception=False, + ) + + assert connection.is_connected + assumed_role_info = mock_assume_role.call_args.args[1] + assert assumed_role_info.sts_region == AWS_REGION_GOV_CLOUD_US_EAST_1 + + def test_get_aws_region_for_sts_env_partition_commercial(self): + with mock.patch.dict( + os.environ, + {"PROWLER_AWS_PARTITION": AWS_COMMERCIAL_PARTITION}, + clear=False, + ): + assert get_aws_region_for_sts(None, None) == AWS_REGION_US_EAST_1 + + def test_get_aws_region_for_sts_env_partition_excluded_region_stays_in_partition( + self, + ): + with mock.patch.dict( + os.environ, + {"PROWLER_AWS_PARTITION": AWS_GOV_CLOUD_PARTITION}, + clear=False, + ): + assert ( + get_aws_region_for_sts(None, None, {AWS_REGION_GOV_CLOUD_US_EAST_1}) + == "us-gov-west-1" + ) + + def test_get_aws_region_for_sts_env_partition_all_regions_excluded_stays_in_partition( + self, + ): + with mock.patch.dict( + os.environ, + {"PROWLER_AWS_PARTITION": AWS_GOV_CLOUD_PARTITION}, + clear=False, + ): + assert ( + get_aws_region_for_sts( + None, None, {AWS_REGION_GOV_CLOUD_US_EAST_1, "us-gov-west-1"} + ) + == AWS_REGION_GOV_CLOUD_US_EAST_1 + ) + + @mock_aws + def test_setup_session_mfa_uses_env_partition_sts_region(self): + with ( + mock.patch.dict( + os.environ, + {"PROWLER_AWS_PARTITION": AWS_GOV_CLOUD_PARTITION}, + clear=False, + ), + mock.patch.object( + AwsProvider, + "input_role_mfa_token_and_code", + return_value=AWSMFAInfo( + arn=f"arn:{AWS_GOV_CLOUD_PARTITION}:iam::{AWS_ACCOUNT_NUMBER}:mfa/test", + totp="123456", + ), + ), + mock.patch.object( + AwsProvider, + "create_sts_session", + side_effect=AwsProvider.create_sts_session, + ) as mock_create_sts_session, + ): + AwsProvider.setup_session( + mfa=True, + aws_access_key_id="test-access-key", + aws_secret_access_key="test-secret-key", + ) + + assert ( + mock_create_sts_session.call_args.args[1] + == AWS_REGION_GOV_CLOUD_US_EAST_1 + ) + + def test_get_env_partition_regions_leads_with_session_region(self): + with mock.patch.dict( + os.environ, + {"PROWLER_AWS_PARTITION": AWS_GOV_CLOUD_PARTITION}, + clear=False, + ): + regions = get_env_partition_regions(AWS_REGION_GOV_CLOUD_US_WEST_1) + + assert regions[0] == AWS_REGION_GOV_CLOUD_US_WEST_1 + assert set(regions) == set(get_env_partition_regions()) + + def test_get_env_partition_regions_ignores_session_region_outside_partition( + self, + ): + with mock.patch.dict( + os.environ, + {"PROWLER_AWS_PARTITION": AWS_GOV_CLOUD_PARTITION}, + clear=False, + ): + regions = get_env_partition_regions(AWS_REGION_EU_WEST_1) + + assert regions[0] == AWS_REGION_GOV_CLOUD_US_EAST_1 + assert AWS_REGION_EU_WEST_1 not in regions + + def test_get_env_partition_bootstrap_region_prefers_session_region(self): + with mock.patch.dict( + os.environ, + {"PROWLER_AWS_PARTITION": AWS_GOV_CLOUD_PARTITION}, + clear=False, + ): + assert ( + get_env_partition_bootstrap_region(AWS_REGION_GOV_CLOUD_US_WEST_1) + == AWS_REGION_GOV_CLOUD_US_WEST_1 + ) + + def test_get_env_partition_bootstrap_region_without_session_region(self): + with mock.patch.dict( + os.environ, + {"PROWLER_AWS_PARTITION": AWS_GOV_CLOUD_PARTITION}, + clear=False, + ): + assert ( + get_env_partition_bootstrap_region() == AWS_REGION_GOV_CLOUD_US_EAST_1 + ) + + def test_get_env_partition_bootstrap_region_without_partition(self): + with mock.patch.dict(os.environ, {"PROWLER_AWS_PARTITION": ""}, clear=False): + assert ( + get_env_partition_bootstrap_region(AWS_REGION_GOV_CLOUD_US_WEST_1) + is None + ) + + def test_get_aws_region_for_sts_env_partition_prefers_session_region(self): + with mock.patch.dict( + os.environ, + {"PROWLER_AWS_PARTITION": AWS_GOV_CLOUD_PARTITION}, + clear=False, + ): + assert ( + get_aws_region_for_sts(AWS_REGION_GOV_CLOUD_US_WEST_1, None) + == AWS_REGION_GOV_CLOUD_US_WEST_1 + ) + + def test_get_profile_region_env_partition_keeps_session_region_inside_partition( + self, + ): + with mock.patch.dict( + os.environ, + {"PROWLER_AWS_PARTITION": AWS_GOV_CLOUD_PARTITION}, + clear=False, + ): + aws_session = session.Session(region_name=AWS_REGION_GOV_CLOUD_US_WEST_1) + + assert ( + AwsProvider.get_profile_region(aws_session) + == AWS_REGION_GOV_CLOUD_US_WEST_1 + ) + + def test_get_profile_region_env_partition_ignores_session_region_outside_partition( + self, + ): + with mock.patch.dict( + os.environ, + {"PROWLER_AWS_PARTITION": AWS_GOV_CLOUD_PARTITION}, + clear=False, + ): + aws_session = session.Session(region_name=AWS_REGION_US_EAST_1) + + assert ( + AwsProvider.get_profile_region(aws_session) + == AWS_REGION_GOV_CLOUD_US_EAST_1 + ) + + def test_get_profile_region_env_partition_excluded_session_region_stays_in_partition( + self, + ): + with mock.patch.dict( + os.environ, + {"PROWLER_AWS_PARTITION": AWS_GOV_CLOUD_PARTITION}, + clear=False, + ): + aws_session = session.Session(region_name=AWS_REGION_GOV_CLOUD_US_WEST_1) + + assert ( + AwsProvider.get_profile_region( + aws_session, {AWS_REGION_GOV_CLOUD_US_WEST_1} + ) + == AWS_REGION_GOV_CLOUD_US_EAST_1 + ) + + def test_get_profile_region_env_partition_all_regions_excluded_stays_in_partition( + self, + ): + with mock.patch.dict( + os.environ, + {"PROWLER_AWS_PARTITION": AWS_GOV_CLOUD_PARTITION}, + clear=False, + ): + aws_session = session.Session(region_name=AWS_REGION_GOV_CLOUD_US_WEST_1) + gov_cloud_regions = set(get_env_partition_regions()) + + assert ( + AwsProvider.get_profile_region(aws_session, gov_cloud_regions) + == AWS_REGION_GOV_CLOUD_US_WEST_1 + ) + + @mock_aws + def test_test_connection_env_partition_prefers_session_region(self): + with ( + mock.patch.dict( + os.environ, + { + "PROWLER_AWS_PARTITION": AWS_GOV_CLOUD_PARTITION, + "AWS_DEFAULT_REGION": AWS_REGION_GOV_CLOUD_US_WEST_1, + }, + clear=False, + ), + mock.patch.object( + AwsProvider, + "validate_credentials", + return_value=AWSCallerIdentity( + user_id="test-user-id", + account=AWS_ACCOUNT_NUMBER, + arn=ARN(AWS_GOV_CLOUD_ACCOUNT_ARN), + region=AWS_REGION_GOV_CLOUD_US_WEST_1, + ), + ) as mock_validate_credentials, + ): + connection = AwsProvider.test_connection( + aws_access_key_id="test-access-key", + aws_secret_access_key="test-secret-key", + raise_on_exception=False, + ) + + assert connection.is_connected + assert ( + mock_validate_credentials.call_args.args[1] + == AWS_REGION_GOV_CLOUD_US_WEST_1 + ) + + @mock_aws + def test_test_connection_role_env_partition_prefers_session_region(self): + with ( + mock.patch.dict( + os.environ, + { + "PROWLER_AWS_PARTITION": AWS_GOV_CLOUD_PARTITION, + "AWS_DEFAULT_REGION": AWS_REGION_GOV_CLOUD_US_WEST_1, + }, + clear=False, + ), + mock.patch.object( + AwsProvider, + "assume_role", + return_value=AWSCredentials( + aws_access_key_id="assumed-access-key", + aws_secret_access_key="assumed-secret-key", + aws_session_token="assumed-session-token", + expiration=datetime.now(), + ), + ) as mock_assume_role, + mock.patch.object( + AwsProvider, + "validate_credentials", + return_value=AWSCallerIdentity( + user_id="test-user-id", + account=AWS_ACCOUNT_NUMBER, + arn=ARN(AWS_GOV_CLOUD_ACCOUNT_ARN), + region=AWS_REGION_GOV_CLOUD_US_WEST_1, + ), + ), + ): + connection = AwsProvider.test_connection( + role_arn=f"arn:{AWS_GOV_CLOUD_PARTITION}:iam::{AWS_ACCOUNT_NUMBER}:role/test-role", + aws_access_key_id="test-access-key", + aws_secret_access_key="test-secret-key", + raise_on_exception=False, + ) + + assert connection.is_connected + assumed_role_info = mock_assume_role.call_args.args[1] + assert assumed_role_info.sts_region == AWS_REGION_GOV_CLOUD_US_WEST_1 + + @mock_aws + def test_setup_session_mfa_env_partition_prefers_session_region(self): + with ( + mock.patch.dict( + os.environ, + { + "PROWLER_AWS_PARTITION": AWS_GOV_CLOUD_PARTITION, + "AWS_DEFAULT_REGION": AWS_REGION_GOV_CLOUD_US_WEST_1, + }, + clear=False, + ), + mock.patch.object( + AwsProvider, + "input_role_mfa_token_and_code", + return_value=AWSMFAInfo( + arn=f"arn:{AWS_GOV_CLOUD_PARTITION}:iam::{AWS_ACCOUNT_NUMBER}:mfa/test", + totp="123456", + ), + ), + mock.patch.object( + AwsProvider, + "create_sts_session", + side_effect=AwsProvider.create_sts_session, + ) as mock_create_sts_session, + ): + AwsProvider.setup_session( + mfa=True, + aws_access_key_id="test-access-key", + aws_secret_access_key="test-secret-key", + ) + + assert ( + mock_create_sts_session.call_args.args[1] + == AWS_REGION_GOV_CLOUD_US_WEST_1 + ) + + @mock_aws + def test_test_connection_env_partition_mismatch(self): + with ( + mock.patch.dict( + os.environ, + {"PROWLER_AWS_PARTITION": AWS_GOV_CLOUD_PARTITION}, + clear=False, + ), + mock.patch.object( + AwsProvider, + "validate_credentials", + return_value=AWSCallerIdentity( + user_id="test-user-id", + account=AWS_ACCOUNT_NUMBER, + arn=ARN(AWS_ACCOUNT_ARN), + region=AWS_REGION_US_EAST_1, + ), + ), + ): + connection = AwsProvider.test_connection( + aws_access_key_id="test-access-key", + aws_secret_access_key="test-secret-key", + raise_on_exception=False, + ) + + assert not connection.is_connected + assert isinstance(connection.error, AWSInvalidPartitionError) + def test_get_profile_region_avoids_excluded_session_region(self): mocked_session = mock.Mock(region_name=AWS_REGION_EU_WEST_1) @@ -2234,6 +3261,8 @@ aws: assert session_config.user_agent_extra == BOTO3_USER_AGENT_EXTRA assert session_config.retries == {"max_attempts": 3, "mode": "standard"} + assert session_config.connect_timeout == BOTO3_CONNECT_TIMEOUT + assert session_config.read_timeout == BOTO3_READ_TIMEOUT @mock_aws def test_set_session_config_10_max_attempts(self): @@ -2242,12 +3271,93 @@ aws: assert session_config.user_agent_extra == BOTO3_USER_AGENT_EXTRA assert session_config.retries == {"max_attempts": 10, "mode": "standard"} + assert session_config.connect_timeout == BOTO3_CONNECT_TIMEOUT + assert session_config.read_timeout == BOTO3_READ_TIMEOUT + + def test_set_session_config_0_max_attempts_disables_retries(self): + session_config = AwsProvider.set_session_config(0) + + assert session_config.retries == {"max_attempts": 0, "mode": "standard"} + + @mock_aws + def test_aws_provider_0_max_attempts_reaches_clients(self): + aws_provider = AwsProvider(retries_max_attempts=0) + client = aws_provider.session.current_session.client( + "ec2", region_name=AWS_REGION_US_EAST_1 + ) + + # botocore rewrites max_attempts into total_max_attempts (retries + 1) + assert client.meta.config.retries["total_max_attempts"] == 1 + + def test_set_session_config_timeouts(self): + session_config = AwsProvider.set_session_config( + None, connect_timeout=2, read_timeout=15 + ) + + assert session_config.retries == {"max_attempts": 3, "mode": "standard"} + assert session_config.connect_timeout == 2 + assert session_config.read_timeout == 15 + + @mock_aws + def test_aws_provider_timeouts_reach_session_config(self): + aws_provider = AwsProvider(connect_timeout=2, read_timeout=15) + + assert aws_provider.session.session_config.connect_timeout == 2 + assert aws_provider.session.session_config.read_timeout == 15 + + @mock_aws + def test_aws_set_up_session_forwards_timeouts(self): + aws_session = AwsSetUpSession( + aws_access_key_id="testing", + aws_secret_access_key="testing", + connect_timeout=2, + read_timeout=15, + ) + + assert aws_session._session.session_config.connect_timeout == 2 + assert aws_session._session.session_config.read_timeout == 15 def test_get_default_session_config(self): config = get_default_session_config() assert config.user_agent_extra == BOTO3_USER_AGENT_EXTRA assert config.retries == {"max_attempts": 3, "mode": "standard"} + assert config.connect_timeout == BOTO3_CONNECT_TIMEOUT + assert config.read_timeout == BOTO3_READ_TIMEOUT + + def test_get_default_session_config_timeouts_from_env(self): + with mock.patch.dict( + os.environ, + { + "PROWLER_AWS_BOTO3_CONNECT_TIMEOUT": "3", + "PROWLER_AWS_BOTO3_READ_TIMEOUT": "20", + }, + ): + config = get_default_session_config() + + assert config.connect_timeout == 3 + assert config.read_timeout == 20 + + def test_set_session_config_argument_overrides_env_timeouts(self): + with mock.patch.dict(os.environ, {"PROWLER_AWS_BOTO3_CONNECT_TIMEOUT": "3"}): + config = AwsProvider.set_session_config(None, connect_timeout=7) + + assert config.connect_timeout == 7 + + @pytest.mark.parametrize("raw", ["0", "-5", "ten", "1.5"]) + def test_get_boto3_timeout_from_env_rejects_non_positive_integers(self, raw): + with mock.patch.dict(os.environ, {"PROWLER_AWS_BOTO3_CONNECT_TIMEOUT": raw}): + with raises( + AWSInvalidBoto3TimeoutError, match="PROWLER_AWS_BOTO3_CONNECT_TIMEOUT" + ): + get_boto3_timeout_from_env("PROWLER_AWS_BOTO3_CONNECT_TIMEOUT", 10) + + def test_get_boto3_timeout_from_env_blank_falls_back_to_default(self): + with mock.patch.dict(os.environ, {"PROWLER_AWS_BOTO3_CONNECT_TIMEOUT": " "}): + assert ( + get_boto3_timeout_from_env("PROWLER_AWS_BOTO3_CONNECT_TIMEOUT", 10) + == 10 + ) @mock_aws @patch( diff --git a/tests/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege_test.py b/tests/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege_test.py index c6e5d7f756..ff50416579 100644 --- a/tests/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege_test.py +++ b/tests/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege_test.py @@ -265,11 +265,11 @@ class Test_bedrock_agent_role_least_privilege: @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) def test_agent_role_not_resolvable(self): - """role_arn returned by GetAgent doesn't match any IAM role -> FAIL.""" + """role_arn returned by GetAgent doesn't match any IAM role -> MANUAL.""" result = _run_check( role_arn_for_get_agent=f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/does-not-exist" ) assert len(result) == 1 - assert result[0].status == "FAIL" + assert result[0].status == "MANUAL" assert "could not be resolved" in result[0].status_extended diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured_test.py index 928ff2b47c..9a79761ac0 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured_test.py @@ -670,10 +670,12 @@ class Test_cloudwatch_changes_to_network_acls_alarm_configured: ) cloudtrail_client.trails = None + cloudtrail_client.trails_unavailable = True check = cloudwatch_changes_to_network_acls_alarm_configured() result = check.execute() - assert len(result) == 0 + assert len(result) == 1 + assert result[0].status == "MANUAL" @mock_aws def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_group_agentcore_data_protection_policy_enabled/cloudwatch_log_group_agentcore_data_protection_policy_enabled_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_group_agentcore_data_protection_policy_enabled/cloudwatch_log_group_agentcore_data_protection_policy_enabled_test.py new file mode 100644 index 0000000000..2823db9ee8 --- /dev/null +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_group_agentcore_data_protection_policy_enabled/cloudwatch_log_group_agentcore_data_protection_policy_enabled_test.py @@ -0,0 +1,333 @@ +import os +import pathlib +from unittest import mock + +import yaml +from moto import mock_aws + +from prowler.providers.aws.services.cloudwatch.cloudwatch_service import LogGroup +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +CHECK_MODULE = "prowler.providers.aws.services.cloudwatch.cloudwatch_log_group_agentcore_data_protection_policy_enabled.cloudwatch_log_group_agentcore_data_protection_policy_enabled" + +RUNTIME_LOG_GROUP = "/aws/bedrock-agentcore/runtimes/my_agent-1a2b3c4d5e" +VENDED_LOG_GROUP = ( + "/aws/vendedlogs/bedrock-agentcore/memory/APPLICATION_LOGS/my-memory-1a2b3c" +) + + +def log_group(name, data_protection_status=None, inherited_properties=None): + """Build a LogGroup carrying the two fields this check reads. + + Both default to the state DescribeLogGroups reports for a log group that has never had a data + protection policy: dataProtectionStatus absent, and no inherited properties. + """ + return LogGroup( + arn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:{name}:*", + name=name, + retention_days=365, + never_expire=False, + kms_id=None, + creation_time=1700000000000, + data_protection_status=data_protection_status, + inherited_properties=inherited_properties or [], + region=AWS_REGION_US_EAST_1, + ) + + +def run_check(log_groups, audit_config=None): + """Drive the check over a fixed inventory. + + The inventory is set on the service object rather than served through a + patched _make_api_call: DescribeLogGroups -> LogGroup field mapping and its + pagination are covered in cloudwatch_service_test.py, and patching a global + here made these tests sensitive to the order they run in. + """ + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import Logs + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1], + audit_config={} if audit_config is None else audit_config, + ) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + logs_client = Logs(aws_provider) + logs_client.log_groups = ( + None if log_groups is None else {group.arn: group for group in log_groups} + ) + + with mock.patch(f"{CHECK_MODULE}.logs_client", new=logs_client): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_group_agentcore_data_protection_policy_enabled.cloudwatch_log_group_agentcore_data_protection_policy_enabled import ( + cloudwatch_log_group_agentcore_data_protection_policy_enabled, + ) + + return ( + cloudwatch_log_group_agentcore_data_protection_policy_enabled().execute() + ) + + +class Test_cloudwatch_log_group_agentcore_data_protection_policy_enabled: + """Tests for the cloudwatch_log_group_agentcore_data_protection_policy_enabled check.""" + + @mock_aws + def test_no_log_groups(self): + """An account with no log groups at all must produce no findings. + + An empty inventory was read successfully, so it is not the MANUAL case; there is simply no + resource to make a claim about. + """ + assert run_check([]) == [] + + @mock_aws + def test_non_agentcore_log_group_is_out_of_scope(self): + """Log groups outside the AgentCore prefixes must produce no findings. + + Asserting a data protection policy on every log group in the account would bury the + AgentCore ones. The lookalike name is the case that matters: the prefix must be matched with + its trailing slash, or /aws/bedrock-agentcore-lookalike/ is pulled into scope. + """ + results = run_check( + [ + log_group("/aws/lambda/unrelated-function"), + log_group("aws/spans"), + log_group("/aws/bedrock-agentcore-lookalike/runtimes/x"), + ] + ) + + assert results == [] + + @mock_aws + def test_agentcore_log_group_without_data_protection_status(self): + """An AgentCore log group reporting no dataProtectionStatus must FAIL. + + dataProtectionStatus is modelled at the botocore pin, so its absence is not a parsing gap: + it is the API reporting that the log group has never had a data protection policy, which + means nothing is masked. Pins the resource identity too, since the finding has to name the + log group an operator must remediate. + """ + results = run_check([log_group(RUNTIME_LOG_GROUP)]) + + assert len(results) == 1 + assert results[0].status == "FAIL" + assert ( + results[0].status_extended + == f"AgentCore log group {RUNTIME_LOG_GROUP} does not have an active data protection policy, so sensitive data written by the agent is not masked." + ) + assert results[0].resource_id == RUNTIME_LOG_GROUP + assert ( + results[0].resource_arn + == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:{RUNTIME_LOG_GROUP}:*" + ) + assert results[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_agentcore_log_group_with_activated_policy(self): + """ACTIVATED is the only dataProtectionStatus that must PASS: masking is running today.""" + results = run_check( + [log_group(RUNTIME_LOG_GROUP, data_protection_status="ACTIVATED")] + ) + + assert len(results) == 1 + assert results[0].status == "PASS" + assert ( + results[0].status_extended + == f"AgentCore log group {RUNTIME_LOG_GROUP} has a data protection policy activated." + ) + + @mock_aws + def test_agentcore_log_group_with_inactive_policy(self): + """DELETED, ARCHIVED and DISABLED must each FAIL, not MANUAL. + + All three were read successfully, so nothing is unknown; they mean the same thing + operationally, which is that nothing is being masked at ingestion today. Together with + ACTIVATED these are all four values the enum carries at the botocore pin. + """ + for status in ("DELETED", "ARCHIVED", "DISABLED"): + results = run_check( + [log_group(RUNTIME_LOG_GROUP, data_protection_status=status)] + ) + + assert len(results) == 1 + assert results[0].status == "FAIL" + assert ( + results[0].status_extended + == f"AgentCore log group {RUNTIME_LOG_GROUP} does not have an active data protection policy, so sensitive data written by the agent is not masked." + ) + + @mock_aws + def test_agentcore_log_group_inheriting_account_policy(self): + """A log group inheriting the account policy must PASS with no status of its own. + + An account-level policy surfaces as ACCOUNT_DATA_PROTECTION in inheritedProperties and + leaves dataProtectionStatus absent, so a check reading only dataProtectionStatus would FAIL + a log group that is fully masked. ACCOUNT_DATA_PROTECTION is the only value the + InheritedProperty enum carries at the botocore pin. + """ + results = run_check( + [ + log_group( + VENDED_LOG_GROUP, + inherited_properties=["ACCOUNT_DATA_PROTECTION"], + ) + ] + ) + + assert len(results) == 1 + assert results[0].status == "PASS" + assert ( + results[0].status_extended + == f"AgentCore log group {VENDED_LOG_GROUP} inherits the account-level data protection policy." + ) + + @mock_aws + def test_agentcore_log_groups_mixed(self): + """Multi-resource: one report per in-scope log group, with the PASS/FAIL split asserted. + + A loop that stopped at the first log group, or one that let the out-of-scope Lambda group + through, would not produce exactly these two verdicts. + """ + results = run_check( + [ + log_group(RUNTIME_LOG_GROUP), + log_group(VENDED_LOG_GROUP, data_protection_status="ACTIVATED"), + log_group("/aws/lambda/unrelated-function"), + ] + ) + + assert len(results) == 2 + assert {result.resource_id: result.status for result in results} == { + RUNTIME_LOG_GROUP: "FAIL", + VENDED_LOG_GROUP: "PASS", + } + + @mock_aws + def test_log_groups_not_retrieved_is_manual(self): + """An unreadable inventory must yield one account-level MANUAL, not PASS and not FAIL. + + PASS would assert masking never observed; FAIL would invent a finding against log groups + nothing is known about. The report is attributed to the account rather than to a log group, + because no log group was read. + """ + results = run_check(None) + + assert len(results) == 1 + assert results[0].status == "MANUAL" + assert ( + results[0].status_extended + == "Log groups could not be retrieved, so data protection policies for AgentCore log groups could not be verified." + ) + assert results[0].resource_id == AWS_ACCOUNT_NUMBER + assert results[0].region == AWS_REGION_US_EAST_1 + assert results[0].resource_tags == [] + + @mock_aws + def test_configured_prefix_brings_a_custom_log_group_into_scope(self): + """A configured prefix must put a log group outside the AWS defaults in scope and FAIL it. + + AgentCore log delivery can be pointed at an arbitrarily named log group, so an operator who + does that has no coverage until the prefix is configured. + """ + results = run_check( + [log_group("/company/agents/support-bot")], + audit_config={ + "agentcore_log_group_name_prefixes": ["/company/agents/"], + }, + ) + + assert len(results) == 1 + assert results[0].status == "FAIL" + assert results[0].resource_id == "/company/agents/support-bot" + + @mock_aws + def test_configured_prefix_replaces_the_defaults(self): + """A configured prefix list REPLACES the defaults, so a real AgentCore group drops out. + + This is the sharp edge of the setting and the reason it is pinned: an operator who adds only + their own prefix silences the check for /aws/bedrock-agentcore/ and + /aws/vendedlogs/bedrock-agentcore/, with no finding to show it happened. They must list the + defaults alongside their own. + """ + results = run_check( + [log_group(RUNTIME_LOG_GROUP)], + audit_config={ + "agentcore_log_group_name_prefixes": ["/company/agents/"], + }, + ) + + assert results == [] + + def test_shipped_config_matches_the_check_defaults(self): + """The prefixes shipped in config.yaml must equal the check's in-code defaults. + + The same two prefixes are written twice, and the shipped config wins wherever it is used, so + a prefix added only to the in-code list would be silently ignored by every operator running + the default config -- and an unmatched log group produces no finding at all, not a FAIL. This + makes that drift a failing test instead of missing coverage. + + The provider is mocked around the import because importing the check module constructs + `logs_client`, which reads the global provider's identity. Every other test here reaches that + import through `run_check`, which mocks it; this one imports the module directly for the + constant, so without the patch it is the only test in the file that cannot be selected on its + own -- 12 of 13 pass alone, and did not. + """ + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_group_agentcore_data_protection_policy_enabled.cloudwatch_log_group_agentcore_data_protection_policy_enabled import ( + DEFAULT_AGENTCORE_LOG_GROUP_PREFIXES, + ) + + repo_root = pathlib.Path(os.path.dirname(os.path.realpath(__file__))).parents[5] + shipped = yaml.safe_load( + (repo_root / "prowler" / "config" / "config.yaml").read_text() + ) + + assert ( + shipped["aws"]["agentcore_log_group_name_prefixes"] + == DEFAULT_AGENTCORE_LOG_GROUP_PREFIXES + ) + + @mock_aws + def test_explicit_null_prefixes_fall_back_to_the_defaults(self): + """An explicitly null prefix list must fall back to the defaults, not silence the check. + + A bare `agentcore_log_group_name_prefixes:` in the YAML parses as None. Passing that + straight to startswith would raise, and treating it as an empty list would skip every log + group and report nothing. + """ + results = run_check( + [log_group(RUNTIME_LOG_GROUP)], + audit_config={"agentcore_log_group_name_prefixes": None}, + ) + + assert len(results) == 1 + assert results[0].status == "FAIL" + + @mock_aws + def test_an_explicitly_empty_prefix_list_selects_no_log_group(self): + """An empty list is a CONFIGURED value and must not fall back to the defaults. + + This is the only way an operator can say "no log group is in scope for this check", and the + docstring promises a configured list REPLACES the defaults. Reading it with `or` treated `[]` + as absent and re-imposed the defaults, so the check reported on a log group the operator had + deliberately excluded, and no configuration could turn it off. + + It is the pair with the null case above that carries the assertion: null must fall back and + empty must not, and a fix that collapsed both to one behaviour would satisfy either test + alone. The distinction is only visible when both are present. + """ + results = run_check( + [log_group(RUNTIME_LOG_GROUP)], + audit_config={"agentcore_log_group_name_prefixes": []}, + ) + + assert results == [] diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled_test.py index 62ca14d4ba..dea4a7aa47 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled_test.py @@ -662,9 +662,11 @@ class Test_cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_c cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled() ) cloudtrail_client.trails = None + cloudtrail_client.trails_unavailable = True result = check.execute() - assert len(result) == 0 + assert len(result) == 1 + assert result[0].status == "MANUAL" @mock_aws def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_root_usage/cloudwatch_log_metric_filter_root_usage_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_root_usage/cloudwatch_log_metric_filter_root_usage_test.py index c04f4896d1..140a4017b5 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_root_usage/cloudwatch_log_metric_filter_root_usage_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_root_usage/cloudwatch_log_metric_filter_root_usage_test.py @@ -596,3 +596,334 @@ class Test_cloudwatch_log_metric_filter_root_usage: == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + def _run_with_unavailable_data(self, *, metric_filters_none, metric_alarms_none): + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ) + logs = Logs(aws_provider) + cloudwatch = CloudWatch(aws_provider) + # The services set these to None when the describe call is denied + # (AccessDeniedException / AccessDenied). + if metric_filters_none: + logs.metric_filters = None + logs.metric_filters_unavailable = True + if metric_alarms_none: + cloudwatch.metric_alarms = None + cloudwatch.metric_alarms_unavailable = True + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage.logs_client", + new=logs, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage.cloudwatch_client", + new=cloudwatch, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage import ( + cloudwatch_log_metric_filter_root_usage, + ) + + return cloudwatch_log_metric_filter_root_usage().execute() + + @mock_aws + def test_cloudwatch_metric_filters_access_denied(self): + """logs:DescribeMetricFilters denied -> MANUAL, not FAIL.""" + result = self._run_with_unavailable_data( + metric_filters_none=True, metric_alarms_none=False + ) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + "metric filters or alarms could not be listed" in result[0].status_extended + ) + assert "logs:DescribeMetricFilters" in result[0].status_extended + assert result[0].resource_id == AWS_ACCOUNT_NUMBER + assert result[0].region == AWS_REGION_EU_WEST_1 + + @mock_aws + def test_cloudwatch_metric_filters_partially_denied(self): + """Filters listed in one region but denied in another -> MANUAL. + + The service keeps the partial list (not None) and only raises the + ``metric_filters_unavailable`` flag; with no matching filter the check + must not claim FAIL. + """ + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ) + logs = Logs(aws_provider) + assert logs.metric_filters == [] + logs.metric_filters_unavailable = True + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage.logs_client", + new=logs, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage import ( + cloudwatch_log_metric_filter_root_usage, + ) + + result = cloudwatch_log_metric_filter_root_usage().execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "in at least one region" in result[0].status_extended + + @mock_aws + def test_cloudwatch_trails_access_denied(self): + """cloudtrail:DescribeTrails denied -> MANUAL instead of no finding.""" + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ) + cloudtrail = Cloudtrail(aws_provider) + cloudtrail.trails = None + cloudtrail.trails_unavailable = True + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage.cloudtrail_client", + new=cloudtrail, + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage import ( + cloudwatch_log_metric_filter_root_usage, + ) + + result = cloudwatch_log_metric_filter_root_usage().execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "cloudtrail:DescribeTrails" in result[0].status_extended + + @mock_aws + def test_cloudwatch_log_groups_access_denied(self): + """logs:DescribeLogGroups denied -> MANUAL.""" + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ) + logs = Logs(aws_provider) + logs.log_groups_unavailable = True + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage.logs_client", + new=logs, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage import ( + cloudwatch_log_metric_filter_root_usage, + ) + + result = cloudwatch_log_metric_filter_root_usage().execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "logs:DescribeLogGroups" in result[0].status_extended + + @mock_aws + def test_cloudwatch_metric_alarms_access_denied(self): + """cloudwatch:DescribeAlarms denied -> MANUAL, not FAIL.""" + result = self._run_with_unavailable_data( + metric_filters_none=False, metric_alarms_none=True + ) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "cloudwatch:DescribeAlarms" in result[0].status_extended + + def _run_with_filter(self, *, with_alarm, metric_alarms_unavailable): + """Create a trail + matching filter (optionally its alarm) and run the check + with the alarm inventory flagged as (un)available.""" + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ $.userIdentity.type = Root && $.userIdentity.invokedBy NOT EXISTS && $.eventType != AwsServiceEvent }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + if with_alarm: + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + from prowler.providers.common.models import Audit_Metadata + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ) + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + cloudwatch = CloudWatch(aws_provider) + cloudwatch.metric_alarms_unavailable = metric_alarms_unavailable + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage.cloudwatch_client", + new=cloudwatch, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage import ( + cloudwatch_log_metric_filter_root_usage, + ) + + return cloudwatch_log_metric_filter_root_usage().execute() + + @mock_aws + def test_cloudwatch_match_found_despite_partial_denial_is_pass(self): + """A filter with its alarm found in a readable region is real evidence: + PASS even if another region denied the alarm listing.""" + result = self._run_with_filter(with_alarm=True, metric_alarms_unavailable=True) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "/log-group/test" + + @mock_aws + def test_cloudwatch_filter_without_alarm_under_partial_denial_is_manual(self): + """Filter found but no alarm, while the alarm listing was denied in some + region: the missing alarm cannot be asserted -> MANUAL, not FAIL.""" + result = self._run_with_filter(with_alarm=False, metric_alarms_unavailable=True) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "cloudwatch:DescribeAlarms" in result[0].status_extended + + @mock_aws + def test_cloudwatch_filter_without_alarm_fully_listed_is_fail(self): + """Same setup with a complete alarm inventory stays FAIL.""" + result = self._run_with_filter( + with_alarm=False, metric_alarms_unavailable=False + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "no alarms associated" in result[0].status_extended diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_service_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_service_test.py index 3d2d53ffa0..88a28f8aac 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_service_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_service_test.py @@ -1,5 +1,9 @@ +from unittest.mock import patch + +import botocore import pytest from boto3 import client +from botocore.exceptions import ClientError from moto import mock_aws from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( @@ -16,6 +20,8 @@ from tests.providers.aws.utils import ( set_mocked_aws_provider, ) +make_api_call = botocore.client.BaseClient._make_api_call + class Test_CloudWatch_Service: # Test CloudWatch Service @@ -167,6 +173,7 @@ class Test_CloudWatch_Service: assert logs.metric_filters[0].log_group is None assert logs.metric_filters[0].name == "test-filter" assert logs.metric_filters[0].metric == "my-metric" + assert logs.metric_filters[0].metric_namespace == "my-namespace" assert logs.metric_filters[0].pattern == "test-pattern" assert logs.metric_filters[0].region == AWS_REGION_US_EAST_1 @@ -226,6 +233,116 @@ class Test_CloudWatch_Service: assert logs.log_groups[arn].region == AWS_REGION_US_EAST_1 assert logs.log_groups[arn].tags == [{}] + @mock_aws + def test_describe_log_groups_data_protection_across_pages(self): + """Both data protection fields must be collected from every page of DescribeLogGroups. + + moto has no data protection support, so the response is served literally. Both pages carry + state a check reads, and each page carries a different one: a collector that stops after the + first page under-reports silently instead of failing loudly. The second page also proves + inheritedProperties survives the round trip rather than being flattened away. + """ + first_page_arn = f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/aws/bedrock-agentcore/runtimes/page-one:*" + second_page_arn = f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/aws/bedrock-agentcore/runtimes/page-two:*" + pages = [ + { + "logGroups": [ + { + "arn": first_page_arn, + "logGroupName": "/aws/bedrock-agentcore/runtimes/page-one", + "creationTime": 2, + "dataProtectionStatus": "DISABLED", + } + ], + "nextToken": "page-two", + }, + { + "logGroups": [ + { + "arn": second_page_arn, + "logGroupName": "/aws/bedrock-agentcore/runtimes/page-two", + "creationTime": 1, + "dataProtectionStatus": "ACTIVATED", + "inheritedProperties": ["ACCOUNT_DATA_PROTECTION"], + } + ] + }, + ] + + def mock_make_api_call(self, operation_name, kwarg): + """Serve page two once the paginator follows nextToken, page one otherwise.""" + if operation_name == "DescribeLogGroups": + return pages[1] if kwarg.get("nextToken") else pages[0] + return make_api_call(self, operation_name, kwarg) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1], + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + ) + with patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call): + logs = Logs(aws_provider) + + assert set(logs.log_groups) == {first_page_arn, second_page_arn} + assert logs.log_groups[first_page_arn].data_protection_status == "DISABLED" + assert logs.log_groups[first_page_arn].inherited_properties == [] + assert logs.log_groups[second_page_arn].data_protection_status == "ACTIVATED" + assert logs.log_groups[second_page_arn].inherited_properties == [ + "ACCOUNT_DATA_PROTECTION" + ] + + @mock_aws + def test_describe_log_groups_without_data_protection_fields(self): + """A log group reporting neither field must collect as None and an empty list. + + This is the common real response, since DescribeLogGroups omits both members for a log group + that has never had a policy. None must not become "DISABLED" and the list must not become + None, or a check cannot tell "never configured" from "switched off". + """ + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + logs_client.create_log_group(logGroupName="/log-group/test") + + aws_provider = set_mocked_aws_provider( + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"] + ) + arn = f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" + logs = Logs(aws_provider) + + assert logs.log_groups[arn].data_protection_status is None + assert logs.log_groups[arn].inherited_properties == [] + + @mock_aws + def test_describe_log_groups_access_denied_leaves_inventory_unknown(self): + """A denied DescribeLogGroups must leave both indexes None, not empty dicts. + + None is the state checks read as "inventory unknown" and report MANUAL for. Collapsing to an + empty dict would be indistinguishable from an account that has no log groups, which every + log group check reads as nothing to report. + """ + + def mock_make_api_call(self, operation_name, kwarg): + """Deny DescribeLogGroups; defer every other operation to botocore.""" + if operation_name == "DescribeLogGroups": + raise ClientError( + { + "Error": { + "Code": "AccessDeniedException", + "Message": "not authorized", + } + }, + operation_name, + ) + return make_api_call(self, operation_name, kwarg) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1], + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + ) + with patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call): + logs = Logs(aws_provider) + + assert logs.log_groups is None + assert logs.all_log_groups is None + def test_log_group_limit_exposes_only_selected_resources(self): class FakeLogsClient: def __init__(self): @@ -419,3 +536,39 @@ class Test_build_metric_filter_pattern: event_names=["ConsoleLogin"], extra_clauses=[("errorMessage", bad_operator, "Failed authentication")], ) + + @mock_aws + def test_describe_log_groups_access_denied_sets_flag(self): + """A denied DescribeLogGroups must raise log_groups_unavailable.""" + from unittest import mock + + from botocore.client import BaseClient + from botocore.exceptions import ClientError + + orig = BaseClient._make_api_call + + def deny_describe_log_groups(self, operation_name, kwarg): + if operation_name == "DescribeLogGroups": + raise ClientError( + { + "Error": { + "Code": "AccessDeniedException", + "Message": "Access Denied", + } + }, + operation_name, + ) + return orig(self, operation_name, kwarg) + + aws_provider = set_mocked_aws_provider( + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"] + ) + with mock.patch( + "botocore.client.BaseClient._make_api_call", + new=deny_describe_log_groups, + ): + logs = Logs(aws_provider) + + assert logs.log_groups_unavailable is True + assert logs.log_groups is None + assert logs.all_log_groups is None diff --git a/tests/providers/aws/services/cloudwatch/lib/metric_filters_test.py b/tests/providers/aws/services/cloudwatch/lib/metric_filters_test.py new file mode 100644 index 0000000000..ddcb241f1d --- /dev/null +++ b/tests/providers/aws/services/cloudwatch/lib/metric_filters_test.py @@ -0,0 +1,202 @@ +from pathlib import Path + +import prowler +from prowler.lib.check.models import CheckMetadata +from prowler.providers.aws.services.cloudtrail.cloudtrail_service import Trail +from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + LogGroup, + MetricAlarm, + MetricFilter, +) +from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + check_cloudwatch_log_metric_filter, +) + +AWS_REGION = "eu-west-1" +AWS_ACCOUNT_NUMBER = "123456789012" + +TRAIL_ARN = f"arn:aws:cloudtrail:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:trail/trail-test" +TRAIL_LOG_GROUP_NAME = "trail-log-group" +TRAIL_LOG_GROUP_ARN = ( + f"arn:aws:logs:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:log-group:{TRAIL_LOG_GROUP_NAME}:*" +) + +# Matches the filter patterns built below, so a filter is only skipped because of +# its missing log group and never because the pattern failed to match. +PATTERN = r"\$\.eventName\s*=\s*.?PutBucketPolicy" +FILTER_PATTERN = "{ ($.eventName = PutBucketPolicy) }" +METRIC_NAME = "PutBucketPolicyCount" + +METADATA = CheckMetadata.parse_file( + Path(prowler.__file__).parent + / "providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_unauthorized_api_calls/cloudwatch_log_metric_filter_unauthorized_api_calls.metadata.json" +).json() + + +def _trails(): + """One trail delivering to TRAIL_LOG_GROUP_NAME, putting that log group in scope. + + Without a trail carrying a log group ARN the check has nothing to match filters + against and returns None for any input, which would make every assertion below + pass for the wrong reason. + """ + return {TRAIL_ARN: Trail(region=AWS_REGION, log_group_arn=TRAIL_LOG_GROUP_ARN)} + + +def _trail_log_group(): + """The trail's log group as the CloudWatch service would have collected it.""" + return LogGroup( + arn=TRAIL_LOG_GROUP_ARN, + name=TRAIL_LOG_GROUP_NAME, + retention_days=7, + never_expire=False, + region=AWS_REGION, + ) + + +def _metric_filter(name, log_group, metric=METRIC_NAME, namespace="CloudTrailMetrics"): + """Build a metric filter whose pattern always matches PATTERN. + + Args: + name: filter name, which the report echoes in status_extended. + log_group: the collected LogGroup, or None to model a filter whose log + group was never retrieved -- the input that used to raise. + metric: metric name an alarm has to carry for the filter to be compliant. + namespace: metric namespace the filter publishes to, or None when the + transformation does not expose one. + """ + return MetricFilter( + arn=f"arn:aws:logs:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:metric-filter/{name}", + name=name, + metric=metric, + metric_namespace=namespace, + pattern=FILTER_PATTERN, + log_group=log_group, + region=AWS_REGION, + ) + + +def _alarm(metric=METRIC_NAME, namespace="CloudTrailMetrics", region=AWS_REGION): + """Build an alarm on metric; a non-default name models an unrelated alarm. + + The check pairs alarms to filters by metric name and region (and namespace + when both sides expose one), so passing a metric no filter uses is how a + filter with no alarm of its own is expressed. + """ + return MetricAlarm( + arn=f"arn:aws:cloudwatch:{region}:{AWS_ACCOUNT_NUMBER}:alarm:{metric}-alarm", + name=f"{metric}-alarm", + metric=metric, + name_space=namespace, + region=region, + alarm_actions=[f"arn:aws:sns:{region}:{AWS_ACCOUNT_NUMBER}:topic-test"], + actions_enabled=True, + ) + + +class Test_check_cloudwatch_log_metric_filter: + def test_metric_filter_without_collected_log_group(self): + """A metric filter whose log group was not retrieved must be skipped + instead of raising AttributeError on the None log group.""" + report = check_cloudwatch_log_metric_filter( + PATTERN, + _trails(), + [_metric_filter("orphan-filter", None)], + [_alarm()], + METADATA, + ) + + assert report is None + + def test_uncollected_log_group_does_not_mask_a_compliant_filter(self): + """The skip must not abandon the remaining filters: a compliant filter + listed after an uncollected one still has to be evaluated.""" + report = check_cloudwatch_log_metric_filter( + PATTERN, + _trails(), + [ + _metric_filter("orphan-filter", None), + _metric_filter("trail-filter", _trail_log_group()), + ], + [_alarm()], + METADATA, + ) + + assert report is not None + assert report.status == "PASS" + assert ( + report.status_extended + == f"CloudWatch log group {TRAIL_LOG_GROUP_NAME} found with metric filter trail-filter and alarms set." + ) + assert report.resource_id == TRAIL_LOG_GROUP_NAME + assert report.resource_arn == TRAIL_LOG_GROUP_ARN + assert report.region == AWS_REGION + + def test_uncollected_log_group_does_not_mask_a_missing_alarm(self): + """The skip must not turn a filter with no alarm into a pass.""" + report = check_cloudwatch_log_metric_filter( + PATTERN, + _trails(), + [ + _metric_filter("orphan-filter", None), + _metric_filter("trail-filter", _trail_log_group()), + ], + [_alarm(metric="UnrelatedMetric")], + METADATA, + ) + + assert report is not None + assert report.status == "FAIL" + assert ( + report.status_extended + == f"CloudWatch log group {TRAIL_LOG_GROUP_NAME} found with metric filter trail-filter but no alarms associated." + ) + + def test_alarm_in_other_namespace_does_not_pass(self): + """A same-named metric in another namespace is a different metric.""" + report = check_cloudwatch_log_metric_filter( + PATTERN, + _trails(), + [_metric_filter("trail-filter", _trail_log_group())], + [_alarm(namespace="OtherNamespace")], + METADATA, + ) + + assert report.status == "FAIL" + assert "no alarms associated" in report.status_extended + + def test_alarm_in_other_region_does_not_pass(self): + """A same-named metric in another region is a different metric.""" + report = check_cloudwatch_log_metric_filter( + PATTERN, + _trails(), + [_metric_filter("trail-filter", _trail_log_group())], + [_alarm(region="us-east-1")], + METADATA, + ) + + assert report.status == "FAIL" + + def test_alarm_without_namespace_still_matches(self): + """Namespace is only compared when both sides expose one.""" + report = check_cloudwatch_log_metric_filter( + PATTERN, + _trails(), + [_metric_filter("trail-filter", _trail_log_group())], + [_alarm(namespace=None)], + METADATA, + ) + + assert report.status == "PASS" + + def test_filter_without_namespace_still_matches(self): + """A filter with no namespace accepts an alarm in any namespace.""" + report = check_cloudwatch_log_metric_filter( + PATTERN, + _trails(), + [_metric_filter("trail-filter", _trail_log_group(), namespace=None)], + [_alarm()], + METADATA, + ) + + assert report.status == "PASS" diff --git a/tests/providers/aws/services/codebuild/codebuild_project_uses_allowed_github_organizations/codebuild_project_uses_allowed_github_organizations_test.py b/tests/providers/aws/services/codebuild/codebuild_project_uses_allowed_github_organizations/codebuild_project_uses_allowed_github_organizations_test.py index bdabea03fa..0ac90d50d2 100644 --- a/tests/providers/aws/services/codebuild/codebuild_project_uses_allowed_github_organizations/codebuild_project_uses_allowed_github_organizations_test.py +++ b/tests/providers/aws/services/codebuild/codebuild_project_uses_allowed_github_organizations/codebuild_project_uses_allowed_github_organizations_test.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch from boto3 import client from moto import mock_aws @@ -182,6 +182,59 @@ class Test_codebuild_project_uses_allowed_github_organizations: ) assert result[0].region == AWS_REGION_EU_WEST_1 + @mock_aws + def test_project_github_with_unlisted_roles(self): + # iam:ListRoles denied leaves iam_client.roles as None. + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + codebuild_client = client("codebuild", region_name=AWS_REGION_EU_WEST_1) + codebuild_client.create_project( + name="test-project-github-unlisted-roles", + source={ + "type": "GITHUB", + "location": "https://github.com/allowed-org/repo", + }, + artifacts={"type": "NO_ARTIFACTS"}, + environment={ + "type": "LINUX_CONTAINER", + "image": "aws/codebuild/standard:4.0", + "computeType": "BUILD_GENERAL1_SMALL", + "environmentVariables": [], + }, + serviceRole=f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/codebuild-test-role", + ) + + from prowler.providers.aws.services.codebuild.codebuild_service import Codebuild + + iam_client = MagicMock() + iam_client.roles = None + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.codebuild.codebuild_project_uses_allowed_github_organizations.codebuild_project_uses_allowed_github_organizations.codebuild_client", + new=Codebuild(aws_provider), + ), + patch( + "prowler.providers.aws.services.codebuild.codebuild_project_uses_allowed_github_organizations.codebuild_project_uses_allowed_github_organizations.iam_client", + new=iam_client, + ), + patch( + "prowler.providers.aws.services.codebuild.codebuild_project_uses_allowed_github_organizations.codebuild_project_uses_allowed_github_organizations.codebuild_client.audit_config", + {"codebuild_github_allowed_organizations": ["allowed-org"]}, + ), + ): + from prowler.providers.aws.services.codebuild.codebuild_project_uses_allowed_github_organizations.codebuild_project_uses_allowed_github_organizations import ( + codebuild_project_uses_allowed_github_organizations, + ) + + assert ( + len(codebuild_project_uses_allowed_github_organizations().execute()) + == 0 + ) + @mock_aws def test_project_github_no_codebuild_trusted_principal(self): aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) diff --git a/tests/providers/aws/services/ecr/ecr_registry_enhanced_scanning_enabled/ecr_registry_enhanced_scanning_enabled_test.py b/tests/providers/aws/services/ecr/ecr_registry_enhanced_scanning_enabled/ecr_registry_enhanced_scanning_enabled_test.py new file mode 100644 index 0000000000..fcd52222b3 --- /dev/null +++ b/tests/providers/aws/services/ecr/ecr_registry_enhanced_scanning_enabled/ecr_registry_enhanced_scanning_enabled_test.py @@ -0,0 +1,311 @@ +from unittest import mock + +from prowler.providers.aws.services.ecr.ecr_service import ( + Registry, + Repository, + ScanningRule, +) +from tests.providers.aws.utils import ( + AWS_ACCOUNT_ARN, + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +repository_name = "test_repo" +repository_arn = ( + f"arn:aws:ecr:eu-west-1:{AWS_ACCOUNT_NUMBER}:repository/{repository_name}" +) +registry_arn = ( + f"arn:aws:ecr:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:registry/" + f"{AWS_ACCOUNT_NUMBER}" +) + + +def _registry(scan_type, rules, repositories=None, region=AWS_REGION_EU_WEST_1): + """Build a Registry holding one repository unless told otherwise. + + A registry is per-region, so `region` is a parameter: an account using ECR in more than one + region has more than one registry, and each carries its own scanning configuration. + """ + if repositories is None: + repositories = [ + Repository( + name=repository_name, + arn=f"arn:aws:ecr:{region}:{AWS_ACCOUNT_NUMBER}:repository/{repository_name}", + region=region, + scan_on_push=True, + policy="", + images_details=None, + lifecycle_policy="", + ) + ] + return Registry( + id=AWS_ACCOUNT_NUMBER, + arn=f"arn:aws:ecr:{region}:{AWS_ACCOUNT_NUMBER}:registry/{AWS_ACCOUNT_NUMBER}", + region=region, + scan_type=scan_type, + repositories=repositories, + rules=rules, + ) + + +class Test_ecr_registry_enhanced_scanning_enabled: + def test_no_registries(self): + """Check produces no findings when no registries exist.""" + ecr_client = mock.MagicMock + ecr_client.registries = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider(), + ), + mock.patch( + "prowler.providers.aws.services.ecr.ecr_registry_enhanced_scanning_enabled.ecr_registry_enhanced_scanning_enabled.ecr_client", + ecr_client, + ), + ): + from prowler.providers.aws.services.ecr.ecr_registry_enhanced_scanning_enabled.ecr_registry_enhanced_scanning_enabled import ( + ecr_registry_enhanced_scanning_enabled, + ) + + check = ecr_registry_enhanced_scanning_enabled() + result = check.execute() + assert len(result) == 0 + + def test_registry_no_repositories(self): + """Check skips registries holding no repositories (not in use).""" + ecr_client = mock.MagicMock + ecr_client.registries = {} + ecr_client.registries[AWS_REGION_EU_WEST_1] = _registry( + "BASIC", [], repositories=[] + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider(), + ), + mock.patch( + "prowler.providers.aws.services.ecr.ecr_registry_enhanced_scanning_enabled.ecr_registry_enhanced_scanning_enabled.ecr_client", + ecr_client, + ), + ): + from prowler.providers.aws.services.ecr.ecr_registry_enhanced_scanning_enabled.ecr_registry_enhanced_scanning_enabled import ( + ecr_registry_enhanced_scanning_enabled, + ) + + check = ecr_registry_enhanced_scanning_enabled() + result = check.execute() + assert len(result) == 0 + + def test_registry_enhanced_scanning(self): + """Registry with ENHANCED scan type passes the check.""" + ecr_client = mock.MagicMock + ecr_client.audited_account_arn = AWS_ACCOUNT_ARN + ecr_client.registries = {} + ecr_client.registries[AWS_REGION_EU_WEST_1] = _registry( + "ENHANCED", + [ + ScanningRule( + scan_frequency="CONTINUOUS_SCAN", + scan_filters=[{"filter": "*", "filterType": "WILDCARD"}], + ) + ], + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider(), + ), + mock.patch( + "prowler.providers.aws.services.ecr.ecr_registry_enhanced_scanning_enabled.ecr_registry_enhanced_scanning_enabled.ecr_client", + ecr_client, + ), + ): + from prowler.providers.aws.services.ecr.ecr_registry_enhanced_scanning_enabled.ecr_registry_enhanced_scanning_enabled import ( + ecr_registry_enhanced_scanning_enabled, + ) + + check = ecr_registry_enhanced_scanning_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"ECR registry {AWS_ACCOUNT_NUMBER} has enhanced scanning enabled." + ) + assert result[0].resource_id == AWS_ACCOUNT_NUMBER + assert result[0].resource_arn == registry_arn + assert result[0].region == AWS_REGION_EU_WEST_1 + + def test_registry_basic_scanning(self): + """Registry with BASIC scan type fails the check.""" + ecr_client = mock.MagicMock + ecr_client.audited_account_arn = AWS_ACCOUNT_ARN + ecr_client.registries = {} + ecr_client.registries[AWS_REGION_EU_WEST_1] = _registry( + "BASIC", + [ + ScanningRule( + scan_frequency="SCAN_ON_PUSH", + scan_filters=[{"filter": "*", "filterType": "WILDCARD"}], + ) + ], + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider(), + ), + mock.patch( + "prowler.providers.aws.services.ecr.ecr_registry_enhanced_scanning_enabled.ecr_registry_enhanced_scanning_enabled.ecr_client", + ecr_client, + ), + ): + from prowler.providers.aws.services.ecr.ecr_registry_enhanced_scanning_enabled.ecr_registry_enhanced_scanning_enabled import ( + ecr_registry_enhanced_scanning_enabled, + ) + + check = ecr_registry_enhanced_scanning_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"ECR registry {AWS_ACCOUNT_NUMBER} has BASIC scanning enabled instead of enhanced scanning." + ) + assert result[0].resource_id == AWS_ACCOUNT_NUMBER + assert result[0].resource_arn == registry_arn + assert result[0].region == AWS_REGION_EU_WEST_1 + + def test_registry_enhanced_scanning_empty_rules(self): + """ENHANCED scan type with empty rules list. + + An ENHANCED registry with no scanning rules still has the scan type + set to ENHANCED. The check verifies scan type only, not rules. + """ + ecr_client = mock.MagicMock + ecr_client.audited_account_arn = AWS_ACCOUNT_ARN + ecr_client.registries = {} + ecr_client.registries[AWS_REGION_EU_WEST_1] = _registry("ENHANCED", []) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider(), + ), + mock.patch( + "prowler.providers.aws.services.ecr.ecr_registry_enhanced_scanning_enabled.ecr_registry_enhanced_scanning_enabled.ecr_client", + ecr_client, + ), + ): + from prowler.providers.aws.services.ecr.ecr_registry_enhanced_scanning_enabled.ecr_registry_enhanced_scanning_enabled import ( + ecr_registry_enhanced_scanning_enabled, + ) + + check = ecr_registry_enhanced_scanning_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"ECR registry {AWS_ACCOUNT_NUMBER} has enhanced scanning enabled." + ) + assert result[0].resource_id == AWS_ACCOUNT_NUMBER + assert result[0].resource_arn == registry_arn + assert result[0].region == AWS_REGION_EU_WEST_1 + + def test_registry_scanning_configuration_not_retrieved(self): + """An unread scan type is MANUAL, never PASS. + + ``_get_registry_scanning_configuration`` leaves ``scan_type`` at + ``None`` when ``GetRegistryScanningConfiguration`` fails for any + reason other than the "feature is disabled" ValidationException, and + when the response carries no ``scanningConfiguration`` at all. An + unanswered scan type is not evidence that enhanced scanning is on. + """ + ecr_client = mock.MagicMock + ecr_client.audited_account_arn = AWS_ACCOUNT_ARN + ecr_client.registries = {} + ecr_client.registries[AWS_REGION_EU_WEST_1] = _registry(None, None) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider(), + ), + mock.patch( + "prowler.providers.aws.services.ecr.ecr_registry_enhanced_scanning_enabled.ecr_registry_enhanced_scanning_enabled.ecr_client", + ecr_client, + ), + ): + from prowler.providers.aws.services.ecr.ecr_registry_enhanced_scanning_enabled.ecr_registry_enhanced_scanning_enabled import ( + ecr_registry_enhanced_scanning_enabled, + ) + + check = ecr_registry_enhanced_scanning_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == f"ECR registry {AWS_ACCOUNT_NUMBER} scanning configuration could not be retrieved, check manually if enhanced scanning is enabled." + ) + assert result[0].resource_id == AWS_ACCOUNT_NUMBER + assert result[0].resource_arn == registry_arn + assert result[0].region == AWS_REGION_EU_WEST_1 + + def test_multiple_registries_one_verdict_each(self): + """Each registry is judged on its own scanning configuration. + + A registry is per-region, so an account using ECR in three regions has three of them. One + ENHANCED, one BASIC and one unread must yield PASS, FAIL and MANUAL against the matching + region -- a check that reported only the first registry, or reused one verdict across them, + would leave the other regions unreported or misreported. + """ + ecr_client = mock.MagicMock + ecr_client.audited_account_arn = AWS_ACCOUNT_ARN + ecr_client.registries = { + AWS_REGION_EU_WEST_1: _registry( + "ENHANCED", [], region=AWS_REGION_EU_WEST_1 + ), + AWS_REGION_US_EAST_1: _registry("BASIC", [], region=AWS_REGION_US_EAST_1), + "eu-central-1": _registry(None, None, region="eu-central-1"), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider(), + ), + mock.patch( + "prowler.providers.aws.services.ecr.ecr_registry_enhanced_scanning_enabled.ecr_registry_enhanced_scanning_enabled.ecr_client", + ecr_client, + ), + ): + from prowler.providers.aws.services.ecr.ecr_registry_enhanced_scanning_enabled.ecr_registry_enhanced_scanning_enabled import ( + ecr_registry_enhanced_scanning_enabled, + ) + + check = ecr_registry_enhanced_scanning_enabled() + result = check.execute() + + assert len(result) == 3 + by_region = {report.region: report for report in result} + assert set(by_region) == { + AWS_REGION_EU_WEST_1, + AWS_REGION_US_EAST_1, + "eu-central-1", + } + assert by_region[AWS_REGION_EU_WEST_1].status == "PASS" + assert by_region[AWS_REGION_US_EAST_1].status == "FAIL" + assert by_region["eu-central-1"].status == "MANUAL" + assert ( + by_region[AWS_REGION_US_EAST_1].status_extended + == f"ECR registry {AWS_ACCOUNT_NUMBER} has BASIC scanning enabled instead of enhanced scanning." + ) diff --git a/tests/providers/aws/services/ecr/ecr_registry_scan_images_on_push_enabled/ecr_registry_scan_images_on_push_enabled_test.py b/tests/providers/aws/services/ecr/ecr_registry_scan_images_on_push_enabled/ecr_registry_scan_images_on_push_enabled_test.py index f736866742..f30e2de49e 100644 --- a/tests/providers/aws/services/ecr/ecr_registry_scan_images_on_push_enabled/ecr_registry_scan_images_on_push_enabled_test.py +++ b/tests/providers/aws/services/ecr/ecr_registry_scan_images_on_push_enabled/ecr_registry_scan_images_on_push_enabled_test.py @@ -72,6 +72,7 @@ class Test_ecr_registry_scan_images_on_push_enabled: assert len(result) == 0 def test_registry_scan_on_push_enabled(self): + """A BASIC registry whose one unfiltered rule is SCAN_ON_PUSH passes as scan on push.""" ecr_client = mock.MagicMock ecr_client.audited_account_arn = AWS_ACCOUNT_ARN ecr_client.registries = {} @@ -119,7 +120,7 @@ class Test_ecr_registry_scan_images_on_push_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"ECR registry {AWS_ACCOUNT_NUMBER} has BASIC scan with scan on push enabled." + == f"ECR registry {AWS_ACCOUNT_NUMBER} has BASIC scanning with scan on push for all repositories." ) assert result[0].resource_id == AWS_ACCOUNT_NUMBER assert ( @@ -186,6 +187,7 @@ class Test_ecr_registry_scan_images_on_push_enabled: assert result[0].region == AWS_REGION_EU_WEST_1 def test_scan_on_push_disabled(self): + """A registry with no scanning rules at all fails: no frequency is configured to read.""" ecr_client = mock.MagicMock ecr_client.audited_account_arn = AWS_ACCOUNT_ARN ecr_client.registries = {} @@ -228,7 +230,7 @@ class Test_ecr_registry_scan_images_on_push_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"ECR registry {AWS_ACCOUNT_NUMBER} has BASIC scanning without scan on push enabled." + == f"ECR registry {AWS_ACCOUNT_NUMBER} has BASIC scanning without automated scanning enabled." ) assert result[0].resource_id == AWS_ACCOUNT_NUMBER assert ( @@ -236,3 +238,242 @@ class Test_ecr_registry_scan_images_on_push_enabled: == f"arn:aws:ecr:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:registry/{AWS_ACCOUNT_NUMBER}" ) assert result[0].region == AWS_REGION_EU_WEST_1 + + def test_enhanced_with_no_rules_fails(self): + """An ENHANCED registry with an empty rule set fails: nothing is scanned automatically. + + Reviewed as a false FAIL, on the grounds that enhanced scanning defaults to continuous + scanning for all repositories when no rules are configured. It does not. Enhanced scanning + is filter-driven, and a repository matching no filter is not scanned at all: "Any + repositories that don't match a filter will have an Off scan frequency and won't be + scanned" (https://docs.aws.amazon.com/AmazonECR/latest/userguide/image-scanning-enhanced.html), + and "Any repositories not matching an enhanced scanning filter will have scanning disabled" + (https://docs.aws.amazon.com/AmazonECR/latest/userguide/image-scanning-filters.html). With + no rules at all, no repository matches, so the whole registry goes unscanned. + + Nor is this state a disguised pass. ECR materialises "continuous scanning for all + repositories" as an explicit CONTINUOUS_SCAN rule filtered on '*' -- which the check already + passes, and which an observed ENHANCED registry returns instead of an empty list. So an + empty rule set is either unreachable or genuinely means nothing is scanned, and PASS here + would report a registry that scans nothing as covered. + """ + ecr_client = mock.MagicMock + ecr_client.audited_account_arn = AWS_ACCOUNT_ARN + ecr_client.registries = {} + ecr_client.registries[AWS_REGION_EU_WEST_1] = Registry( + id=AWS_ACCOUNT_NUMBER, + arn=f"arn:aws:ecr:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:registry/{AWS_ACCOUNT_NUMBER}", + region=AWS_REGION_EU_WEST_1, + scan_type="ENHANCED", + repositories=[ + Repository( + name=repository_name, + arn=repository_arn, + region=AWS_REGION_EU_WEST_1, + scan_on_push=False, + policy="", + images_details=None, + lifecycle_policy="", + ) + ], + rules=[], + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider(), + ), + mock.patch( + "prowler.providers.aws.services.ecr.ecr_registry_scan_images_on_push_enabled.ecr_registry_scan_images_on_push_enabled.ecr_client", + ecr_client, + ), + ): + from prowler.providers.aws.services.ecr.ecr_registry_scan_images_on_push_enabled.ecr_registry_scan_images_on_push_enabled import ( + ecr_registry_scan_images_on_push_enabled, + ) + + check = ecr_registry_scan_images_on_push_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"ECR registry {AWS_ACCOUNT_NUMBER} has ENHANCED scanning without automated scanning enabled." + ) + + def test_continuous_scan_is_not_reported_as_scan_on_push(self): + """A CONTINUOUS_SCAN-only registry passes, but must not be described as scan on push. + + Observed against a live ENHANCED registry whose single rule was CONTINUOUS_SCAN: the check + reported \"scan with scan on push enabled\", a configuration the registry did not have. The + verdict was right and the sentence was not, because scan-on-push was inferred from the mere + presence of a rule and scanFrequency was never read.""" + ecr_client = mock.MagicMock + ecr_client.audited_account_arn = AWS_ACCOUNT_ARN + ecr_client.registries = {} + ecr_client.registries[AWS_REGION_EU_WEST_1] = Registry( + id=AWS_ACCOUNT_NUMBER, + arn=f"arn:aws:ecr:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:registry/{AWS_ACCOUNT_NUMBER}", + region=AWS_REGION_EU_WEST_1, + scan_type="ENHANCED", + repositories=[ + Repository( + name=repository_name, + arn=repository_arn, + region=AWS_REGION_EU_WEST_1, + scan_on_push=False, + policy="", + images_details=None, + lifecycle_policy="", + ) + ], + rules=[ + ScanningRule( + scan_frequency="CONTINUOUS_SCAN", + scan_filters=[{"filter": "*", "filterType": "WILDCARD"}], + ) + ], + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider(), + ), + mock.patch( + "prowler.providers.aws.services.ecr.ecr_registry_scan_images_on_push_enabled.ecr_registry_scan_images_on_push_enabled.ecr_client", + ecr_client, + ), + ): + from prowler.providers.aws.services.ecr.ecr_registry_scan_images_on_push_enabled.ecr_registry_scan_images_on_push_enabled import ( + ecr_registry_scan_images_on_push_enabled, + ) + + check = ecr_registry_scan_images_on_push_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"ECR registry {AWS_ACCOUNT_NUMBER} has ENHANCED scanning with continuous scanning for all repositories." + ) + + def test_manual_only_scanning_fails(self): + """MANUAL is the third frequency the API returns, and nothing scans a pushed image. + + The registry is BASIC because that is the only scan type MANUAL occurs under: scanFrequency + documents CONTINUOUS_SCAN and SCAN_ON_PUSH for ENHANCED, and MANUAL as the BASIC default + when scan on push is not specified. Before this was read, any rule at all produced PASS -- + so a registry that scans nothing until someone asks reported as scanning on push. + """ + ecr_client = mock.MagicMock + ecr_client.audited_account_arn = AWS_ACCOUNT_ARN + ecr_client.registries = {} + ecr_client.registries[AWS_REGION_EU_WEST_1] = Registry( + id=AWS_ACCOUNT_NUMBER, + arn=f"arn:aws:ecr:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:registry/{AWS_ACCOUNT_NUMBER}", + region=AWS_REGION_EU_WEST_1, + scan_type="BASIC", + repositories=[ + Repository( + name=repository_name, + arn=repository_arn, + region=AWS_REGION_EU_WEST_1, + scan_on_push=False, + policy="", + images_details=None, + lifecycle_policy="", + ) + ], + rules=[ + ScanningRule( + scan_frequency="MANUAL", + scan_filters=[{"filter": "*", "filterType": "WILDCARD"}], + ) + ], + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider(), + ), + mock.patch( + "prowler.providers.aws.services.ecr.ecr_registry_scan_images_on_push_enabled.ecr_registry_scan_images_on_push_enabled.ecr_client", + ecr_client, + ), + ): + from prowler.providers.aws.services.ecr.ecr_registry_scan_images_on_push_enabled.ecr_registry_scan_images_on_push_enabled import ( + ecr_registry_scan_images_on_push_enabled, + ) + + check = ecr_registry_scan_images_on_push_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"ECR registry {AWS_ACCOUNT_NUMBER} has BASIC scanning set to manual only, so images are not scanned when they are pushed." + ) + + def test_both_frequencies_are_named_in_a_fixed_order(self): + """Two rules, one of each frequency: the wording is ordered, not set-iteration order. + + ECR allows up to two rules, so this is a real configuration rather than a contrived one. The + frequencies are collected into a set, and rendering a set directly would let the sentence vary + between runs for identical input. + """ + ecr_client = mock.MagicMock + ecr_client.audited_account_arn = AWS_ACCOUNT_ARN + ecr_client.registries = {} + ecr_client.registries[AWS_REGION_EU_WEST_1] = Registry( + id=AWS_ACCOUNT_NUMBER, + arn=f"arn:aws:ecr:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:registry/{AWS_ACCOUNT_NUMBER}", + region=AWS_REGION_EU_WEST_1, + scan_type="ENHANCED", + repositories=[ + Repository( + name=repository_name, + arn=repository_arn, + region=AWS_REGION_EU_WEST_1, + scan_on_push=True, + policy="", + images_details=None, + lifecycle_policy="", + ) + ], + rules=[ + ScanningRule( + scan_frequency="CONTINUOUS_SCAN", + scan_filters=[{"filter": "*", "filterType": "WILDCARD"}], + ), + ScanningRule( + scan_frequency="SCAN_ON_PUSH", + scan_filters=[{"filter": "*", "filterType": "WILDCARD"}], + ), + ], + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider(), + ), + mock.patch( + "prowler.providers.aws.services.ecr.ecr_registry_scan_images_on_push_enabled.ecr_registry_scan_images_on_push_enabled.ecr_client", + ecr_client, + ), + ): + from prowler.providers.aws.services.ecr.ecr_registry_scan_images_on_push_enabled.ecr_registry_scan_images_on_push_enabled import ( + ecr_registry_scan_images_on_push_enabled, + ) + + check = ecr_registry_scan_images_on_push_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"ECR registry {AWS_ACCOUNT_NUMBER} has ENHANCED scanning with scan on push and continuous scanning for all repositories." + ) diff --git a/tests/providers/aws/services/ecr/ecr_service_test.py b/tests/providers/aws/services/ecr/ecr_service_test.py index 9181d6e8d8..a93badad2f 100644 --- a/tests/providers/aws/services/ecr/ecr_service_test.py +++ b/tests/providers/aws/services/ecr/ecr_service_test.py @@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch import botocore import pytest from boto3 import client +from botocore.exceptions import ClientError from moto import mock_aws from prowler.providers.aws.services.ecr.ecr_service import ( @@ -201,6 +202,21 @@ def mock_make_api_call(self, operation_name, kwarg): return make_api_call(self, operation_name, kwarg) +def mock_make_api_call_registry_scanning_denied(self, operation_name, kwarg): + """Deny GetRegistryScanningConfiguration, serving every other call normally.""" + if operation_name == "GetRegistryScanningConfiguration": + raise ClientError( + { + "Error": { + "Code": "AccessDeniedException", + "Message": "User is not authorized to perform: ecr:GetRegistryScanningConfiguration", + } + }, + operation_name, + ) + return mock_make_api_call(self, operation_name, kwarg) + + def mock_generate_regional_clients(provider, service): """Return a single regional client for every requested region.""" regional_client = provider._session.current_session.client( @@ -437,6 +453,27 @@ class Test_ECR_Service: ) ] + @mock_aws + def test_get_registry_scanning_configuration_not_retrieved(self): + """A denied GetRegistryScanningConfiguration leaves the scan type unknown. + + Prowler leaves unretrieved attributes at None, so checks reading + ``scan_type`` can tell "not enhanced" apart from "not answered". + + The patch is entered inside the test body rather than as a decorator: + stacked patch decorators are merged into one ``patchings`` list, so the + class-level ``_make_api_call`` patch would be applied last and win. + """ + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + with patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_registry_scanning_denied, + ): + ecr = ECR(aws_provider) + assert len(ecr.registries) == 1 + assert ecr.registries[AWS_REGION_EU_WEST_1].scan_type is None + assert ecr.registries[AWS_REGION_EU_WEST_1].rules is None + def test_is_artifact_scannable_docker(self): """A Docker image config is scannable.""" assert ECR._is_artifact_scannable( diff --git a/tests/providers/aws/services/eks/eks_cluster_vpc_cni_network_policy_enforced/eks_cluster_vpc_cni_network_policy_enforced_test.py b/tests/providers/aws/services/eks/eks_cluster_vpc_cni_network_policy_enforced/eks_cluster_vpc_cni_network_policy_enforced_test.py new file mode 100644 index 0000000000..56b9990afe --- /dev/null +++ b/tests/providers/aws/services/eks/eks_cluster_vpc_cni_network_policy_enforced/eks_cluster_vpc_cni_network_policy_enforced_test.py @@ -0,0 +1,307 @@ +from unittest import mock + +import pytest + +from prowler.providers.aws.services.eks.eks_service import EKSAddon, EKSCluster +from tests.providers.aws.utils import AWS_ACCOUNT_NUMBER, AWS_REGION_EU_WEST_1 + +cluster_name = "cluster_test" +cluster_arn = ( + f"arn:aws:eks:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:cluster/{cluster_name}" +) +addon_arn = f"arn:aws:eks:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:addon/{cluster_name}/vpc-cni/1a2b3c4d" + + +def build_cluster(name=cluster_name, arn=cluster_arn, **kwargs): + """Build an EKSCluster whose add-on state the caller supplies through kwargs. + + The defaults leave `addons` empty and both discovery flags false, which is the shape of a + cluster that carries no managed add-ons rather than one whose add-ons could not be read. + """ + return EKSCluster(name=name, arn=arn, region=AWS_REGION_EU_WEST_1, **kwargs) + + +def vpc_cni_addon(configuration_values=None, configuration_discovery_failed=False): + """Build the `addons` mapping for a cluster carrying the vpc-cni managed add-on. + + `configuration_values` is passed through as the raw JSON string DescribeAddon returns, so a + test can supply the exact blob the API would, including an absent or malformed one. + """ + return { + "vpc-cni": EKSAddon( + name="vpc-cni", + arn=addon_arn, + configuration_values=configuration_values, + configuration_discovery_failed=configuration_discovery_failed, + ) + } + + +def run_check(clusters): + """Execute the check against the given clusters and return its reports. + + The clusters are model objects, so the reports exercise the check's own branching over + already-collected state and no EKS API call takes place. + """ + eks_client = mock.MagicMock + eks_client.clusters = clusters + with mock.patch( + "prowler.providers.aws.services.eks.eks_service.EKS", + eks_client, + ): + from prowler.providers.aws.services.eks.eks_cluster_vpc_cni_network_policy_enforced.eks_cluster_vpc_cni_network_policy_enforced import ( + eks_cluster_vpc_cni_network_policy_enforced, + ) + + return eks_cluster_vpc_cni_network_policy_enforced().execute() + + +class Test_eks_cluster_vpc_cni_network_policy_enforced: + def test_no_clusters(self): + """An account with no EKS clusters must produce no reports at all.""" + assert len(run_check([])) == 0 + + def test_addons_discovery_failed(self): + """A cluster whose ListAddons call failed must be MANUAL, not FAIL. + + The add-on mapping is empty in both this case and the no-managed-add-on case, so the + cluster-level flag is what separates "unknown" from "not installed". + """ + result = run_check([build_cluster(addons_discovery_failed=True)]) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].status_extended == ( + f"EKS cluster {cluster_name} add-ons could not be listed, so Kubernetes " + "network policy enforcement in the Amazon VPC CNI add-on cannot be determined." + ) + assert result[0].resource_id == cluster_name + assert result[0].resource_arn == cluster_arn + assert result[0].region == AWS_REGION_EU_WEST_1 + + def test_no_vpc_cni_addon(self): + """A cluster with managed add-ons but no vpc-cni must be MANUAL and name the CNI. + + The EKS API exposes nothing about a CNI it does not manage, so the verdict cannot be + FAIL: the cluster may well enforce network policies through a self-managed CNI. + """ + result = run_check( + [build_cluster(addons={"coredns": EKSAddon(name="coredns")})] + ) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].status_extended == ( + f"EKS cluster {cluster_name} does not use the Amazon VPC CNI managed " + "add-on, so Kubernetes network policy enforcement cannot be determined " + "from the EKS API. Review the self-managed CNI configuration in the cluster." + ) + assert result[0].resource_id == cluster_name + assert result[0].resource_arn == cluster_arn + assert result[0].region == AWS_REGION_EU_WEST_1 + + def test_addons_listed_but_empty(self): + """A cluster whose add-ons listed successfully as empty must get the not-installed wording. + + Same MANUAL verdict as a failed listing but a different explanation, so the report does + not tell an operator to fix permissions when the add-on is simply not there. + """ + result = run_check([build_cluster(addons={})]) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].status_extended == ( + f"EKS cluster {cluster_name} does not use the Amazon VPC CNI managed " + "add-on, so Kubernetes network policy enforcement cannot be determined " + "from the EKS API. Review the self-managed CNI configuration in the cluster." + ) + + def test_addon_configuration_unreadable(self): + """A vpc-cni add-on whose DescribeAddon call failed must be MANUAL. + + The add-on is known to be installed, but its configuration was never read, so network + policy enforcement is undetermined rather than off. + """ + result = run_check( + [build_cluster(addons=vpc_cni_addon(configuration_discovery_failed=True))] + ) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].status_extended == ( + f"EKS cluster {cluster_name} Amazon VPC CNI add-on configuration could " + "not be read, so Kubernetes network policy enforcement cannot be determined." + ) + assert result[0].resource_id == cluster_name + assert result[0].resource_arn == cluster_arn + assert result[0].region == AWS_REGION_EU_WEST_1 + + def test_addon_configuration_unreadable_wins_over_stale_values(self): + """A failed describe must stay MANUAL even when the model still carries a true setting. + + Guards the branch order: reading `configuration_values` before checking the failure flag + would report PASS from a value the failed call did not return. + """ + result = run_check( + [ + build_cluster( + addons=vpc_cni_addon( + configuration_values='{"enableNetworkPolicy":"true"}', + configuration_discovery_failed=True, + ) + ) + ] + ) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].status_extended == ( + f"EKS cluster {cluster_name} Amazon VPC CNI add-on configuration could " + "not be read, so Kubernetes network policy enforcement cannot be determined." + ) + + @pytest.mark.parametrize( + "configuration_values", + ['{"enableNetworkPolicy": "true"', '["enableNetworkPolicy"]', "true", "42"], + ) + def test_addon_configuration_not_a_json_object(self, configuration_values): + """Configuration values that do not decode to a JSON object must be MANUAL. + + Truncated JSON, an array, a bare boolean and a bare number each reach a different line of + the decoder, and none may raise out of the check or be read as an empty configuration. + """ + result = run_check([build_cluster(addons=vpc_cni_addon(configuration_values))]) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].status_extended == ( + f"EKS cluster {cluster_name} Amazon VPC CNI add-on configuration values " + "are not a readable JSON object, so Kubernetes network policy enforcement " + "cannot be determined." + ) + assert result[0].resource_id == cluster_name + assert result[0].resource_arn == cluster_arn + assert result[0].region == AWS_REGION_EU_WEST_1 + + @pytest.mark.parametrize( + "configuration_values", + [ + None, + "", + "{}", + '{"enableWindowsIpam": "false"}', + '{"enableNetworkPolicy": "yes"}', + '{"enableNetworkPolicy": 1}', + '{"enableNetworkPolicy": null}', + ], + ) + def test_network_policy_setting_not_a_boolean(self, configuration_values): + """A configuration carrying no recognizable boolean for the setting must be MANUAL, never FAIL. + + Absent, empty, a different key, `"yes"`, `1` and `null` all mean the setting was not + stated. Reporting FAIL on any of them would assert that enforcement is off on the strength + of a value the API never returned. + """ + result = run_check([build_cluster(addons=vpc_cni_addon(configuration_values))]) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].status_extended == ( + f"EKS cluster {cluster_name} Amazon VPC CNI add-on does not set " + "enableNetworkPolicy to true or false, so Kubernetes network policy " + "enforcement cannot be determined." + ) + assert result[0].resource_id == cluster_name + assert result[0].resource_arn == cluster_arn + assert result[0].region == AWS_REGION_EU_WEST_1 + + @pytest.mark.parametrize( + "configuration_values", + [ + '{"enableNetworkPolicy": "true"}', + '{"enableNetworkPolicy": "True"}', + '{"enableNetworkPolicy": true}', + '{"enableNetworkPolicy": "true", "enableWindowsIpam": "false"}', + ], + ) + def test_network_policy_enforced(self, configuration_values): + """A cluster whose vpc-cni add-on enables the setting must PASS, string or boolean. + + The add-on configuration schema types this setting as a string carrying + `"format": "boolean"`, so the API returns `"true"` rather than `true`; `"True"` and a JSON + boolean must land on the same verdict, and an unrelated sibling key must not disturb it. + """ + result = run_check([build_cluster(addons=vpc_cni_addon(configuration_values))]) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + f"EKS cluster {cluster_name} enforces Kubernetes network policies through " + "the Amazon VPC CNI add-on. This does not confirm that NetworkPolicy " + "resources restricting pod-to-pod traffic exist in the cluster." + ) + assert result[0].resource_id == cluster_name + assert result[0].resource_arn == cluster_arn + assert result[0].region == AWS_REGION_EU_WEST_1 + + @pytest.mark.parametrize( + "configuration_values", + [ + '{"enableNetworkPolicy": "false"}', + '{"enableNetworkPolicy": "False"}', + '{"enableNetworkPolicy": false}', + ], + ) + def test_network_policy_not_enforced(self, configuration_values): + """A cluster whose vpc-cni add-on sets the setting to false must FAIL, string or boolean. + + `"false"`, `"False"` and a JSON `false` are the three forms the setting can arrive in, and + a decoder that only understood one of them would report MANUAL on a real misconfiguration. + """ + result = run_check([build_cluster(addons=vpc_cni_addon(configuration_values))]) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + f"EKS cluster {cluster_name} Amazon VPC CNI managed add-on does not enforce " + "Kubernetes network policies, since it sets enableNetworkPolicy to false. " + "Enforcement by a third-party policy engine or a self-managed VPC CNI is not " + "visible to the EKS API and is not evaluated." + ) + # The finding must not claim a property of the CLUSTER from an add-on setting: a cluster + # enforcing through Calico or Cilium, or through a self-managed VPC CNI, has this setting + # false and does enforce. Both are documented architectures, so the old wording was false + # of them rather than merely imprecise. + assert "cluster does not enforce" not in result[0].status_extended + assert result[0].resource_id == cluster_name + assert result[0].resource_arn == cluster_arn + assert result[0].region == AWS_REGION_EU_WEST_1 + + def test_multiple_clusters(self): + """Six clusters must yield six reports, in input order, each judged on its own add-on. + + Two enforcing, three not and one with no managed add-on, so a check that carried state + between iterations or reported once per account would not produce this split. + """ + clusters = [] + for index in range(6): + name = f"{cluster_name}_{index}" + arn = f"arn:aws:eks:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:cluster/{name}" + if index in (0, 1): + addons = vpc_cni_addon('{"enableNetworkPolicy": "true"}') + elif index in (2, 3, 4): + addons = vpc_cni_addon('{"enableNetworkPolicy": "false"}') + else: + addons = {} + clusters.append(build_cluster(name=name, arn=arn, addons=addons)) + + result = run_check(clusters) + + assert len(result) == 6 + statuses = [report.status for report in result] + assert statuses.count("PASS") == 2 + assert statuses.count("FAIL") == 3 + assert statuses.count("MANUAL") == 1 + assert [report.resource_id for report in result] == [ + f"{cluster_name}_{index}" for index in range(6) + ] diff --git a/tests/providers/aws/services/eks/eks_service_test.py b/tests/providers/aws/services/eks/eks_service_test.py index 86de0e246a..1feb75cc45 100644 --- a/tests/providers/aws/services/eks/eks_service_test.py +++ b/tests/providers/aws/services/eks/eks_service_test.py @@ -1,5 +1,6 @@ from unittest.mock import patch +import botocore from boto3 import client from moto import mock_aws @@ -7,6 +8,7 @@ from prowler.providers.aws.services.eks.eks_service import EKS from tests.providers.aws.utils import ( AWS_ACCOUNT_NUMBER, AWS_REGION_EU_WEST_1, + mocked_api_response, set_mocked_aws_provider, ) @@ -14,6 +16,77 @@ cluster_name = "test" cidr_block_vpc = "10.0.0.0/16" cidr_block_subnet_1 = "10.0.0.0/22" cidr_block_subnet_2 = "10.0.4.0/22" +cluster_arn = ( + f"arn:aws:eks:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:cluster/{cluster_name}" +) +vpc_cni_addon_arn = f"arn:aws:eks:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:addon/{cluster_name}/vpc-cni/1a2b3c4d" +vpc_cni_configuration_values = '{"enableNetworkPolicy":"true"}' + +make_api_call = botocore.client.BaseClient._make_api_call +described_addons = [] + + +def _addon_response(addon_name, arn, configuration_values=None): + """Build a DescribeAddon response, omitting `configurationValues` when none is given. + + The key is absent from the API response for an add-on left at its defaults, so passing None + reproduces that rather than sending an empty string. + """ + addon = {"addonName": addon_name, "addonArn": arn, "clusterName": cluster_name} + if configuration_values is not None: + addon["configurationValues"] = configuration_values + return mocked_api_response("eks", "DescribeAddon", {"addon": addon}) + + +def mock_make_api_call_addons(self, operation_name, kwargs): + """Serve the add-on inventory, with vpc-cni on the SECOND ListAddons page.""" + if operation_name == "ListClusters": + return mocked_api_response("eks", "ListClusters", {"clusters": [cluster_name]}) + if operation_name == "DescribeCluster": + return mocked_api_response( + "eks", + "DescribeCluster", + {"cluster": {"name": cluster_name, "arn": cluster_arn, "version": "1.34"}}, + ) + if operation_name == "ListAddons": + if kwargs.get("nextToken") is None: + return mocked_api_response( + "eks", + "ListAddons", + {"addons": ["coredns"], "nextToken": "second-page"}, + ) + return mocked_api_response("eks", "ListAddons", {"addons": ["vpc-cni"]}) + if operation_name == "DescribeAddon": + described_addons.append(kwargs["addonName"]) + if kwargs["addonName"] == "vpc-cni": + return _addon_response( + "vpc-cni", vpc_cni_addon_arn, vpc_cni_configuration_values + ) + return _addon_response( + "coredns", + f"arn:aws:eks:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:addon/{cluster_name}/coredns/5e6f7a8b", + ) + return make_api_call(self, operation_name, kwargs) + + +def mock_make_api_call_list_addons_denied(self, operation_name, kwargs): + """Deny ListAddons and serve every other call, as a role without eks:ListAddons would.""" + if operation_name == "ListAddons": + raise botocore.exceptions.ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "denied"}}, + operation_name, + ) + return mock_make_api_call_addons(self, operation_name, kwargs) + + +def mock_make_api_call_describe_vpc_cni_denied(self, operation_name, kwargs): + """Deny DescribeAddon for vpc-cni only, so listing succeeds and the per-add-on read fails.""" + if operation_name == "DescribeAddon" and kwargs["addonName"] == "vpc-cni": + raise botocore.exceptions.ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "denied"}}, + operation_name, + ) + return mock_make_api_call_addons(self, operation_name, kwargs) def mock_generate_regional_clients(provider, service): @@ -139,3 +212,86 @@ class Test_EKS_Service: assert eks.clusters[0].public_access_cidrs == ["0.0.0.0/0"] assert eks.clusters[0].encryptionConfig assert eks.clusters[0].version == "1.10" + + +@patch( + "prowler.providers.aws.aws_provider.AwsProvider.generate_regional_clients", + new=mock_generate_regional_clients, +) +class Test_EKS_Service_Addons: + # Test EKS describe cluster add-ons + @mock_aws + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_addons, + ) + def test__describe_cluster_addons(self): + """vpc-cni is collected with its ARN and configuration from the SECOND ListAddons page. + + Also asserts coredns costs no DescribeAddon call: it is listed on the cluster but not in + COLLECTED_ADDONS, and DescribeAddon has no batch form, so describing it would be one extra + API call per cluster for data no check reads. + """ + described_addons.clear() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + eks = EKS(aws_provider) + + assert len(eks.clusters) == 1 + cluster = eks.clusters[0] + assert not cluster.addons_discovery_failed + # vpc-cni is only on the second ListAddons page, so this fails without pagination + assert sorted(cluster.addons) == ["vpc-cni"] + assert cluster.addons["vpc-cni"].name == "vpc-cni" + assert cluster.addons["vpc-cni"].arn == vpc_cni_addon_arn + assert ( + cluster.addons["vpc-cni"].configuration_values + == vpc_cni_configuration_values + ) + assert not cluster.addons["vpc-cni"].configuration_discovery_failed + # coredns is listed but not in COLLECTED_ADDONS, so it costs no DescribeAddon call + assert described_addons == ["vpc-cni"] + + # Test EKS cluster add-ons cannot be listed + @mock_aws + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_list_addons_denied, + ) + def test__describe_cluster_addons_list_denied(self): + """A denied ListAddons sets addons_discovery_failed and issues no DescribeAddon call. + + The cluster itself must survive collection: a missing add-on permission may not cost the + scan every other EKS finding for that cluster. + """ + described_addons.clear() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + eks = EKS(aws_provider) + + assert len(eks.clusters) == 1 + assert eks.clusters[0].addons_discovery_failed + assert eks.clusters[0].addons == {} + assert described_addons == [] + + # Test EKS cluster add-on configuration cannot be described + @mock_aws + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_describe_vpc_cni_denied, + ) + def test__describe_cluster_addons_describe_denied(self): + """A denied DescribeAddon flags the add-on, not the cluster, and leaves its fields None. + + The add-on is known to exist because ListAddons succeeded, so the failure belongs on + `configuration_discovery_failed` while `addons_discovery_failed` stays false. + """ + described_addons.clear() + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + eks = EKS(aws_provider) + + assert len(eks.clusters) == 1 + cluster = eks.clusters[0] + assert not cluster.addons_discovery_failed + assert sorted(cluster.addons) == ["vpc-cni"] + assert cluster.addons["vpc-cni"].configuration_discovery_failed + assert cluster.addons["vpc-cni"].configuration_values is None + assert cluster.addons["vpc-cni"].arn is None diff --git a/tests/providers/aws/services/elbv2/elbv2_listener_fips_tls_enabled/elbv2_listener_fips_tls_enabled_test.py b/tests/providers/aws/services/elbv2/elbv2_listener_fips_tls_enabled/elbv2_listener_fips_tls_enabled_test.py new file mode 100644 index 0000000000..ee1afde340 --- /dev/null +++ b/tests/providers/aws/services/elbv2/elbv2_listener_fips_tls_enabled/elbv2_listener_fips_tls_enabled_test.py @@ -0,0 +1,156 @@ +from unittest import mock + +from boto3 import client, resource +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_REGION_EU_WEST_1, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +CHECK_MODULE = "prowler.providers.aws.services.elbv2.elbv2_listener_fips_tls_enabled.elbv2_listener_fips_tls_enabled" +FIPS_POLICY = "ELBSecurityPolicy-TLS13-1-2-FIPS-2023-04" +NON_FIPS_POLICY = "ELBSecurityPolicy-TLS13-1-2-2021-06" + + +def create_application_load_balancer(): + conn = client("elbv2", region_name=AWS_REGION_EU_WEST_1) + ec2 = resource("ec2", region_name=AWS_REGION_EU_WEST_1) + security_group = ec2.create_security_group( + GroupName="a-security-group", Description="First One" + ) + vpc = ec2.create_vpc(CidrBlock="172.28.7.0/24", InstanceTenancy="default") + subnet1 = ec2.create_subnet( + VpcId=vpc.id, + CidrBlock="172.28.7.192/26", + AvailabilityZone=f"{AWS_REGION_EU_WEST_1}a", + ) + subnet2 = ec2.create_subnet( + VpcId=vpc.id, + CidrBlock="172.28.7.0/26", + AvailabilityZone=f"{AWS_REGION_EU_WEST_1}b", + ) + lb = conn.create_load_balancer( + Name="my-lb", + Subnets=[subnet1.id, subnet2.id], + SecurityGroups=[security_group.id], + Scheme="internal", + Type="application", + )["LoadBalancers"][0] + target_group_arn = conn.create_target_group( + Name="a-target", Protocol="HTTP", Port=8080, VpcId=vpc.id + )["TargetGroups"][0]["TargetGroupArn"] + return conn, lb, target_group_arn + + +def create_listener(conn, lb, target_group_arn, protocol, port, ssl_policy=None): + listener_args = { + "LoadBalancerArn": lb["LoadBalancerArn"], + "Protocol": protocol, + "Port": port, + "DefaultActions": [{"Type": "forward", "TargetGroupArn": target_group_arn}], + } + if ssl_policy: + listener_args["SslPolicy"] = ssl_policy + return conn.create_listener(**listener_args)["Listeners"][0] + + +def execute_check(service=None): + from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2 + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1], + create_default_organization=False, + ) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.elbv2_client", new=service or ELBv2(aws_provider)), + ): + from prowler.providers.aws.services.elbv2.elbv2_listener_fips_tls_enabled.elbv2_listener_fips_tls_enabled import ( + elbv2_listener_fips_tls_enabled, + ) + + return elbv2_listener_fips_tls_enabled().execute() + + +class Test_elbv2_listener_fips_tls_enabled: + @mock_aws + def test_no_load_balancers(self): + assert execute_check() == [] + + @mock_aws + def test_http_listener_only(self): + conn, lb, target_group_arn = create_application_load_balancer() + create_listener(conn, lb, target_group_arn, "HTTP", 80) + + result = execute_check() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == "ELBv2 my-lb has no HTTPS/TLS listeners." + + @mock_aws + def test_fips_policy(self): + conn, lb, target_group_arn = create_application_load_balancer() + create_listener(conn, lb, target_group_arn, "HTTPS", 443, FIPS_POLICY) + + result = execute_check() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "ELBv2 my-lb has all HTTPS/TLS listeners using a FIPS TLS security policy." + ) + assert result[0].resource_id == "my-lb" + assert result[0].resource_arn == lb["LoadBalancerArn"] + assert result[0].region == AWS_REGION_EU_WEST_1 + + @mock_aws + def test_non_fips_policy(self): + conn, lb, target_group_arn = create_application_load_balancer() + listener = create_listener( + conn, lb, target_group_arn, "HTTPS", 443, NON_FIPS_POLICY + ) + + result = execute_check() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"ELBv2 my-lb has HTTPS/TLS listeners without a FIPS TLS security policy: HTTPS:443 ({listener['ListenerArn']}) uses {NON_FIPS_POLICY}." + ) + + @mock_aws + def test_mixed_listeners(self): + conn, lb, target_group_arn = create_application_load_balancer() + create_listener(conn, lb, target_group_arn, "HTTPS", 443, FIPS_POLICY) + create_listener(conn, lb, target_group_arn, "HTTPS", 8443, NON_FIPS_POLICY) + + result = execute_check() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert NON_FIPS_POLICY in result[0].status_extended + assert FIPS_POLICY not in result[0].status_extended + + @mock_aws + def test_listener_discovery_failed(self): + from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2 + + conn, lb, target_group_arn = create_application_load_balancer() + create_listener(conn, lb, target_group_arn, "HTTPS", 443, NON_FIPS_POLICY) + service = ELBv2( + set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1], + create_default_organization=False, + ) + ) + service.loadbalancersv2[lb["LoadBalancerArn"]].listener_discovery_failed = True + + assert execute_check(service) == [] diff --git a/tests/providers/aws/services/guardduty/guardduty_ai_protection_enabled/guardduty_ai_protection_enabled_test.py b/tests/providers/aws/services/guardduty/guardduty_ai_protection_enabled/guardduty_ai_protection_enabled_test.py new file mode 100644 index 0000000000..2b3e585611 --- /dev/null +++ b/tests/providers/aws/services/guardduty/guardduty_ai_protection_enabled/guardduty_ai_protection_enabled_test.py @@ -0,0 +1,215 @@ +from unittest import mock + +import botocore +from boto3 import client +from moto import mock_aws + +from prowler.providers.aws.services.guardduty.guardduty_service import GuardDuty +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +CHECK_CLIENT_PATH = "prowler.providers.aws.services.guardduty.guardduty_ai_protection_enabled.guardduty_ai_protection_enabled.guardduty_client" + +make_api_call = botocore.client.BaseClient._make_api_call + + +def mock_get_detector_raises(self, operation_name, kwarg): + """Deny GetDetector only, leaving every other GuardDuty operation intact.""" + if operation_name == "GetDetector": + raise botocore.exceptions.ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "denied"}}, + operation_name, + ) + return make_api_call(self, operation_name, kwarg) + + +def _run_check(aws_provider): + """Run the check against a GuardDuty service built from the mocked provider.""" + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(CHECK_CLIENT_PATH, new=GuardDuty(aws_provider)), + ): + from prowler.providers.aws.services.guardduty.guardduty_ai_protection_enabled.guardduty_ai_protection_enabled import ( + guardduty_ai_protection_enabled, + ) + + return guardduty_ai_protection_enabled().execute() + + +class Test_guardduty_ai_protection_enabled: + @mock_aws + def test_no_detectors(self): + """A Region with no detector has no resource to judge; guardduty_is_enabled owns it.""" + client("guardduty", region_name=AWS_REGION_US_EAST_1) + + result = _run_check(set_mocked_aws_provider([AWS_REGION_US_EAST_1])) + + assert len(result) == 0 + + @mock_aws + def test_ai_protection_enabled(self): + """An enabled feature PASSes and carries the detector's own resource fields.""" + guardduty_client = client("guardduty", region_name=AWS_REGION_US_EAST_1) + response = guardduty_client.create_detector( + Enable=True, + Features=[{"Name": "AI_PROTECTION", "Status": "ENABLED"}], + ) + + result = _run_check(set_mocked_aws_provider([AWS_REGION_US_EAST_1])) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"GuardDuty detector {response['DetectorId']} has AI Protection enabled." + ) + assert result[0].resource_id == response["DetectorId"] + assert result[0].region == AWS_REGION_US_EAST_1 + assert ( + result[0].resource_arn + == f"arn:aws:guardduty:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:detector/{response['DetectorId']}" + ) + assert result[0].resource_tags == [] + + @mock_aws + def test_ai_protection_disabled(self): + """A reported-disabled feature FAILs and names the detector.""" + guardduty_client = client("guardduty", region_name=AWS_REGION_US_EAST_1) + response = guardduty_client.create_detector( + Enable=True, + Features=[{"Name": "AI_PROTECTION", "Status": "DISABLED"}], + ) + + result = _run_check(set_mocked_aws_provider([AWS_REGION_US_EAST_1])) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"GuardDuty detector {response['DetectorId']} does not have AI Protection enabled." + ) + assert result[0].resource_id == response["DetectorId"] + assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_ai_protection_feature_absent(self): + """A feature GuardDuty does not report is not a feature GuardDuty turned off. + + The Region or the GuardDuty version may not offer AI Protection at all. + """ + guardduty_client = client("guardduty", region_name=AWS_REGION_US_EAST_1) + response = guardduty_client.create_detector( + Enable=True, + Features=[ + {"Name": "S3_DATA_EVENTS", "Status": "ENABLED"}, + {"Name": "LAMBDA_NETWORK_LOGS", "Status": "ENABLED"}, + ], + ) + + result = _run_check(set_mocked_aws_provider([AWS_REGION_US_EAST_1])) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == f"GuardDuty detector {response['DetectorId']} does not report the AI Protection feature, so verify manually whether AI Protection is available in region {AWS_REGION_US_EAST_1}." + ) + assert result[0].resource_id == response["DetectorId"] + assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_no_features_reported(self): + """An empty features array reads the same as an absent AI_PROTECTION entry.""" + guardduty_client = client("guardduty", region_name=AWS_REGION_US_EAST_1) + response = guardduty_client.create_detector(Enable=True) + + result = _run_check(set_mocked_aws_provider([AWS_REGION_US_EAST_1])) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == f"GuardDuty detector {response['DetectorId']} does not report the AI Protection feature, so verify manually whether AI Protection is available in region {AWS_REGION_US_EAST_1}." + ) + + @mock_aws + def test_detector_not_enabled(self): + """A suspended detector is MANUAL: its feature state is unknown, not absent.""" + guardduty_client = client("guardduty", region_name=AWS_REGION_US_EAST_1) + response = guardduty_client.create_detector( + Enable=False, + Features=[{"Name": "AI_PROTECTION", "Status": "ENABLED"}], + ) + + result = _run_check(set_mocked_aws_provider([AWS_REGION_US_EAST_1])) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == f"GuardDuty detector {response['DetectorId']} is not enabled or could not be read, so AI Protection coverage could not be determined." + ) + + @mock_aws + def test_get_detector_unreadable(self): + """A denied GetDetector is unknown, not absent: MANUAL instead of FAIL.""" + guardduty_client = client("guardduty", region_name=AWS_REGION_US_EAST_1) + response = guardduty_client.create_detector( + Enable=True, + Features=[{"Name": "AI_PROTECTION", "Status": "DISABLED"}], + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "botocore.client.BaseClient._make_api_call", new=mock_get_detector_raises + ): + result = _run_check(aws_provider) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == f"GuardDuty detector {response['DetectorId']} is not enabled or could not be read, so AI Protection coverage could not be determined." + ) + + @mock_aws + def test_real_get_detector_payload_ai_protection_disabled(self): + """The real GetDetector payload carries AI_PROTECTION alongside AI_ANALYST. + + AI_PROTECTION is absent from the pinned DetectorFeatureResult enum, so this + asserts the name still reaches the check alongside the two runtime feature names. + """ + guardduty_client = client("guardduty", region_name=AWS_REGION_US_EAST_1) + response = guardduty_client.create_detector( + Enable=True, + Features=[ + {"Name": "CLOUD_TRAIL", "Status": "ENABLED"}, + {"Name": "DNS_LOGS", "Status": "ENABLED"}, + {"Name": "FLOW_LOGS", "Status": "ENABLED"}, + {"Name": "S3_DATA_EVENTS", "Status": "ENABLED"}, + {"Name": "EKS_AUDIT_LOGS", "Status": "ENABLED"}, + {"Name": "EBS_MALWARE_PROTECTION", "Status": "ENABLED"}, + {"Name": "RDS_LOGIN_EVENTS", "Status": "ENABLED"}, + {"Name": "AI_PROTECTION", "Status": "DISABLED"}, + {"Name": "AI_ANALYST", "Status": "ENABLED"}, + {"Name": "EKS_RUNTIME_MONITORING", "Status": "DISABLED"}, + {"Name": "LAMBDA_NETWORK_LOGS", "Status": "ENABLED"}, + {"Name": "RUNTIME_MONITORING", "Status": "DISABLED"}, + ], + ) + + result = _run_check(set_mocked_aws_provider([AWS_REGION_US_EAST_1])) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"GuardDuty detector {response['DetectorId']} does not have AI Protection enabled." + ) diff --git a/tests/providers/aws/services/guardduty/guardduty_runtime_monitoring_enabled/guardduty_runtime_monitoring_enabled_test.py b/tests/providers/aws/services/guardduty/guardduty_runtime_monitoring_enabled/guardduty_runtime_monitoring_enabled_test.py new file mode 100644 index 0000000000..9d861c5f23 --- /dev/null +++ b/tests/providers/aws/services/guardduty/guardduty_runtime_monitoring_enabled/guardduty_runtime_monitoring_enabled_test.py @@ -0,0 +1,270 @@ +from unittest import mock + +import botocore +from boto3 import client +from moto import mock_aws + +from prowler.providers.aws.services.guardduty.guardduty_service import GuardDuty +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +CHECK_CLIENT_PATH = "prowler.providers.aws.services.guardduty.guardduty_runtime_monitoring_enabled.guardduty_runtime_monitoring_enabled.guardduty_client" + +make_api_call = botocore.client.BaseClient._make_api_call + + +def mock_get_detector_raises(self, operation_name, kwarg): + """Deny GetDetector only, leaving every other GuardDuty operation intact.""" + if operation_name == "GetDetector": + raise botocore.exceptions.ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "denied"}}, + operation_name, + ) + return make_api_call(self, operation_name, kwarg) + + +def _run_check(aws_provider): + """Run the check against a GuardDuty service built from the mocked provider.""" + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(CHECK_CLIENT_PATH, new=GuardDuty(aws_provider)), + ): + from prowler.providers.aws.services.guardduty.guardduty_runtime_monitoring_enabled.guardduty_runtime_monitoring_enabled import ( + guardduty_runtime_monitoring_enabled, + ) + + return guardduty_runtime_monitoring_enabled().execute() + + +class Test_guardduty_runtime_monitoring_enabled: + @mock_aws + def test_no_detectors(self): + """A Region with no detector has no resource to judge; guardduty_is_enabled owns it.""" + client("guardduty", region_name=AWS_REGION_US_EAST_1) + + result = _run_check(set_mocked_aws_provider([AWS_REGION_US_EAST_1])) + + assert len(result) == 0 + + @mock_aws + def test_detector_disabled(self): + """A suspended detector is MANUAL, never dropped. + + The detector exists, so omitting it would leave the Region unreported, which + reads as compliant -- and it is reported here with Runtime Monitoring ENABLED, + the case where the omission was hardest to notice. + """ + guardduty_client = client("guardduty", region_name=AWS_REGION_US_EAST_1) + response = guardduty_client.create_detector( + Enable=False, + Features=[{"Name": "RUNTIME_MONITORING", "Status": "ENABLED"}], + ) + + result = _run_check(set_mocked_aws_provider([AWS_REGION_US_EAST_1])) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == f"GuardDuty detector {response['DetectorId']} is not enabled or could not be read, so Runtime Monitoring coverage could not be determined." + ) + assert result[0].resource_id == response["DetectorId"] + assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_get_detector_unreadable(self): + """A denied GetDetector is unknown, not absent: MANUAL instead of FAIL. + + Detector.status cannot distinguish this from a suspended detector, which is why + both carry the same MANUAL wording rather than a definite verdict. + """ + guardduty_client = client("guardduty", region_name=AWS_REGION_US_EAST_1) + response = guardduty_client.create_detector( + Enable=True, + Features=[{"Name": "RUNTIME_MONITORING", "Status": "DISABLED"}], + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "botocore.client.BaseClient._make_api_call", new=mock_get_detector_raises + ): + result = _run_check(aws_provider) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == f"GuardDuty detector {response['DetectorId']} is not enabled or could not be read, so Runtime Monitoring coverage could not be determined." + ) + + @mock_aws + def test_runtime_monitoring_enabled(self): + """An enabled unified feature PASSes and carries the detector's own resource fields.""" + guardduty_client = client("guardduty", region_name=AWS_REGION_US_EAST_1) + response = guardduty_client.create_detector( + Enable=True, + Features=[{"Name": "RUNTIME_MONITORING", "Status": "ENABLED"}], + ) + + result = _run_check(set_mocked_aws_provider([AWS_REGION_US_EAST_1])) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"GuardDuty detector {response['DetectorId']} has Runtime Monitoring enabled." + ) + assert result[0].resource_id == response["DetectorId"] + assert result[0].region == AWS_REGION_US_EAST_1 + assert ( + result[0].resource_arn + == f"arn:aws:guardduty:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:detector/{response['DetectorId']}" + ) + assert result[0].resource_tags == [] + + @mock_aws + def test_runtime_monitoring_disabled(self): + """A reported-disabled unified feature FAILs and names the detector.""" + guardduty_client = client("guardduty", region_name=AWS_REGION_US_EAST_1) + response = guardduty_client.create_detector( + Enable=True, + Features=[{"Name": "RUNTIME_MONITORING", "Status": "DISABLED"}], + ) + + result = _run_check(set_mocked_aws_provider([AWS_REGION_US_EAST_1])) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"GuardDuty detector {response['DetectorId']} does not have Runtime Monitoring enabled." + ) + assert result[0].resource_id == response["DetectorId"] + assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_unreported_runtime_feature_is_manual_not_fail(self): + """A feature the detector never reports is not the same as one it reports DISABLED. + + GuardDuty returns a disabled feature with Status DISABLED rather than omitting + it -- which is exactly why AI_PROTECTION is recorded even when off -- so an + omitted RUNTIME_MONITORING means the Region does not offer the unified feature, + or the features array could not be read. Neither is a definite absence of + runtime coverage. + """ + guardduty_client = client("guardduty", region_name=AWS_REGION_US_EAST_1) + response = guardduty_client.create_detector( + Enable=True, + Features=[{"Name": "S3_DATA_EVENTS", "Status": "ENABLED"}], + ) + + result = _run_check(set_mocked_aws_provider([AWS_REGION_US_EAST_1])) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == f"GuardDuty detector {response['DetectorId']} did not report the Runtime Monitoring feature, so runtime coverage could not be determined; verify manually." + ) + + @mock_aws + def test_runtime_monitoring_reported_disabled_still_fails(self): + """The complement of the unreported case: reported and DISABLED stays a FAIL. + + Making the unreported case MANUAL must not soften a definite absence of coverage. + """ + guardduty_client = client("guardduty", region_name=AWS_REGION_US_EAST_1) + response = guardduty_client.create_detector( + Enable=True, + Features=[{"Name": "RUNTIME_MONITORING", "Status": "DISABLED"}], + ) + + result = _run_check(set_mocked_aws_provider([AWS_REGION_US_EAST_1])) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"GuardDuty detector {response['DetectorId']} does not have Runtime Monitoring enabled." + ) + + @mock_aws + def test_legacy_eks_runtime_monitoring_only_fails(self): + """The legacy EKS-only feature covers Amazon EKS and nothing else. + + It must not PASS a check about Amazon EC2 and Amazon ECS on Fargate coverage. + """ + guardduty_client = client("guardduty", region_name=AWS_REGION_US_EAST_1) + response = guardduty_client.create_detector( + Enable=True, + Features=[ + {"Name": "EKS_RUNTIME_MONITORING", "Status": "ENABLED"}, + {"Name": "RUNTIME_MONITORING", "Status": "DISABLED"}, + ], + ) + + result = _run_check(set_mocked_aws_provider([AWS_REGION_US_EAST_1])) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"GuardDuty detector {response['DetectorId']} only has the legacy EKS Runtime Monitoring enabled, leaving Amazon EC2 instances and Amazon ECS on Fargate tasks without runtime coverage." + ) + + @mock_aws + def test_legacy_eks_only_without_any_unified_entry_fails(self): + """The real legacy shape: the unified feature is absent, not reported DISABLED. + + The two features are mutually exclusive at the API, so a detector on the legacy + one has no RUNTIME_MONITORING entry at all -- which is the same absence that makes + an unknown detector MANUAL. The test above supplies a DISABLED unified entry as + well, so it never reaches that ambiguity. Here the legacy verdict has to win on + ordering alone: EKS coverage is stated, so EC2 and Fargate are definitively + uncovered rather than undetermined. + """ + guardduty_client = client("guardduty", region_name=AWS_REGION_US_EAST_1) + response = guardduty_client.create_detector( + Enable=True, + Features=[{"Name": "EKS_RUNTIME_MONITORING", "Status": "ENABLED"}], + ) + + result = _run_check(set_mocked_aws_provider([AWS_REGION_US_EAST_1])) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"GuardDuty detector {response['DetectorId']} only has the legacy EKS Runtime Monitoring enabled, leaving Amazon EC2 instances and Amazon ECS on Fargate tasks without runtime coverage." + ) + + @mock_aws + def test_unified_enabled_with_legacy_disabled(self): + """GetDetector returns an entry for both feature names on the same detector. + + A DISABLED legacy feature must not mask the ENABLED unified one. + """ + guardduty_client = client("guardduty", region_name=AWS_REGION_US_EAST_1) + response = guardduty_client.create_detector( + Enable=True, + Features=[ + {"Name": "EKS_RUNTIME_MONITORING", "Status": "DISABLED"}, + {"Name": "RUNTIME_MONITORING", "Status": "ENABLED"}, + ], + ) + + result = _run_check(set_mocked_aws_provider([AWS_REGION_US_EAST_1])) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"GuardDuty detector {response['DetectorId']} has Runtime Monitoring enabled." + ) diff --git a/tests/providers/aws/services/guardduty/guardduty_service_test.py b/tests/providers/aws/services/guardduty/guardduty_service_test.py index 4b903ecb4f..b73a81a0be 100644 --- a/tests/providers/aws/services/guardduty/guardduty_service_test.py +++ b/tests/providers/aws/services/guardduty/guardduty_service_test.py @@ -2,6 +2,7 @@ from datetime import datetime from unittest.mock import patch import botocore +import pytest from boto3 import client from moto import mock_aws @@ -143,6 +144,142 @@ class Test_GuardDuty_Service: assert guardduty.detectors[0].region == AWS_REGION_EU_WEST_1 assert guardduty.detectors[0].tags == [{"test": "test"}] + @mock_aws + @pytest.mark.parametrize( + "feature_name", ["EKS_RUNTIME_MONITORING", "RUNTIME_MONITORING"] + ) + def test_get_detector_eks_runtime_monitoring(self, feature_name): + """Both feature names set eks_runtime_monitoring. + + Unified Runtime Monitoring supersedes EKS Runtime Monitoring and covers EKS. + """ + guardduty_client = client("guardduty", region_name=AWS_REGION_EU_WEST_1) + guardduty_client.create_detector( + Enable=True, + Features=[{"Name": feature_name, "Status": "ENABLED"}], + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + guardduty = GuardDuty(aws_provider) + + assert len(guardduty.detectors) == 1 + assert guardduty.detectors[0].eks_runtime_monitoring + + @mock_aws + @pytest.mark.parametrize( + "feature_name", ["EKS_RUNTIME_MONITORING", "RUNTIME_MONITORING"] + ) + def test_get_detector_eks_runtime_monitoring_disabled(self, feature_name): + """Neither feature name sets eks_runtime_monitoring while it is DISABLED.""" + guardduty_client = client("guardduty", region_name=AWS_REGION_EU_WEST_1) + guardduty_client.create_detector( + Enable=True, + Features=[{"Name": feature_name, "Status": "DISABLED"}], + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + guardduty = GuardDuty(aws_provider) + + assert len(guardduty.detectors) == 1 + assert not guardduty.detectors[0].eks_runtime_monitoring + + @mock_aws + def test_get_detector_unified_runtime_monitoring_with_disabled_eks_feature(self): + """GetDetector returns an entry for both feature names. + + The DISABLED legacy feature must not mask the ENABLED unified one regardless of + the order they arrive in. + """ + guardduty_client = client("guardduty", region_name=AWS_REGION_EU_WEST_1) + guardduty_client.create_detector( + Enable=True, + Features=[ + {"Name": "EKS_RUNTIME_MONITORING", "Status": "DISABLED"}, + {"Name": "RUNTIME_MONITORING", "Status": "ENABLED"}, + ], + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + guardduty = GuardDuty(aws_provider) + + assert len(guardduty.detectors) == 1 + assert guardduty.detectors[0].eks_runtime_monitoring + + @mock_aws + def test_get_detector_runtime_monitoring_is_unified_only(self): + """The legacy EKS feature must not set runtime_monitoring. + + Only the unified feature covers Amazon EC2 and Amazon ECS on Fargate. + """ + guardduty_client = client("guardduty", region_name=AWS_REGION_EU_WEST_1) + guardduty_client.create_detector( + Enable=True, + Features=[{"Name": "EKS_RUNTIME_MONITORING", "Status": "ENABLED"}], + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + guardduty = GuardDuty(aws_provider) + + assert len(guardduty.detectors) == 1 + assert guardduty.detectors[0].eks_runtime_monitoring + assert not guardduty.detectors[0].runtime_monitoring + + @mock_aws + @pytest.mark.parametrize("status", ["ENABLED", "DISABLED"]) + def test_get_detector_runtime_monitoring(self, status): + """runtime_monitoring tracks the reported status of the unified feature.""" + guardduty_client = client("guardduty", region_name=AWS_REGION_EU_WEST_1) + guardduty_client.create_detector( + Enable=True, + Features=[{"Name": "RUNTIME_MONITORING", "Status": status}], + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + guardduty = GuardDuty(aws_provider) + + assert len(guardduty.detectors) == 1 + assert guardduty.detectors[0].runtime_monitoring == (status == "ENABLED") + + @mock_aws + @pytest.mark.parametrize( + "status, expected", [("ENABLED", True), ("DISABLED", False)] + ) + def test_get_detector_ai_protection(self, status, expected): + """ai_protection is recorded as a bool for both reported statuses.""" + guardduty_client = client("guardduty", region_name=AWS_REGION_EU_WEST_1) + guardduty_client.create_detector( + Enable=True, + Features=[{"Name": "AI_PROTECTION", "Status": status}], + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + guardduty = GuardDuty(aws_provider) + + assert len(guardduty.detectors) == 1 + assert guardduty.detectors[0].ai_protection is expected + + @mock_aws + def test_get_detector_ai_protection_absent_stays_none(self): + """An AI_PROTECTION entry GuardDuty never returned must stay None. + + That is what lets a check tell a Region without AI Protection apart from a + Region that offers the feature and disabled it. + """ + guardduty_client = client("guardduty", region_name=AWS_REGION_EU_WEST_1) + guardduty_client.create_detector( + Enable=True, + Features=[ + {"Name": "S3_DATA_EVENTS", "Status": "ENABLED"}, + {"Name": "AI_ANALYST", "Status": "ENABLED"}, + ], + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + guardduty = GuardDuty(aws_provider) + + assert len(guardduty.detectors) == 1 + assert guardduty.detectors[0].ai_protection is None + @mock_aws # Test GuardDuty session def test_list_findings(self): diff --git a/tests/providers/aws/services/iam/iam_policy_no_agentcore_workload_access_token_wildcard/iam_policy_no_agentcore_workload_access_token_wildcard_test.py b/tests/providers/aws/services/iam/iam_policy_no_agentcore_workload_access_token_wildcard/iam_policy_no_agentcore_workload_access_token_wildcard_test.py new file mode 100644 index 0000000000..f3cb9156e0 --- /dev/null +++ b/tests/providers/aws/services/iam/iam_policy_no_agentcore_workload_access_token_wildcard/iam_policy_no_agentcore_workload_access_token_wildcard_test.py @@ -0,0 +1,1169 @@ +from unittest import mock + +import pytest + +from prowler.providers.aws.services.iam.iam_service import Policy +from tests.providers.aws.utils import AWS_REGION_US_EAST_1, set_mocked_aws_provider + +AWS_ACCOUNT_ID = "123456789012" +DIRECTORY_ARN = f"arn:aws:bedrock-agentcore:us-east-1:{AWS_ACCOUNT_ID}:workload-identity-directory/default" +WORKLOAD_ARN = f"{DIRECTORY_ARN}/workload-identity/my-agent-abc123" +TOKEN_VAULT_ARN = f"arn:aws:bedrock-agentcore:us-east-1:{AWS_ACCOUNT_ID}:token-vault/default/oauth2credentialprovider/my-provider" + +CHECK_MODULE = "prowler.providers.aws.services.iam.iam_policy_no_agentcore_workload_access_token_wildcard.iam_policy_no_agentcore_workload_access_token_wildcard" + + +def _policy( + statements, + name: str = "policy", + policy_type: str = "Custom", + attached: bool = True, + document_present: bool = True, +): + """Build a customer-managed Policy, with the document present unless told otherwise. + + `document_present=False` reproduces a policy whose GetPolicyVersion call failed, which the + collector leaves as a None document rather than an empty one. + """ + document = None + if document_present: + document = {"Version": "2012-10-17", "Statement": statements} + return Policy( + name=name, + arn=f"arn:aws:iam::{AWS_ACCOUNT_ID}:policy/{name}", + entity="ANPAEXAMPLEPOLICYID", + version_id="v1", + type=policy_type, + attached=attached, + document=document, + ) + + +def _run(policies: list, scan_unused_services: bool = True): + """Execute the check against the given policies and return its reports. + + The policies are model objects, so the reports exercise the check's own statement parsing + without any IAM API call. + """ + iam_client = mock.MagicMock() + iam_client.policies = {policy.arn: policy for policy in policies} + iam_client.region = AWS_REGION_US_EAST_1 + iam_client.provider = mock.MagicMock(scan_unused_services=scan_unused_services) + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam_client), + ): + from prowler.providers.aws.services.iam.iam_policy_no_agentcore_workload_access_token_wildcard.iam_policy_no_agentcore_workload_access_token_wildcard import ( + iam_policy_no_agentcore_workload_access_token_wildcard, + ) + + return iam_policy_no_agentcore_workload_access_token_wildcard().execute() + + +class Test_iam_policy_no_agentcore_workload_access_token_wildcard: + def test_no_policies(self): + """An account with no customer-managed policies produces no reports at all.""" + assert len(_run([])) == 0 + + def test_aws_managed_policy_not_evaluated(self): + """An AWS-managed policy is out of the population even when it grants the tokens. + + The fixture is BedrockAgentCoreFullAccess-shaped, so it would FAIL on merit; a zero here + therefore measures the type filter and not an absence of violations. + """ + policy = _policy( + [ + { + "Effect": "Allow", + "Action": "bedrock-agentcore:*", + "Resource": "arn:aws:bedrock-agentcore:*:*:*", + } + ], + policy_type="AWS", + ) + assert len(_run([policy])) == 0 + + def test_inline_policy_not_evaluated(self): + """An inline policy is out of the population: a separate check owns that surface.""" + policy = _policy( + [ + { + "Effect": "Allow", + "Action": "bedrock-agentcore:GetWorkloadAccessToken", + "Resource": "*", + } + ], + policy_type="Inline", + ) + assert len(_run([policy])) == 0 + + def test_unattached_policy_skipped_without_scan_unused_services(self): + """An unattached policy is judged only when scan_unused_services is on. + + Both directions are asserted from one fixture, so the flag is shown to be what decides it + rather than something about the policy. + """ + policy = _policy( + [ + { + "Effect": "Allow", + "Action": "bedrock-agentcore:GetWorkloadAccessToken", + "Resource": "*", + } + ], + attached=False, + ) + assert len(_run([policy], scan_unused_services=False)) == 0 + assert _run([policy], scan_unused_services=True)[0].status == "FAIL" + + def test_unreadable_document_is_manual(self): + """A policy whose document was never retrieved must be MANUAL, never PASS. + + An unread document is exactly where a token grant would hide, so reporting compliance from + it would assert something that was never established. + """ + policy = _policy([], name="unreadable", document_present=False) + result = _run([policy]) + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == "Custom Policy unreadable could not be evaluated because its policy document was not retrieved." + ) + + def test_policy_without_agentcore_actions_passes(self): + """A policy naming no AgentCore action at all must PASS, with its identity fields set. + + Also pins resource id, ARN and region, which the rest of the file takes for granted. + """ + policy = _policy( + [{"Effect": "Allow", "Action": "s3:GetObject", "Resource": "*"}], + name="s3_reader", + ) + result = _run([policy]) + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Custom Policy s3_reader does not allow AgentCore workload access token retrieval outside a workload identity ARN." + ) + assert result[0].resource_id == "s3_reader" + assert ( + result[0].resource_arn == f"arn:aws:iam::{AWS_ACCOUNT_ID}:policy/s3_reader" + ) + assert result[0].region == AWS_REGION_US_EAST_1 + + def test_scoped_to_workload_identity_arns_passes(self): + """Both the directory ARN and a workload-identity child ARN are an accepted scope. + + This is the shape the AgentCore console issues for a gateway's own identity, so a FAIL here + would report the product's own default as a misconfiguration. + """ + policy = _policy( + [ + { + "Sid": "GetWorkloadAccessToken", + "Effect": "Allow", + "Action": ["bedrock-agentcore:GetWorkloadAccessToken"], + "Resource": [DIRECTORY_ARN, WORKLOAD_ARN], + } + ] + ) + assert _run([policy])[0].status == "PASS" + + def test_wildcard_within_directory_namespace_passes(self): + """A wildcard confined to the workload-identity namespace is still a scope, so PASS.""" + policy = _policy( + [ + { + "Effect": "Allow", + "Action": "bedrock-agentcore:GetWorkloadAccessTokenForJWT", + "Resource": f"{DIRECTORY_ARN}/workload-identity/my-gateway-*", + } + ] + ) + assert _run([policy])[0].status == "PASS" + + def test_directory_arn_without_child_passes(self): + """The bare workload-identity-directory ARN, naming no child, is an accepted scope.""" + policy = _policy( + [ + { + "Effect": "Allow", + "Action": "bedrock-agentcore:GetWorkloadAccessTokenForUserId", + "Resource": DIRECTORY_ARN, + } + ] + ) + assert _run([policy])[0].status == "PASS" + + def test_other_agentcore_resource_type_passes(self): + """A token action pointed at a token-vault ARN reaches no workload identity, so PASS. + + The token operations accept no token-vault resource, so the grant is inert rather than + broad, and reporting it would be a false positive. + """ + policy = _policy( + [ + { + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:GetWorkloadAccessToken", + "bedrock-agentcore:GetResourceOauth2Token", + ], + "Resource": [TOKEN_VAULT_ARN], + } + ] + ) + assert _run([policy])[0].status == "PASS" + + def test_another_service_resource_passes(self): + """A token action in a statement whose resources belong to another service must PASS. + + However wide the S3 wildcard is, it names no workload identity, so the token action it sits + beside grants nothing this check is about. + """ + policy = _policy( + [ + { + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:GetWorkloadAccessToken", + "s3:GetObject", + ], + "Resource": "arn:aws:s3:::*", + } + ] + ) + assert _run([policy])[0].status == "PASS" + + def test_wildcard_across_the_whole_directory_namespace_passes(self): + """A wildcard spanning every workload identity in the directory is still in-namespace, so PASS. + + The ARN type is the granularity this check asserts; narrowing further is a different claim. + """ + policy = _policy( + [ + { + "Effect": "Allow", + "Action": "bedrock-agentcore:GetWorkloadAccessTokenForUserId", + "Resource": f"{DIRECTORY_ARN}/workload-identity/*", + } + ] + ) + assert _run([policy])[0].status == "PASS" + + def test_one_broad_resource_among_scoped_ones_fails(self): + """A single "*" beside correctly scoped ARNs must FAIL: IAM evaluates each Resource on its own.""" + policy = _policy( + [ + { + "Effect": "Allow", + "Action": "bedrock-agentcore:GetWorkloadAccessToken", + "Resource": [WORKLOAD_ARN, "*"], + } + ] + ) + assert _run([policy])[0].status == "FAIL" + + def test_wildcard_resource_fails(self): + """A token action on Resource "*" must FAIL, and the report must name the operation. + + The full sentence is asserted, so a reworded finding cannot pass silently. + """ + policy = _policy( + [ + { + "Effect": "Allow", + "Action": ["bedrock-agentcore:GetWorkloadAccessToken"], + "Resource": "*", + } + ], + name="obo_permissions", + ) + result = _run([policy]) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "Custom Policy obo_permissions allows bedrock-agentcore:GetWorkloadAccessToken " + "on resources outside a workload identity ARN, so its resources do not confine " + "token retrieval to the workload's own identity; conditions on the statement are " + "not evaluated." + ) + + def test_condition_narrowed_statement_still_fails_without_claiming_capability(self): + """A grant narrowed only by aws:ResourceTag still FAILs, and the text says why it might not. + + `_is_workload_identity_scoped` reads Resource and NotResource and never Condition, the + mirror of the Deny-side rule that a conditional Deny is not credited with removing a + permission. FAILing is deliberate conservatism -- a tag condition is not the ARN-level + scope this check asserts -- but the finding must not then assert that the holder CAN mint + tokens for other identities, which this condition may already prevent. So the sentence + claims only that the resources do not confine it, and discloses that conditions are unread. + """ + policy = _policy( + [ + { + "Effect": "Allow", + "Action": "bedrock-agentcore:GetWorkloadAccessToken", + "Resource": "*", + "Condition": { + "StringEquals": { + "aws:ResourceTag/owner": "${aws:PrincipalTag/owner}" + } + }, + } + ], + name="tag_scoped", + ) + result = _run([policy]) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "conditions on the statement are not evaluated" in result[0].status_extended + ) + assert "can mint" not in result[0].status_extended + + def test_service_wildcard_action_fails_on_all_three_operations(self): + """bedrock-agentcore:* covers all three token operations, and all three must be named. + + The order is asserted too, since the finding renders them sorted. + """ + policy = _policy( + [ + { + "Effect": "Allow", + "Action": "bedrock-agentcore:*", + "Resource": "*", + } + ], + name="agentcore_all", + ) + result = _run([policy]) + assert result[0].status == "FAIL" + assert ( + "bedrock-agentcore:GetWorkloadAccessToken, " + "bedrock-agentcore:GetWorkloadAccessTokenForJWT, " + "bedrock-agentcore:GetWorkloadAccessTokenForUserId" + ) in result[0].status_extended + + def test_operation_prefix_wildcard_fails(self): + """A prefix wildcard such as GetWorkload* reaches the token operations, so it must FAIL.""" + policy = _policy( + [ + { + "Effect": "Allow", + "Action": "bedrock-agentcore:GetWorkload*", + "Resource": "*", + } + ] + ) + assert _run([policy])[0].status == "FAIL" + + def test_all_wildcard_agentcore_arn_fails(self): + """An all-wildcard AgentCore ARN is not a workload-identity scope, so it must FAIL. + + Only the granted operation is named: the finding must not list operations the Action never + covered, which is what the negative assertion pins. + """ + policy = _policy( + [ + { + "Effect": "Allow", + "Action": "bedrock-agentcore:GetWorkloadAccessTokenForUserId", + "Resource": "arn:aws:bedrock-agentcore:*:*:*", + } + ], + name="wildcard_arn", + ) + result = _run([policy]) + assert result[0].status == "FAIL" + assert ( + "bedrock-agentcore:GetWorkloadAccessTokenForUserId" + in result[0].status_extended + ) + assert "GetWorkloadAccessTokenForJWT" not in result[0].status_extended + + def test_partial_wildcard_service_field_fails(self): + """A PARTIAL wildcard in the ARN's service field still names AgentCore, so it must FAIL. + + `bedrock-*` matches bedrock-agentcore, so with an all-wildcard resource field the grant + reaches every workload identity. An exact-membership test on that field recognised `*` but + not `bedrock-*`, and reported PASS -- the direction that does not self-correct, since a + false PASS is filed as clean and nobody looks again. The service field is now matched as an + IAM pattern, the same way the resource field already was. + """ + policy = _policy( + [ + { + "Effect": "Allow", + "Action": "bedrock-agentcore:GetWorkloadAccessToken", + "Resource": f"arn:aws:bedrock-*:us-east-1:{AWS_ACCOUNT_ID}:*", + } + ] + ) + assert _run([policy])[0].status == "FAIL" + + def test_unrelated_service_in_the_arn_still_passes(self): + """An ARN for an unrelated service must still be out of reach, so PASS. + + The guard against the fix above over-reaching: `s3` is matched as a pattern too, and must + not match bedrock-agentcore. Without this, widening the service-field test could quietly + pull every S3 grant into the check. + """ + policy = _policy( + [ + { + "Effect": "Allow", + "Action": "bedrock-agentcore:GetWorkloadAccessToken", + "Resource": f"arn:aws:s3:us-east-1:{AWS_ACCOUNT_ID}:*", + } + ] + ) + assert _run([policy])[0].status == "PASS" + + @pytest.mark.parametrize( + "resource", + [ + f"*:aws:bedrock-agentcore:us-east-1:{AWS_ACCOUNT_ID}:*", + "*:*:*:*:*:*", + "arn:aws:bedrock-agentcore:*", + "arn:aws:*", + "arn:*", + ], + ) + def test_a_star_spanning_arn_fields_still_reaches_every_workload_identity( + self, resource + ): + """A star anywhere in the ARN that can span fields must FAIL. + + Every one of these PASSed. Two separate causes, both of which made a BROADER pattern + score better than the correctly spelled six-field equivalent that already FAILed: + comparing the first field to the literal "arn" rejected the two that wildcard it, and a + fewer-than-six-fields test rejected the three that are short because a star absorbs the + rest. IAM wildcards match the colon, so none of these is narrower than `arn:aws:...:*`. + """ + policy = _policy( + [ + { + "Effect": "Allow", + "Action": "bedrock-agentcore:GetWorkloadAccessToken", + "Resource": resource, + } + ] + ) + assert _run([policy])[0].status == "FAIL" + + @pytest.mark.parametrize( + "resource", + [ + "arn:aws:bedrock-agentcore:us-west-2:*", + "arn:aws:bedrock-agentcore:eu-central-1:*", + "arn:aws-cn:bedrock-agentcore:*", + f"arn:aws:bedrock-agentcore:us-east-1:{AWS_ACCOUNT_ID}*", + "arn:aws:bedrock-agentcore:us-east-1:555555555555*", + ], + ) + def test_a_truncated_arn_reaches_workload_identities_in_any_region_or_account( + self, resource + ): + """A short starred ARN must FAIL whatever region, account or partition it names. + + The previous fix matched short ARNs against ONE concrete probe pinned to us-east-1 and + 123456789012, which made those two values load-bearing: `us-west-2:*` PASSed while the + byte-identical `us-east-1:*` FAILed, so the check cleared the common spelling and reported + only the one nobody writes. AgentCore is generally available outside us-east-1 and that + account is a documentation placeholder. + + An account PREFIX is the case that shows no probe corpus could have fixed it -- there is + nothing to enumerate -- so the short form is now decided structurally: a star in the last + spelled-out field spans every field after it, because IAM wildcards match the colon. + """ + policy = _policy( + [ + { + "Effect": "Allow", + "Action": "bedrock-agentcore:GetWorkloadAccessToken", + "Resource": resource, + } + ] + ) + assert _run([policy])[0].status == "FAIL" + + @pytest.mark.parametrize( + "resource", + [ + f"arn:aws:bedrock-agentcore:us-east-1:{AWS_ACCOUNT_ID}:workload-identity-*", + "arn:aws:bedrock-agentcore:*:*:workload-identity-directory*", + ], + ) + def test_a_resource_confined_to_the_workload_identity_namespace_passes( + self, resource + ): + """A resource confined to the workload-identity namespace PASSes however it is spelled. + + These two were ordered backwards: `workload-identity-*` FAILed while the strictly BROADER + `workload-identity-directory*` -- whose reach is a superset of it, across every account and + partition -- PASSed. The cause was a startswith test on the literal namespace prefix, which + asks whether the field BEGINS with it rather than whether it can reach outside it. Confinement + is now decided by whether the field can name a resource of another AgentCore type. + """ + policy = _policy( + [ + { + "Effect": "Allow", + "Action": "bedrock-agentcore:GetWorkloadAccessToken", + "Resource": resource, + } + ] + ) + assert _run([policy])[0].status == "PASS" + + @pytest.mark.parametrize( + "resource_field", + ["*prod-*", "*/customer-support-agent*", "*chatbot", "*-agent*"], + ) + def test_a_name_keyed_wildcard_escapes_the_namespace_and_fails( + self, resource_field + ): + """A resource field wildcarded on a NAME reaches other AgentCore types, so it must FAIL. + + `*prod-*` matches workload-identity-directory/default/workload-identity/prod-chatbot AND + .../prod-agent -- two distinct identities -- and also runtime/prod-chatbot, + gateway/prod-chatbot-gw, memory/prod-chatbot-mem, a token vault and a custom browser. It + PASSed, because confinement was decided by matching four concrete probe resources whose + example NAMES were load-bearing: token-vault/default, gateway/my-gateway, runtime/my-runtime + and one workload identity called another-workload. A field keyed on any other name matched + none of them, so the check read it as confined to the namespace and cleared it. + + The decisive pair is `*` versus `*prod-*`: the first FAILed and the second PASSed, and nothing + stated in the check separated them. Both reach a workload identity and both escape the + namespace, so both must FAIL. These names are not invented -- the AgentCore devguide's own + examples are prod-chatbot, dev-chatbot and customer-support-agent, identities it creates + automatically named after the runtime or gateway that made them, and its own example policy + wildcards on the name. + + Confinement is now decided against the enumerable list of AgentCore resource-path SEGMENTS + from AWS's service reference, because types can be enumerated and names cannot. + """ + policy = _policy( + [ + { + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:GetWorkloadAccessToken", + "bedrock-agentcore:GetWorkloadAccessTokenForJWT", + "bedrock-agentcore:GetWorkloadAccessTokenForUserId", + ], + "Resource": ( + f"arn:aws:bedrock-agentcore:us-east-1:{AWS_ACCOUNT_ID}:{resource_field}" + ), + } + ] + ) + assert _run([policy])[0].status == "FAIL" + + @pytest.mark.parametrize( + "resource_field", + [ + "?orkload-identity-directory/*", + "?orkload-identity-directory/default/workload-identity/*", + "??orkload-identity-directory/*", + "?untime/*", + "?", + ], + ) + def test_a_question_mark_consumes_one_character_not_a_span(self, resource_field): + """`?` matches EXACTLY ONE character, so a field leading with it is not a field leading with `*`. + + These PASS because none of them can name a resource of another AgentCore type: `?orkload-...` + matches only strings whose first character is arbitrary and whose remainder is the literal + namespace path, and no other resource type ends up under that shape. `?untime/*` matches nothing + at all, since no AgentCore segment is `?untime`. + + This is a regression I shipped and these fixtures are what would have caught it. The reach test + cut the field's head at the first `*` OR `?`, treating them alike, so a leading `?` emptied the + head; an empty head is compatible with every segment, so the field was read as reaching all 22 + of them and then as escaping the namespace. An exact oracle over 1345 fields found 265 false + FAILs, every one `?`-leading, and replacing the first character of the nine pinned-PASS fields + with `?` flipped nine of nine. + + Bare `?` IS here, and getting it here took two corrections. It was first reported as a defect, + then dropped because `accessanalyzer validate-policy` returns ERROR INVALID_ARN_RESOURCE for it, + then reinstated because `create-policy` ACCEPTS and stores it. That is the third time in this + campaign that validate-policy has contradicted create-policy, every time in the direction that + would have discarded a real input, so create-policy is the oracle of record. + """ + policy = _policy( + [ + { + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:GetWorkloadAccessToken", + "bedrock-agentcore:GetWorkloadAccessTokenForJWT", + "bedrock-agentcore:GetWorkloadAccessTokenForUserId", + ], + "Resource": ( + f"arn:aws:bedrock-agentcore:us-east-1:{AWS_ACCOUNT_ID}:{resource_field}" + ), + } + ] + ) + assert _run([policy])[0].status == "PASS" + + @pytest.mark.parametrize("resource_field", ["?*", "??*"]) + def test_a_question_mark_followed_by_a_star_still_reaches_everything( + self, resource_field + ): + """The FAIL direction for `?`, which no other fixture here pins. + + `?*` matches any string of length one or more, so it reaches every AgentCore resource type AND + every workload identity: it must FAIL. The four `?` cases above are all PASS cases, so nothing + pinned the direction where `?` MATCHING a character is what establishes reach. Stopping `?` + from matching anything would leave the whole suite green, because every `?` fixture reaches its + PASS verdict by a different route -- "confined" instead of "reaches nothing" -- and both routes + end in PASS. One-directional fixtures cannot separate those. + + Both spellings are storable: create-policy accepts `?*` and `??*` as resource fields. + """ + policy = _policy( + [ + { + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:GetWorkloadAccessToken", + "bedrock-agentcore:GetWorkloadAccessTokenForJWT", + "bedrock-agentcore:GetWorkloadAccessTokenForUserId", + ], + "Resource": ( + f"arn:aws:bedrock-agentcore:us-east-1:{AWS_ACCOUNT_ID}:{resource_field}" + ), + } + ] + ) + assert _run([policy])[0].status == "FAIL" + + @pytest.mark.parametrize( + "resource_field", + [ + "runtime/prod-*", + "memory/prod-chatbot*", + "gateway/*-agent", + "tool/web-search", + ], + ) + def test_a_name_keyed_wildcard_on_another_type_still_passes(self, resource_field): + """The control in the other direction: name-keyed is not by itself a finding. + + Each of these is wildcarded on a name in exactly the way the cases above are, and each reaches + NO workload identity, so each must still PASS -- the token actions accept no runtime, memory, + gateway or tool resource. Without these, making every name-keyed field FAIL would satisfy the + tests above while being just as wrong in the opposite direction, and the suite could not tell + the fix from that over-correction. + """ + policy = _policy( + [ + { + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:GetWorkloadAccessToken", + "bedrock-agentcore:GetWorkloadAccessTokenForJWT", + "bedrock-agentcore:GetWorkloadAccessTokenForUserId", + ], + "Resource": ( + f"arn:aws:bedrock-agentcore:us-east-1:{AWS_ACCOUNT_ID}:{resource_field}" + ), + } + ] + ) + assert _run([policy])[0].status == "PASS" + + @pytest.mark.parametrize( + "resource", + [ + "arn:aws:bedrock-agentcore", + "arn:aws:bedrock-agentcore:us-east-1", + "arn:aws:s3:*", + f"arn:aws:bedrock-agentcore:us-east-1:{AWS_ACCOUNT_ID}:token-vault/default", + ], + ) + def test_a_truncated_arn_carrying_no_star_reaches_nothing(self, resource): + """A short ARN that reaches nothing must still PASS. + + The guard on the two fixes above. The first two carry no star, so they match no ARN at all + and what separates them from `arn:aws:bedrock-agentcore:*` is the star, not the length. + `arn:aws:s3:*` is short AND starred, so it guards the other direction: the fields a short + pattern DOES spell out still have to be able to name an AgentCore ARN. The token-vault + resource reaches a type these actions cannot name. + """ + policy = _policy( + [ + { + "Effect": "Allow", + "Action": "bedrock-agentcore:GetWorkloadAccessToken", + "Resource": resource, + } + ] + ) + assert _run([policy])[0].status == "PASS" + + def test_partial_wildcard_service_in_the_action_fails(self): + """A PARTIAL wildcard in the ACTION's service field still names AgentCore, so it must FAIL. + + `bedrock-*:GetWorkloadAccessToken` reaches the operation, and a literal comparison on that + field read it as granting nothing at all -- so the statement was invisible to the check + whatever its Resource said. This is the same defect as the one in the resource ARN's service + field, one function above it, and independent of it. + """ + policy = _policy( + [ + { + "Effect": "Allow", + "Action": "bedrock-*:GetWorkloadAccessToken", + "Resource": "*", + } + ] + ) + assert _run([policy])[0].status == "FAIL" + + def test_wildcard_service_in_the_action_fails(self): + """`*:GetWorkloadAccessToken` names every service, AgentCore among them, so it must FAIL.""" + policy = _policy( + [ + { + "Effect": "Allow", + "Action": "*:GetWorkloadAccessToken", + "Resource": "*", + } + ] + ) + assert _run([policy])[0].status == "FAIL" + + def test_unrelated_service_in_the_action_still_passes(self): + """An action for an unrelated service must stay out of reach, so PASS. + + The guard against the two fixes above over-reaching: `s3` is matched as a pattern too and + must not match bedrock-agentcore. Without this, widening the service-field test could + quietly pull every S3 grant into the check. + """ + policy = _policy( + [{"Effect": "Allow", "Action": "s3:GetObject", "Resource": "*"}] + ) + assert _run([policy])[0].status == "PASS" + + def test_wildcard_dense_action_reaches_a_verdict(self): + """A wildcard-dense service field must reach a verdict rather than stall the scan. + + End-to-end with an adversarial Action, on the ACTION service field specifically, because + that surface exists only because of the pattern-matching fix: converting an O(1) string + comparison into a match is what made it reachable. The dense field does not name + bedrock-agentcore, so the statement grants nothing and the policy PASSes -- the point is that + a verdict arrives at all. Timing is asserted in `Test_iam_pattern_matches` instead, where the + matcher can be measured without several seconds of service construction around it. + """ + policy = _policy( + [ + { + "Effect": "Allow", + "Action": f"{'*' * 14}zzz{'*' * 14}:GetWorkloadAccessToken", + "Resource": "*", + } + ] + ) + result = _run([policy]) + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_bare_action_wildcard_is_left_to_admin_checks(self): + """A bare Action "*" is left to the administrative-privileges checks, so PASS here. + + Reporting it would duplicate check_admin_access rather than add a claim, and duplicate + findings on one policy are what make a report harder to act on. + """ + policy = _policy([{"Effect": "Allow", "Action": "*", "Resource": "*"}]) + assert _run([policy])[0].status == "PASS" + + def test_not_resource_fails(self): + """A statement using NotResource names no resource, so it is not scoped and must FAIL.""" + policy = _policy( + [ + { + "Effect": "Allow", + "Action": "bedrock-agentcore:GetWorkloadAccessToken", + "NotResource": TOKEN_VAULT_ARN, + } + ] + ) + assert _run([policy])[0].status == "FAIL" + + def test_scoped_statement_does_not_rescue_a_broad_one(self): + """A correctly scoped statement does not rescue a broad one in the same policy. + + Measured on a live gateway role. Statements are evaluated independently, so the scoped one + cannot narrow the Resource "*" one sitting beside it. + """ + policy = _policy( + [ + { + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:GetWorkloadAccessToken", + "bedrock-agentcore:GetWorkloadAccessTokenForJWT", + "bedrock-agentcore:GetWorkloadAccessTokenForUserId", + ], + "Resource": [DIRECTORY_ARN, WORKLOAD_ARN], + }, + { + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:GetPolicy", + "bedrock-agentcore:GetWorkloadAccessToken", + ], + "Resource": "*", + }, + ] + ) + result = _run([policy]) + assert result[0].status == "FAIL" + assert ( + "allows bedrock-agentcore:GetWorkloadAccessToken on resources" + in result[0].status_extended + ) + + def test_unconditional_deny_on_all_resources_clears_the_finding(self): + """An unconditional Deny of the same action on "*" removes the permission, so PASS.""" + policy = _policy( + [ + { + "Effect": "Allow", + "Action": "bedrock-agentcore:GetWorkloadAccessToken", + "Resource": "*", + }, + { + "Effect": "Deny", + "Action": "bedrock-agentcore:GetWorkloadAccessToken", + "Resource": "*", + }, + ] + ) + assert _run([policy])[0].status == "PASS" + + def test_bare_action_wildcard_pair_is_left_to_admin_checks(self): + """`Action: "*:*"` must PASS: it is check_admin_access's finding, not this one. + + The only bare-wildcard fixture was `"*"`, which the `separator != ":"` clause alone + satisfies, so the `service == "*" and operation == "*"` clause was load-bearing with zero + coverage -- deleting it left all tests green while flipping this policy to FAIL, emitting + exactly the duplicate finding the docstring says must not be emitted. + """ + policy = _policy([{"Effect": "Allow", "Action": "*:*", "Resource": "*"}]) + assert _run([policy])[0].status == "PASS" + + def test_a_single_field_pattern_is_not_an_arn(self): + """A one-field pattern with a star reaches no workload identity, so PASS. + + `myprefix*` matches no ARN at all -- an ARN starts with the literal "arn" -- but it is short + and starred, so it takes the short branch. That branch's first-field guard is what rejects + it; deleting the guard left 55 tests green while flipping this to FAIL. The six-field + branch's equivalent guard was already covered, so only the short one was unprotected. + """ + policy = _policy( + [ + { + "Effect": "Allow", + "Action": "bedrock-agentcore:GetWorkloadAccessToken", + "Resource": "myprefix*", + } + ] + ) + assert _run([policy])[0].status == "PASS" + + @pytest.mark.parametrize("deny_action", ["*", "*:*"]) + def test_a_deny_of_every_action_clears_the_finding(self, deny_action): + """A Deny of EVERY action on "*" removes these operations, so the policy must PASS. + + Both spellings reported FAIL at high severity on a policy that grants nothing -- prowler's + own effective-action resolver agrees the operation is not granted. The cause is that the + Deny side reused the Allow side's reader, which deliberately skips a bare "*" and "*:*" so + this check does not duplicate the administrative-privileges checks. On the Deny side that + exclusion inverts: skipping the broadest possible Deny credits nothing. + + It also contradicted this check's own published Notes, which say an unconditional Deny of + the operation on Resource "*" clears the finding. The Allow-side guard is untouched. + """ + policy = _policy( + [ + { + "Effect": "Allow", + "Action": "bedrock-agentcore:GetWorkloadAccessTokenForUserId", + "Resource": "*", + }, + {"Effect": "Deny", "Action": deny_action, "Resource": "*"}, + ] + ) + assert _run([policy])[0].status == "PASS" + + def test_a_deny_of_every_action_in_another_service_does_not_clear_the_finding(self): + """The guard on the fix above: a Deny of every action in a DIFFERENT service clears nothing. + + `s3:*` removes no AgentCore operation, so widening the Deny-side reader to credit any + wildcard would have cleared this policy too. It must still FAIL. + """ + policy = _policy( + [ + { + "Effect": "Allow", + "Action": "bedrock-agentcore:GetWorkloadAccessTokenForUserId", + "Resource": "*", + }, + {"Effect": "Deny", "Action": "s3:*", "Resource": "*"}, + ] + ) + assert _run([policy])[0].status == "FAIL" + + def test_deny_listing_the_wildcard_among_other_resources_clears_the_finding(self): + """A Deny whose Resource list contains "*" denies everything in it. + + The collector tests the list with any(), not all(): one "*" entry is sufficient, + because IAM evaluates each Resource independently. Under all() this policy would + stop clearing the finding and report a false FAIL. Every other Deny fixture here + uses a single Resource value, so nothing else distinguishes the two. + """ + policy = _policy( + [ + { + "Effect": "Allow", + "Action": "bedrock-agentcore:GetWorkloadAccessToken", + "Resource": "*", + }, + { + "Effect": "Deny", + "Action": "bedrock-agentcore:GetWorkloadAccessToken", + "Resource": ["*", DIRECTORY_ARN], + }, + ] + ) + assert _run([policy])[0].status == "PASS" + + def test_conditional_deny_does_not_clear_the_finding(self): + """A CONDITIONAL Deny must not clear the finding: it only bites when the condition holds. + + The Allow still stands for every request outside the condition, so the capability remains. + """ + policy = _policy( + [ + { + "Effect": "Allow", + "Action": "bedrock-agentcore:GetWorkloadAccessToken", + "Resource": "*", + }, + { + "Effect": "Deny", + "Action": "bedrock-agentcore:GetWorkloadAccessToken", + "Resource": "*", + "Condition": {"StringEquals": {"aws:PrincipalTag/team": "agents"}}, + }, + ] + ) + assert _run([policy])[0].status == "FAIL" + + def test_deny_scoped_to_one_directory_does_not_clear_the_finding(self): + """A Deny naming one directory does not answer an Allow that reaches every other one. + + This is AWS's own recommended Deny shape, so treating it as sufficient would clear a + finding on a policy that is still broad. + """ + policy = _policy( + [ + { + "Effect": "Allow", + "Action": "bedrock-agentcore:GetWorkloadAccessTokenForUserId", + "Resource": "*", + }, + { + "Sid": "DenyForUserIdAccess", + "Effect": "Deny", + "Action": "bedrock-agentcore:GetWorkloadAccessTokenForUserId", + "Resource": DIRECTORY_ARN, + }, + ] + ) + assert _run([policy])[0].status == "FAIL" + + def test_deny_of_a_different_operation_does_not_clear_the_finding(self): + """A Deny removes only the operation it names; the rest of the wildcard Allow still FAILs. + + Asserted in both directions: the denied operation must be absent from the finding and the + still-granted one present, so a Deny applied too widely or too narrowly both fail. + """ + policy = _policy( + [ + { + "Effect": "Allow", + "Action": "bedrock-agentcore:GetWorkload*", + "Resource": "*", + }, + { + "Effect": "Deny", + "Action": "bedrock-agentcore:GetWorkloadAccessTokenForUserId", + "Resource": "*", + }, + ] + ) + result = _run([policy]) + assert result[0].status == "FAIL" + assert "ForUserId" not in result[0].status_extended + assert "GetWorkloadAccessTokenForJWT" in result[0].status_extended + + def test_deny_only_policy_passes(self): + """A policy containing only a Deny grants nothing, so it must PASS.""" + policy = _policy( + [ + { + "Effect": "Deny", + "Action": "bedrock-agentcore:GetWorkloadAccessToken", + "Resource": "*", + } + ] + ) + assert _run([policy])[0].status == "PASS" + + def test_conditional_deny_only_policy_passes(self): + """A conditional Deny alone still grants nothing, so it must PASS. + + Reading Effect is what stops the statement being treated as though it allowed the action. + """ + policy = _policy( + [ + { + "Effect": "Deny", + "Action": "bedrock-agentcore:GetWorkloadAccessTokenForUserId", + "Resource": "*", + "Condition": {"StringEquals": {"aws:PrincipalTag/team": "agents"}}, + } + ] + ) + assert _run([policy])[0].status == "PASS" + + def test_single_statement_dict_is_evaluated(self): + """A Statement given as a single dict rather than a list is still evaluated. + + IAM accepts both shapes, so a check reading only lists would silently skip the policy. + """ + policy = _policy(None) + policy.document = { + "Version": "2012-10-17", + "Statement": { + "Effect": "Allow", + "Action": "bedrock-agentcore:GetWorkloadAccessToken", + "Resource": "*", + }, + } + assert _run([policy])[0].status == "FAIL" + + def test_malformed_statements_do_not_raise(self): + """Malformed statements must not raise, and must not mask the real grant beside them. + + A bare string, non-string actions, and an action with no colon all coexist with one genuine + wildcard grant, so the FAIL proves the parser survived rather than short-circuited. + """ + policy = _policy( + [ + "not-a-statement", + {"Effect": "Allow", "Action": [None, 7], "Resource": [None]}, + { + "Effect": "Allow", + "Action": "bedrock-agentcore", + "Resource": "*", + }, + { + "Effect": "Allow", + "Action": "bedrock-agentcore:GetWorkloadAccessToken", + "Resource": "*", + }, + ] + ) + assert _run([policy])[0].status == "FAIL" + + def test_action_of_another_service_matching_the_name_passes(self): + """bedrock:GetWorkloadAccessToken is a different service, so it must PASS. + + Only the bedrock-agentcore prefix is read; matching on the operation name alone would + report a policy for a service that has no such action. + """ + policy = _policy( + [ + { + "Effect": "Allow", + "Action": "bedrock:GetWorkloadAccessToken", + "Resource": "*", + } + ] + ) + assert _run([policy])[0].status == "PASS" + + def test_every_policy_gets_one_finding(self): + """Three policies get one report each, judged on their own document. + + FAIL, PASS and MANUAL from one run, so a check reporting only the first policy or carrying + a verdict between them would not produce this mapping. + """ + policies = [ + _policy( + [ + { + "Effect": "Allow", + "Action": "bedrock-agentcore:GetWorkloadAccessToken", + "Resource": "*", + } + ], + name="broad", + ), + _policy( + [ + { + "Effect": "Allow", + "Action": "bedrock-agentcore:GetWorkloadAccessToken", + "Resource": WORKLOAD_ARN, + } + ], + name="scoped", + ), + _policy([], name="unreadable", document_present=False), + ] + result = _run(policies) + assert {report.resource_id: report.status for report in result} == { + "broad": "FAIL", + "scoped": "PASS", + "unreadable": "MANUAL", + } + + +class Test_iam_policy_no_agentcore_workload_access_token_wildcard_notaction: + """NotAction under Effect Allow is IAM-valid and grants everything it does not list.""" + + def test_notaction_allow_statement_is_manual(self): + """The check reads only Action, so a NotAction statement looked like no grant at all. + + `{"Effect": "Allow", "NotAction": ["s3:*"], "Resource": "*"}` grants every action + except S3 -- including the ones this check exists to find -- while carrying no Action + key. IAM Access Analyzer ValidatePolicy accepts the shape, so it is reachable in a + stored policy. Inverting NotAction correctly means resolving it against the whole + action namespace and its interaction with Resource and NotResource, which is more + than this check can claim, so it reports the policy as unevaluated instead. + """ + policy = _policy( + [{"Effect": "Allow", "NotAction": ["s3:*"], "Resource": "*"}], + name="notaction-policy", + ) + result = _run([policy]) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "NotAction" in result[0].status_extended diff --git a/tests/providers/aws/services/iam/iam_policy_passrole_to_bedrock_agentcore_restricted/iam_policy_passrole_to_bedrock_agentcore_restricted_test.py b/tests/providers/aws/services/iam/iam_policy_passrole_to_bedrock_agentcore_restricted/iam_policy_passrole_to_bedrock_agentcore_restricted_test.py new file mode 100644 index 0000000000..1e3f1eb362 --- /dev/null +++ b/tests/providers/aws/services/iam/iam_policy_passrole_to_bedrock_agentcore_restricted/iam_policy_passrole_to_bedrock_agentcore_restricted_test.py @@ -0,0 +1,1370 @@ +from unittest import mock + +import pytest + +from prowler.providers.aws.services.iam.iam_service import Policy +from prowler.providers.aws.services.iam.lib.policy import iam_pattern_matches +from tests.providers.aws.utils import AWS_REGION_US_EAST_1, set_mocked_aws_provider + +AWS_ACCOUNT_ID = "123456789012" +# Role ARNs IAM actually returns, used to ask whether a REPORTED resource names any role at all. +# Concrete ARNs rather than a list of shapes that cannot name a role: the impossible shapes cannot +# be enumerated -- botocore models no ARN format for any service, so there is nothing to derive +# them from -- while these are simply true. Empty region, twelve-digit account, a bare name, a +# path, a service-linked path, the 64-character maximum, and the two non-commercial partitions. +REAL_ROLE_ARNS = ( + f"arn:aws:iam::{AWS_ACCOUNT_ID}:role/a", + f"arn:aws:iam::{AWS_ACCOUNT_ID}:role/AgentCoreRuntimeRole", + f"arn:aws:iam::{AWS_ACCOUNT_ID}:role/service-role/AgentCoreRuntimeRole", + f"arn:aws:iam::{AWS_ACCOUNT_ID}:role/aws-service-role/config.amazonaws.com/AWSServiceRoleForConfig", + f"arn:aws:iam::{AWS_ACCOUNT_ID}:role/" + "r0le-name-" * 6 + "abcd", + "arn:aws:iam::999999999999:role/Prod", + f"arn:aws-us-gov:iam::{AWS_ACCOUNT_ID}:role/GovRole", + f"arn:aws-cn:iam::{AWS_ACCOUNT_ID}:role/CnRole", +) +AGENTCORE_PRINCIPAL = "bedrock-agentcore.amazonaws.com" +EVALUATION_ROLE_PREFIX_ARN = "arn:aws:iam::*:role/AgentCoreEvaluationRole*" +ANY_ROLE_IN_ACCOUNT_ARN = f"arn:aws:iam::{AWS_ACCOUNT_ID}:role/*" +AGENTCORE_ACTION_STATEMENT = { + "Effect": "Allow", + "Action": ["bedrock-agentcore:CreateAgentRuntime"], + "Resource": "*", +} + +CHECK_MODULE = "prowler.providers.aws.services.iam.iam_policy_passrole_to_bedrock_agentcore_restricted.iam_policy_passrole_to_bedrock_agentcore_restricted" + + +def _policy( + statements, + name: str = "policy", + policy_type: str = "Custom", + attached: bool = True, + document_present: bool = True, +): + """Build a customer-managed Policy, with the document present unless told otherwise. + + `document_present=False` reproduces a policy whose GetPolicyVersion call failed, which the + collector leaves as a None document rather than an empty one. + """ + document = None + if document_present: + document = {"Version": "2012-10-17", "Statement": statements} + return Policy( + name=name, + arn=f"arn:aws:iam::{AWS_ACCOUNT_ID}:policy/{name}", + entity="ANPAEXAMPLEPOLICYID", + version_id="v1", + type=policy_type, + attached=attached, + document=document, + ) + + +def _passrole(resource, condition: dict = None, action="iam:PassRole") -> dict: + """Build an Allow statement for iam:PassRole, omitting Resource when it is None. + + Omitting Resource entirely is the shape a NotResource statement takes, so `None` is how a + test reaches that branch rather than a missing argument. + """ + statement = {"Effect": "Allow", "Action": action} + if resource is not None: + statement["Resource"] = resource + if condition is not None: + statement["Condition"] = condition + return statement + + +def _reach_grid() -> list: + """Every combination of the two ARN positions whose reach the check has to decide. + + Generated rather than listed so the cases are not the ones somebody thought to write down. + The region and account fields are the two an IAM role ARN constrains -- region always empty, + account always twelve digits -- so varying only those two, against a resource field that is + unambiguously unbounded, isolates the question the invariant below asks. + """ + regions = ("", "*", "?", "us-east-1") + accounts = ("", "*", AWS_ACCOUNT_ID, "12345", "????????????", "1234567890*") + return [ + f"arn:aws:iam:{region}:{account}:role/*" + for region in regions + for account in accounts + ] + + +def _passed_to(service) -> dict: + """Build a StringEquals condition pinning iam:PassedToService to the given service.""" + return {"StringEquals": {"iam:PassedToService": service}} + + +def _run(policies: list, scan_unused_services: bool = True): + """Execute the check against the given policies and return its reports. + + The policies are model objects, so the reports exercise the check's own statement parsing + without any IAM API call. + """ + iam_client = mock.MagicMock() + iam_client.policies = {policy.arn: policy for policy in policies} + iam_client.region = AWS_REGION_US_EAST_1 + # Own mock: other test files set MagicMock.provider at class level to a real AwsProvider. + iam_client.provider = mock.MagicMock(scan_unused_services=scan_unused_services) + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam_client), + ): + from prowler.providers.aws.services.iam.iam_policy_passrole_to_bedrock_agentcore_restricted.iam_policy_passrole_to_bedrock_agentcore_restricted import ( + iam_policy_passrole_to_bedrock_agentcore_restricted, + ) + + return iam_policy_passrole_to_bedrock_agentcore_restricted().execute() + + +class Test_iam_policy_passrole_to_bedrock_agentcore_restricted: + def test_no_policies(self): + """An account with no customer-managed policies produces no reports at all.""" + assert len(_run([])) == 0 + + def test_aws_managed_policy_not_evaluated(self): + """An AWS-managed policy is out of the population even when it would FAIL on merit. + + The fixture is the failing shape, so a zero measures the type filter rather than an + absence of violations. + """ + policy = _policy( + [_passrole("*", _passed_to(AGENTCORE_PRINCIPAL))], policy_type="AWS" + ) + assert len(_run([policy])) == 0 + + def test_inline_policy_not_evaluated(self): + """An inline policy is out of the population: a separate check owns that surface.""" + policy = _policy( + [_passrole("*", _passed_to(AGENTCORE_PRINCIPAL))], policy_type="Inline" + ) + assert len(_run([policy])) == 0 + + def test_unattached_policy_skipped_without_scan_unused_services(self): + """An unattached policy is judged only when scan_unused_services is on. + + Both directions are asserted from one fixture, so the flag is shown to be what decides it. + """ + policy = _policy( + [_passrole("*", _passed_to(AGENTCORE_PRINCIPAL))], attached=False + ) + assert len(_run([policy], scan_unused_services=False)) == 0 + assert _run([policy], scan_unused_services=True)[0].status == "FAIL" + + def test_unreadable_document_is_manual(self): + """A policy whose document was never retrieved must be MANUAL, never PASS. + + An unread document is where an unbounded PassRole grant would hide, so claiming compliance + from it would assert something never established. + """ + policy = _policy([], name="unreadable", document_present=False) + result = _run([policy]) + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == "Custom Policy unreadable could not be evaluated because its policy document was not retrieved." + ) + + def test_aws_reference_evaluations_policy_passes(self): + """AWS's own least-privilege example must PASS, and the report's identity fields are pinned. + + Taken verbatim from the AgentCore Evaluations prerequisites page: PassRole confined to a + role-name prefix and to the AgentCore principal. A FAIL here would report AWS's documented + guidance as a misconfiguration, which is the strongest possible false positive. + """ + policy = _policy( + [ + { + "Effect": "Allow", + "Action": [ + "bedrock-agentcore:CreateEvaluator", + "bedrock-agentcore:CreateOnlineEvaluationConfig", + "bedrock-agentcore:Evaluate", + ], + "Resource": "*", + }, + { + "Effect": "Allow", + "Action": ["iam:PassRole"], + "Resource": EVALUATION_ROLE_PREFIX_ARN, + "Condition": { + "StringEquals": {"iam:PassedToService": AGENTCORE_PRINCIPAL} + }, + }, + ], + name="AgentCoreEvaluationsLeastPrivilege", + ) + result = _run([policy]) + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + "Custom Policy AgentCoreEvaluationsLeastPrivilege does not allow iam:PassRole " + "to Bedrock AgentCore on every role." + ) + assert result[0].resource_id == "AgentCoreEvaluationsLeastPrivilege" + assert ( + result[0].resource_arn + == f"arn:aws:iam::{AWS_ACCOUNT_ID}:policy/AgentCoreEvaluationsLeastPrivilege" + ) + assert result[0].region == AWS_REGION_US_EAST_1 + + def test_wildcard_resource_pinned_to_agentcore_fails(self): + """PassRole on Resource "*" pinned to the AgentCore principal must FAIL. + + The full sentence is asserted, so a reworded finding cannot pass silently. + """ + policy = _policy( + [_passrole("*", _passed_to(AGENTCORE_PRINCIPAL))], name="pass_any_role" + ) + result = _run([policy]) + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "Custom Policy pass_any_role allows iam:PassRole to Bedrock AgentCore on every " + "role instead of the specific execution roles AgentCore is meant to run as." + ) + + def test_wildcard_resource_with_agentcore_action_in_same_policy_fails(self): + """An unpinned PassRole is in scope when the same policy also allows an AgentCore action. + + That combination is what lets one policy both create the resource and choose the role it + runs as, which is the escalation this check exists for. + """ + policy = _policy([_passrole("*"), AGENTCORE_ACTION_STATEMENT]) + assert _run([policy])[0].status == "FAIL" + + def test_wildcard_resource_without_agentcore_reach_passes(self): + """An unbounded PassRole that reaches no AgentCore action and pins no service must PASS. + + It is a real finding, but a different one: the privilege-escalation checks own it, and + reporting it here would duplicate them. + """ + policy = _policy([_passrole("*")]) + assert _run([policy])[0].status == "PASS" + + def test_passed_to_another_service_is_out_of_scope(self): + """PassRole pinned to another service stays out of scope even beside an AgentCore action. + + Measured live on SageMaker Studio policies. The condition confines the statement to + sagemaker.amazonaws.com, so it cannot hand a role to AgentCore however the rest of the + policy is shaped. + """ + policy = _policy( + [ + _passrole("*", _passed_to("sagemaker.amazonaws.com")), + AGENTCORE_ACTION_STATEMENT, + ] + ) + assert _run([policy])[0].status == "PASS" + + @pytest.mark.parametrize( + "operator", + [ + "StringEquals", + "StringEqualsIfExists", + "StringEqualsIgnoreCase", + "StringEqualsIgnoreCaseIfExists", + "StringLike", + "StringLikeIfExists", + "ForAnyValue:StringEquals", + "ForAllValues:StringEquals", + "ForAllValues:StringEqualsIfExists", + ], + ) + def test_every_spelling_of_an_agentcore_pin_fails(self, operator): + """A pin naming AgentCore must FAIL under every operator that compares the key. + + The policy carries NO other AgentCore action, so the pin is the only thing that can put + the statement in scope. That is what makes the case discriminating: an operator the check + fails to read produces no values, _targets_agentcore then treats the statement as reaching + every service and consults the document, finds no AgentCore action, and PASSes a PassRole + grant on every role in the account. Every spelling here PASSed before the allow-list. + """ + policy = _policy( + [_passrole("*", {operator: {"iam:PassedToService": AGENTCORE_PRINCIPAL}})], + name="pass_any_role", + ) + result = _run([policy]) + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "Custom Policy pass_any_role allows iam:PassRole to Bedrock AgentCore on every " + "role instead of the specific execution roles AgentCore is meant to run as." + ) + + @pytest.mark.parametrize( + "operator", + [ + "StringEquals", + "StringEqualsIgnoreCase", + "StringLike", + "ForAnyValue:StringEquals", + ], + ) + def test_a_pin_that_cannot_be_skipped_keeps_another_service_out_of_scope( + self, operator + ): + """A pin naming another service PASSes only under operators the caller cannot skip. + + The AgentCore action is present, so the document-wide fallback pulls the statement back in + unless the condition genuinely confines it. ForAnyValue is here rather than with the + defeasible spellings because AWS documents it as returning false for an absent key, so it + fails closed on an Allow. + """ + policy = _policy( + [ + _passrole( + "*", {operator: {"iam:PassedToService": "sagemaker.amazonaws.com"}} + ), + AGENTCORE_ACTION_STATEMENT, + ] + ) + assert _run([policy])[0].status == "PASS" + + @pytest.mark.parametrize( + "value", + [ + "*.bedrock-agentcore.amazonaws.com", + "*-identity.bedrock-agentcore.amazonaws.com", + "runtime-identity.*", + "*.bedrock-agentcore.*", + "bedrock-agentcore*", + ], + ) + def test_a_wildcard_value_covering_an_agentcore_principal_is_in_scope(self, value): + """A condition value that COVERS an AgentCore principal must FAIL. + + The policy allows no other AgentCore action, so the pin is the only thing that can put the + statement in scope -- and each of these PASSed. The value was probed as a pattern against + one base principal, and a regex asked whether it IS a family member; neither asked whether + it COVERS one. So a wildcard reaching runtime-identity.bedrock-agentcore.amazonaws.com read + as a pin to some other service and the statement left scope. + + The severity is in the ordering: the narrower literal FAILs and the no-condition case FAILs, + so BROADENING the grant flipped the verdict to clean. That is the direction that never + self-corrects, because a PASS is filed and nobody looks again. + """ + policy = _policy( + [_passrole("*", {"StringEquals": {"iam:PassedToService": value}})], + name="wildcard_principal", + ) + result = _run([policy]) + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "Custom Policy wildcard_principal allows iam:PassRole to Bedrock AgentCore on every " + "role instead of the specific execution roles AgentCore is meant to run as." + ) + + @pytest.mark.parametrize( + "value", + [ + "*.sagemaker.amazonaws.com", + "?.bedrock-agentcore.amazonaws.com", + "bedrock.amazonaws.com", + ], + ) + def test_a_wildcard_value_covering_no_agentcore_principal_stays_out_of_scope( + self, value + ): + """A wildcard that reaches no AgentCore principal must still PASS. + + The guard on the fix above, and the middle row is the one that matters: `?` matches exactly + one character and no principal has a single-character subdomain, so that value covers + nothing and reads as a pin elsewhere. Treating any metacharacter as coverage would report + all three, and `bedrock.amazonaws.com` shows the prefix test is not a substring test. + """ + policy = _policy( + [_passrole("*", {"StringEquals": {"iam:PassedToService": value}})] + ) + assert _run([policy])[0].status == "PASS" + + @pytest.mark.parametrize("operator", ["ArnLike", "ArnEquals", "ArnLikeIfExists"]) + def test_an_arn_operator_on_a_string_key_is_not_read_as_a_pin(self, operator): + """An ARN operator on iam:PassedToService must not be read as naming a service. + + The allow-list carries only the string operators, deliberately: iam:PassedToService is + string-typed and holds a service principal, and AWS documents it as working with the string + operators, so an ARN operator on it cannot compare meaningfully. That makes this list + shorter than the trust check's, which needs the ARN spellings because aws:SourceArn is + ARN-typed -- and a shorter list reads as an oversight unless something asserts it. + + The value here names ANOTHER service, so admitting these operators would take the statement + out of scope and PASS it. Unread, the statement reaches the no-pin path and the document + decides: FAIL beside the AgentCore action, by the same route as carrying no condition at all. + """ + policy = _policy( + [ + _passrole( + "*", {operator: {"iam:PassedToService": "sagemaker.amazonaws.com"}} + ), + AGENTCORE_ACTION_STATEMENT, + ] + ) + assert _run([policy])[0].status == "FAIL" + + @pytest.mark.parametrize( + "guard", + ["false", False], + ids=["guard-as-string", "guard-as-json-boolean"], + ) + @pytest.mark.parametrize( + "operator", + [ + "StringEqualsIfExists", + "StringLikeIfExists", + "ForAllValues:StringEquals", + "ForAllValues:StringEqualsIfExists", + ], + ) + def test_a_guarded_defeasible_pin_confines_the_service(self, operator, guard): + """A defeasible pin with a same-key Null:"false" guard must PASS, either spelling. + + The guard forces the key to be present, so the caller cannot reach AgentCore by omitting it + and the statement really is confined to sagemaker. This rescue was applied to the trust + check's collector and never here, so every one of these FAILed beside an AgentCore action -- + penalising the spelling AWS prescribes: "You should always include the Null condition + operator ... with a false value". Adding the guard to a pinned statement made the verdict + worse, which is the shape that turns hardening into a finding. + + The guard is parametrized over the STRING and the JSON BOOLEAN because IAM stores both and + hands back what it was given: create_policy with {"Null": {"iam:PassedToService": false}} + is accepted and get_policy_version returns a Python bool, unconverted. Reading only the + string left all four operators above FAILing on the hardened spelling. Measured on a + customer-managed policy, which is this check's own population -- a trust policy normalizes + the same value to "false", so this surface is the only one where the bool is observable. + """ + policy = _policy( + [ + _passrole( + "*", + { + operator: {"iam:PassedToService": "sagemaker.amazonaws.com"}, + "Null": {"iam:PassedToService": guard}, + }, + ), + AGENTCORE_ACTION_STATEMENT, + ] + ) + assert _run([policy])[0].status == "PASS" + + @pytest.mark.parametrize( + "condition", + [ + { + "StringEqualsIfExists": { + "iam:PassedToService": "bedrock-agentcore.amazonaws.com" + }, + "Null": {"iam:PassedToService": "false"}, + }, + { + "StringEqualsIfExists": { + "iam:PassedToService": "sagemaker.amazonaws.com" + }, + "Null": {"iam:PassedToService": "true"}, + }, + { + "StringEqualsIfExists": { + "iam:PassedToService": "sagemaker.amazonaws.com" + }, + "Null": {"iam:AssociatedResourceArn": "false"}, + }, + { + "StringEqualsIfExists": { + "iam:PassedToService": "sagemaker.amazonaws.com" + }, + "Null": {"iam:PassedToService": 0}, + }, + { + "StringEqualsIfExists": { + "iam:PassedToService": "sagemaker.amazonaws.com" + }, + "Null": {"iam:PassedToService": True}, + }, + { + "StringEqualsIfExists": { + "iam:PassedToService": "sagemaker.amazonaws.com" + }, + "Null": {"iam:PassedToService": ["true", "false"]}, + }, + { + "StringEqualsIfExists": { + "iam:PassedToService": "sagemaker.amazonaws.com" + }, + "Null": {"iam:PassedToService": ["false", "true"]}, + }, + ], + ids=[ + "guard-names-agentcore", + "guard-demands-absence", + "guard-on-another-key", + "guard-is-a-number", + "guard-is-boolean-true", + "guard-list-ORs-to-always-true", + "guard-list-ORs-to-always-true-reversed", + ], + ) + def test_the_guard_rescue_requires_the_right_guard(self, condition): + """The rescue must not fire on any Null block that happens to be present. + + The last two are the sharpest, because they made the check score a WEAKER policy BETTER: values + inside one condition operator are ORed by IAM, so ["true","false"] means "key absent OR key + present", which is always true and binds nothing. Reading the list with `any` credited it, so + adding the word "true" to a guard list turned a FAIL into a PASS. Both orderings are pinned + because a fix that scanned only the first element would satisfy one and not the other. Measured + on IAM's own evaluator with the key omitted: `allowed` for both orderings, indistinguishable + from carrying no Null block, against `implicitDeny` for "false", ["false"] and + ["false","false"]. And reachable rather than argued: create_policy stores the multi-value list + and get_policy_version returns it unchanged. + + Seven negatives, each failing for its own reason: the first is guarded but names AgentCore + so it is in scope on its own terms; `Null: "true"` demands the key be ABSENT, which makes the + operator vacuous by design rather than rescuing it; and a guard on a different key leaves + iam:PassedToService omissible. These mirror the trust check's cases, so the two collectors + are now pinned to one rule in both directions. + + The last two are the controls on reading a JSON boolean, and they exist because crediting a + guard turns a FAIL into a PASS -- over-recognition is the unsafe direction here. `0` must + NOT count, and what it pins is the FORMULATION: the tempting `not candidate` or + `candidate is False` reads 0, "", None and [] as guards, because isinstance(False, int) is + True in Python and falsiness is not the question being asked. Boolean `True` must not count + either, for the same reason its string spelling does not -- it demands absence, so it makes the + operator vacuous rather than rescuing it. + + An earlier version of this docstring cited Access Analyzer as drawing the same boundary. It does + not: measured, it reports TYPE_MISMATCH_BOOLEAN for "FALSE" and " false ", both of which this + check CREDITS, as well as for 0, which it does not. The boundary here is the check's own. + """ + policy = _policy([_passrole("*", condition), AGENTCORE_ACTION_STATEMENT]) + assert _run([policy])[0].status == "FAIL" + + def test_one_unskippable_operator_confines_the_statement_whatever_sits_beside_it( + self, + ): + """A defeasible operator beside a firm one must not make the statement defeasible. + + Conditions are ANDed, so `StringEquals` alone already holds the request to sagemaker + whatever the `StringEqualsIfExists` next to it does; the statement is confined and out of + scope. Every other test here uses ONE operator on the key, so none of them can tell + `all(defeasible)` from `any(defeasible)`; this mixed fixture is the input that + distinguishes them. Under `any` the pin would read as + skippable, the document-wide fallback would pull the statement back in beside the AgentCore + action, and this would FAIL. + """ + policy = _policy( + [ + _passrole( + "*", + { + "StringEquals": { + "iam:PassedToService": "sagemaker.amazonaws.com" + }, + "StringEqualsIfExists": { + "iam:PassedToService": "sagemaker.amazonaws.com" + }, + }, + ), + AGENTCORE_ACTION_STATEMENT, + ] + ) + assert _run([policy])[0].status == "PASS" + + @pytest.mark.parametrize( + "operator", + [ + "StringEqualsIfExists", + "StringLikeIfExists", + "ForAllValues:StringEquals", + "ForAllValues:StringEqualsIfExists", + ], + ) + def test_a_pin_the_caller_can_skip_does_not_confine_the_service(self, operator): + """A defeasible pin naming another service must still FAIL beside an AgentCore action. + + It names sagemaker, but the caller reaches AgentCore by omitting the key, so the statement + is not confined and scope falls to the rest of the policy. This is the same rule the + pre-existing *IfExists case asserts, extended to the ForAllValues spellings, and it is why + reading the value is not the same as crediting it as a constraint. + """ + policy = _policy( + [ + _passrole( + "*", {operator: {"iam:PassedToService": "sagemaker.amazonaws.com"}} + ), + AGENTCORE_ACTION_STATEMENT, + ] + ) + assert _run([policy])[0].status == "FAIL" + + def test_role_wildcard_arn_fails(self): + """An IAM ARN ending in role/* names every role in the account, so it must FAIL.""" + policy = _policy( + [_passrole(ANY_ROLE_IN_ACCOUNT_ARN), AGENTCORE_ACTION_STATEMENT] + ) + assert _run([policy])[0].status == "FAIL" + + def test_account_wide_wildcard_arn_fails(self): + """An IAM ARN whose whole resource field is "*" names every role, so it must FAIL.""" + policy = _policy( + [_passrole(f"arn:aws:iam::{AWS_ACCOUNT_ID}:*"), AGENTCORE_ACTION_STATEMENT] + ) + assert _run([policy])[0].status == "FAIL" + + def test_role_prefix_without_slash_fails(self): + """role* without a slash still matches every role path, so it must FAIL. + + A pattern that looks narrower than role/* is not: the wildcard swallows the separator too. + """ + policy = _policy( + [ + _passrole(f"arn:aws:iam::{AWS_ACCOUNT_ID}:role*"), + AGENTCORE_ACTION_STATEMENT, + ] + ) + assert _run([policy])[0].status == "FAIL" + + def test_named_role_passes(self): + """PassRole confined to one fully named role, path included, must PASS.""" + policy = _policy( + [ + _passrole( + f"arn:aws:iam::{AWS_ACCOUNT_ID}:role/service-role/AgentCoreRuntimeRole", + _passed_to(AGENTCORE_PRINCIPAL), + ) + ] + ) + assert _run([policy])[0].status == "PASS" + + def test_service_linked_role_path_passes(self): + """PassRole confined to a service-linked-role path must PASS. + + Measured live on AWS Config collector policies: the wildcard sits inside a path that + bounds the role set, so it is not every role. + """ + policy = _policy( + [ + _passrole( + "arn:aws:iam::*:role/aws-service-role/config.amazonaws.com/*", + _passed_to(AGENTCORE_PRINCIPAL), + ) + ] + ) + assert _run([policy])[0].status == "PASS" + + def test_role_path_wildcard_with_name_prefix_passes(self): + """A wildcard path combined with a name prefix still bounds the role set, so PASS.""" + policy = _policy( + [ + _passrole( + f"arn:aws:iam::{AWS_ACCOUNT_ID}:role/*/AgentCore-*", + _passed_to(AGENTCORE_PRINCIPAL), + ) + ] + ) + assert _run([policy])[0].status == "PASS" + + def test_multiple_resources_fail_if_any_names_every_role(self): + """One resource naming every role makes the statement FAIL despite a scoped sibling. + + IAM evaluates each Resource independently, so a narrow entry cannot rescue a broad one. + """ + policy = _policy( + [ + _passrole( + [EVALUATION_ROLE_PREFIX_ARN, ANY_ROLE_IN_ACCOUNT_ARN], + _passed_to(AGENTCORE_PRINCIPAL), + ) + ] + ) + assert _run([policy])[0].status == "FAIL" + + @pytest.mark.parametrize( + "resource", + [ + f"arn:aws:iam::{AWS_ACCOUNT_ID}:role/?*", + f"arn:aws:iam::{AWS_ACCOUNT_ID}:*role*", + "arn:aws:iam::*", + f"*:aws:iam::{AWS_ACCOUNT_ID}:role/*", + ], + ) + def test_a_resource_that_names_every_role_without_being_all_stars_fails( + self, resource + ): + """Every resource that names every role must FAIL, not only a run of asterisks. + + All four PASSed. `role/?*` names every role with at least one character and `*role*` every + role/... resource there is; `arn:aws:iam::*` has a star spanning the account and resource + fields, and the last is a wildcarded partition, which the old regex could not express + because it was anchored on a literal `arn:`. None of these is narrower than `role/*`, which + FAILed correctly all along. + """ + policy = _policy([_passrole(resource), AGENTCORE_ACTION_STATEMENT]) + assert _run([policy])[0].status == "FAIL" + + @pytest.mark.parametrize( + "resource", + [ + "arn:aws:iam::555555555555*", + f"arn:aws:iam::{AWS_ACCOUNT_ID}*", + "arn:aws-us-gov:iam::*", + "arn:aws-cn:iam::*", + "arn:aws-us-gov:*", + # Four fields with an EMPTY region head, which the region test must not catch: the + # star spans the region onward, and `?` matches the colon. + "arn:aws:iam:*", + "arn:aws:iam:?*", + ], + ) + def test_a_truncated_arn_names_every_role_in_any_partition_or_account( + self, resource + ): + """A short starred IAM ARN must FAIL whatever partition or account it names. + + The previous fix matched short ARNs against probes pinned to partition `aws` and account + 123456789012, so those two values became load-bearing in the SHORT branch while the + six-field branch ignored both. `arn:aws:iam::555555555555*` was therefore read as specific + while the identical shape in the probe's own account FAILed, and the two non-commercial + partitions were read as specific because no probe carried them. + + An account PREFIX is what shows no probe corpus could fix this: there is nothing to + enumerate. Decided structurally now -- a star in the last spelled-out field spans every field + after it, so the resource name is unbounded and every role is named. + """ + policy = _policy([_passrole(resource), AGENTCORE_ACTION_STATEMENT]) + assert _run([policy])[0].status == "FAIL" + + @pytest.mark.parametrize( + "resource", + [ + f"arn:aws:iam::{AWS_ACCOUNT_ID}:role/?", + "arn:aws:iam::*:role/AgentCoreEvaluationRole*", + "arn:aws:iam", + "arn:aws:iam::role/Prod*", + # The same shape one colon short, so the literal head lands in the REGION position + # instead of the account one. Every IAM ARN has an empty region, so each of these + # matches no role ARN at all -- and each read as naming EVERY role until the region + # position was tested, drawing a high-severity FAIL on a pattern reaching nothing. + "arn:aws:iam:role/Prod*", + "arn:aws:iam:us-east-1*", + "arn:aws:iam:r?le/Prod*", + # A resource that is not an IAM ARN at all names no role, so it cannot name every role. + # Each of these four reaches a different rejection: the partition-prefix and service + # fields are tested in the short branch and again in the six-field one, and none of the + # four was exercised before. iam:PassRole on an S3 ARN is nonsense a real policy can + # still carry, since IAM stores any syntactically valid ARN. + "xyz:aws:iam:*", + "arn:aws:s3:*", + f"xyz:aws:iam::{AWS_ACCOUNT_ID}:role/*", + "arn:aws:s3:::amzn-s3-demo-bucket/*", + ], + ) + def test_a_resource_that_names_a_bounded_set_of_roles_passes(self, resource): + """A resource covering some roles but not all of them must still PASS. + + The guard on the fix above, and the reason the rule is not "contains a metacharacter": + `role/?` matches exactly one character so it names single-character roles only, the + prefix form is the scope AWS's own AgentCore Evaluations reference policy uses, and + `arn:aws:iam` carries no star so it matches no ARN at all. Widening the fix to catch + `role/?*` by treating any metacharacter as unbounded would report all three. + + The last three are the four-field spellings. `arn:aws:iam::role/Prod*` above puts + `role/Prod` in the ACCOUNT position, where the digits test rejects it; one colon fewer + puts it in the REGION position, which went untested. A region literal is included because + it is the shape an operator plausibly writes, and IAM has no region either way. + """ + policy = _policy([_passrole(resource), AGENTCORE_ACTION_STATEMENT]) + assert _run([policy])[0].status == "PASS" + + @pytest.mark.parametrize( + "resource", + [ + f"arn:aws:iam:us-east-1:{AWS_ACCOUNT_ID}:role/*", + f"arn:aws:iam:?:{AWS_ACCOUNT_ID}:role/*", + f"arn:aws:iam:us-east-1:{AWS_ACCOUNT_ID}:role/?*", + "arn:aws:iam::12345:role/*", + "arn:aws:iam:::role/*", + "arn:aws:iam::?????:role/*", + ], + ) + def test_a_six_field_arn_that_names_no_role_passes(self, resource): + """A fully spelled-out ARN naming no role must PASS, as the short spellings already do. + + The six-field branch applied neither the region test nor the account one, so every resource + here drew a high-severity privilege-escalation FAIL on a pattern matching no role ARN at + all -- the same defect the four- and five-field branches had already been fixed for, + surviving in the branch that was not re-read. + + Every IAM ARN has an EMPTY region, so `us-east-1` in that position names nothing, and `?` + names nothing either: unlike the short branch, this field is delimited on both sides, so + there is no colon for `?` to match and it must consume one character of a region that has + none. An account is twelve digits, so `12345`, the empty field and five `?` each name no + account however the resource field is spelled. + """ + policy = _policy([_passrole(resource), AGENTCORE_ACTION_STATEMENT]) + assert _run([policy])[0].status == "PASS" + + @pytest.mark.parametrize( + "resource", + [ + f"arn:aws:iam:*:{AWS_ACCOUNT_ID}:role/*", + "arn:aws:iam::1234567890*:role/*", + ANY_ROLE_IN_ACCOUNT_ARN, + "arn:aws:iam::????????????:role/*", + "arn:aws:iam::12345678901?:role/*", + "arn:aws:iam:*:*:role/*", + ], + ) + def test_a_six_field_arn_that_names_every_role_still_fails(self, resource): + """The guard on the fix above: the region and account tests must reject only unnameable + shapes, never a wildcard that spans a real region or account. + + `*` matches the empty region IAM ARNs carry, an account prefix followed by a star spans + every account sharing it, and twelve `?` spans every account there is -- one `?` short of + twelve names none, which is the pair that pins the width rule rather than assuming it. + `ANY_ROLE_IN_ACCOUNT_ARN` is here because it FAILed correctly before the fix and must go on + doing so: it is the shape the check exists to report. + """ + policy = _policy([_passrole(resource), AGENTCORE_ACTION_STATEMENT]) + assert _run([policy])[0].status == "FAIL" + + def test_every_reported_resource_names_at_least_one_real_role(self): + """A resource this check reports must name some role. Anything else is a finding on nothing. + + The general form of the defect the two tests above pin by example. Rather than enumerating + ARN shapes that can name no role -- which cannot be derived, since botocore models no ARN + format for any service -- this asks the question the other way round: a resource the check + reports as naming EVERY role must match at least one ARN that IAM really returns. A + privilege-escalation FAIL on a pattern matching nothing is a false report whatever spelling + produced it, and this catches every such spelling at once instead of the six now listed. + + Measured on the state this branch was reviewed at: all 24 grid shapes were reported and 16 + of them matched none of REAL_ROLE_ARNS. Now 8 are reported and none is unreachable. The + two tests above name six of those 16 by hand; this covers the rest without listing them. + + ONE DIRECTION ONLY, and worth stating plainly: this cannot see the opposite error, a + resource that names every role while the check clears it. That is the more dangerous + direction and it needs a different invariant, which this does not supply. + """ + by_arn = {} + policies = [] + for index, resource in enumerate(_reach_grid()): + policy = _policy( + [_passrole(resource), AGENTCORE_ACTION_STATEMENT], + name=f"reach-grid-{index}", + ) + by_arn[policy.arn] = resource + policies.append(policy) + + reported = [ + by_arn[report.resource_arn] + for report in _run(policies) + if report.status == "FAIL" + ] + unreachable = [ + resource + for resource in reported + if not any(iam_pattern_matches(resource, arn) for arn in REAL_ROLE_ARNS) + ] + assert not unreachable, ( + f"reported as naming every role, but matching none of the " + f"{len(REAL_ROLE_ARNS)} role ARNs IAM really returns: {unreachable}" + ) + # Guard on the assertion above, which says nothing about a resource the check clears: if + # the grid stopped being reported at all the invariant would hold vacuously. Any positive + # floor proves that; 6 sits below today's 8 so a legitimate narrowing of the check does not + # trip it, while still being far enough from 0 that the invariant cannot go quiet. + assert len(reported) >= 6, ( + f"only {len(reported)} of {len(_reach_grid())} grid shapes were reported, so the " + f"invariant above examined almost nothing -- the grid or the check has drifted" + ) + + def test_iam_service_wildcard_action_fails(self): + """iam:* covers iam:PassRole, so the statement must FAIL.""" + policy = _policy( + [_passrole("*", _passed_to(AGENTCORE_PRINCIPAL), action="iam:*")] + ) + assert _run([policy])[0].status == "FAIL" + + def test_iam_action_prefix_wildcard_fails(self): + """iam:Pass* reaches iam:PassRole, so the statement must FAIL.""" + policy = _policy( + [_passrole("*", _passed_to(AGENTCORE_PRINCIPAL), action=["iam:Pass*"])] + ) + assert _run([policy])[0].status == "FAIL" + + def test_wildcard_service_in_the_agentcore_action_brings_the_policy_into_scope( + self, + ): + """`bedrock-*:CreateAgentRuntime` is AgentCore reach, so an unpinned PassRole beside it FAILs. + + This is the consequential half of the same wildcard defect: `bedrock-*` is a plausible thing + to write, since one prefix covers bedrock and bedrock-agentcore together. A literal + comparison read it as no AgentCore reach, which left the unpinned PassRole statement out of + scope entirely and the policy PASSed. + """ + policy = _policy( + [ + _passrole("*"), + { + "Effect": "Allow", + "Action": ["bedrock-*:CreateAgentRuntime"], + "Resource": "*", + }, + ] + ) + assert _run([policy])[0].status == "FAIL" + + def test_wildcard_service_in_the_passrole_action_fails(self): + """`*:PassRole` names every service, IAM among them, so it covers iam:PassRole. + + Legal but implausible on its own; fixed for consistency with the AgentCore-reach test above, + since both read a service field and one line each removes a known false PASS. + """ + policy = _policy( + [_passrole("*", _passed_to(AGENTCORE_PRINCIPAL), action="*:PassRole")] + ) + assert _run([policy])[0].status == "FAIL" + + def test_unrelated_service_in_the_action_stays_out_of_scope(self): + """An unrelated service in either action field must not create reach or coverage, so PASS. + + The guard against the two fixes above over-reaching: `s3` is matched as a pattern and must + match neither `iam` nor `bedrock-agentcore`. + """ + policy = _policy( + [ + _passrole("*", action="s3:GetObject"), + {"Effect": "Allow", "Action": "s3:PutObject", "Resource": "*"}, + ] + ) + assert _run([policy])[0].status == "PASS" + + def test_bare_action_wildcard_is_left_to_admin_checks(self): + """A bare Action "*" is left to the administrative-privileges checks, so PASS here. + + Reporting it would duplicate check_admin_access rather than add a claim. + """ + policy = _policy([{"Effect": "Allow", "Action": "*", "Resource": "*"}]) + assert _run([policy])[0].status == "PASS" + + def test_bare_action_wildcard_is_not_the_agentcore_evidence(self): + """A bare Action "*" is not evidence that the policy reaches AgentCore. + + Were it counted, every administrator policy would be pulled into this check through the + AgentCore-reach test rather than through a real AgentCore grant. + """ + policy = _policy( + [ + _passrole("*"), + { + "Effect": "Allow", + "Action": "*", + "Resource": "arn:aws:s3:::my-bucket", + }, + ] + ) + assert _run([policy])[0].status == "PASS" + + def test_other_iam_action_is_not_passrole(self): + """An IAM action that is not PassRole must not put the statement in scope.""" + policy = _policy( + [_passrole("*", action="iam:GetRole"), AGENTCORE_ACTION_STATEMENT] + ) + assert _run([policy])[0].status == "PASS" + + def test_passed_to_service_wildcard_is_in_scope(self): + """iam:PassedToService pinned to "*" pins nothing, so the statement is in scope and FAILs.""" + policy = _policy([_passrole("*", _passed_to("*"))]) + assert _run([policy])[0].status == "FAIL" + + def test_passed_to_service_subdomain_principal_is_in_scope(self): + """A subdomain principal in the AgentCore family is still AgentCore, so it FAILs. + + runtime-identity.bedrock-agentcore.amazonaws.com reaches IAM as AgentCore does, so + matching only the base principal would let the subdomain form through. + """ + policy = _policy( + [ + _passrole( + "*", _passed_to("runtime-identity.bedrock-agentcore.amazonaws.com") + ) + ] + ) + assert _run([policy])[0].status == "FAIL" + + @pytest.mark.parametrize( + "value,expected", + [ + ("browser-tool.bedrock-agentcore.amazonaws.com", "FAIL"), + ("code-interpreter.bedrock-agentcore.amazonaws.com", "FAIL"), + ("browser-tool.bedrock-agentcore.example.com", "PASS"), + ], + ids=["browser-tool", "code-interpreter", "wrong-suffix"], + ) + def test_a_subdomain_principal_no_probe_enumerates_is_in_scope( + self, value, expected + ): + """A literal subdomain principal outside the probe list must still FAIL. + + The third row is the one that pins ANCHORING rather than membership: it differs from the + first in one field, so a family regex that lost its trailing `$` would return the same + verdict for both and only this pair can tell them apart. The first two rows are positives + that no probe enumerates, so together the parameters exercise both mechanisms. + + This is what the family regex is for, and the test above can no longer show it: the + principal it uses became one of AGENTCORE_PRINCIPAL_PROBES when coverage matching was + added, so the probes alone now carry it and deleting the regex changes nothing it asserts. + + `browser-tool.` is not enumerated anywhere, so with the regex gone this value matches no + probe -- a literal pattern matches only itself -- and the statement would leave scope. The + two mechanisms answer different questions and each needs one input only it can decide. + """ + policy = _policy([_passrole("*", _passed_to(value))]) + assert _run([policy])[0].status == expected + + def test_bare_action_wildcard_pair_is_left_to_admin_checks(self): + """`Action: "*:*"` must PASS: it is check_admin_access's finding, not this one. + + The only bare-wildcard fixture was `"*"`, which the `separator != ":"` clause alone + satisfies, so the `service == "*" and operation == "*"` clause was load-bearing with zero + coverage -- deleting it left every test green while flipping this policy to FAIL. + """ + policy = _policy( + [_passrole("*", _passed_to(AGENTCORE_PRINCIPAL), action="*:*")] + ) + assert _run([policy])[0].status == "PASS" + + def test_an_account_head_of_question_marks_still_names_every_role(self): + """`arn:aws:iam::?*` names every role, so it must FAIL. + + The account head is "?", which `.replace("?", "")` reduces to empty and therefore accepts as + account-shaped; the star then spans every field after it. Dropping that strip judged the head + non-numeric and declared the pattern specific, with all 87 tests still green. + """ + policy = _policy([_passrole("arn:aws:iam::?*"), AGENTCORE_ACTION_STATEMENT]) + assert _run([policy])[0].status == "FAIL" + + def test_a_naming_convention_scope_is_not_every_role(self): + """`role/*-*` is a naming convention, not every role, so it must PASS. + + It matches the 64-character probe but not "role/a", which is exactly what the shortest-name + probe exists to catch. Dropping that probe left the suite green and reported this scope as + "every role". + """ + policy = _policy( + [ + _passrole(f"arn:aws:iam::{AWS_ACCOUNT_ID}:role/*-*"), + AGENTCORE_ACTION_STATEMENT, + ] + ) + assert _run([policy])[0].status == "PASS" + + def test_passed_to_service_list_with_one_agentcore_value_is_in_scope(self): + """A PassedToService list containing one AgentCore value is in scope and FAILs. + + The other value being a different service must not rescue it: any one value that can name + AgentCore is sufficient. + """ + policy = _policy( + [ + _passrole( + "*", + _passed_to(["sagemaker.amazonaws.com", AGENTCORE_PRINCIPAL]), + ) + ] + ) + assert _run([policy])[0].status == "FAIL" + + def test_negated_operator_does_not_pin_the_service(self): + """StringNotEquals inverts the match, so it pins the statement to no service at all. + + Scope then falls to the rest of the policy, which allows an AgentCore action, so it FAILs. + Crediting a negated operator as a pin would clear a statement that confines nothing. + """ + policy = _policy( + [ + _passrole( + "*", + { + "StringNotEquals": { + "iam:PassedToService": "sagemaker.amazonaws.com" + } + }, + ), + AGENTCORE_ACTION_STATEMENT, + ] + ) + assert _run([policy])[0].status == "FAIL" + + def test_if_exists_operator_does_not_pin_the_service(self): + """An *IfExists operator is skipped when the caller omits the key, so it pins nothing. + + Scope falls to the rest of the policy and the statement FAILs, for the same reason as the + negated operator above. + """ + policy = _policy( + [ + _passrole( + "*", + { + "StringEqualsIfExists": { + "iam:PassedToService": "sagemaker.amazonaws.com" + } + }, + ), + AGENTCORE_ACTION_STATEMENT, + ] + ) + assert _run([policy])[0].status == "FAIL" + + def test_associated_resource_arn_does_not_bound_the_role_set(self): + """iam:AssociatedResourceArn bounds the target resource, not the set of passable roles. + + So a statement carrying it alongside Resource "*" still hands AgentCore every role and + must FAIL: the two conditions constrain different things. + """ + policy = _policy( + [ + _passrole( + "*", + { + "StringEquals": {"iam:PassedToService": AGENTCORE_PRINCIPAL}, + "ArnLike": { + "iam:AssociatedResourceArn": "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/*" + }, + }, + ) + ] + ) + assert _run([policy])[0].status == "FAIL" + + def test_not_resource_fails(self): + """A statement using NotResource leaves every role outside the exclusion passable, so FAIL.""" + statement = _passrole(None, _passed_to(AGENTCORE_PRINCIPAL)) + statement["NotResource"] = f"arn:aws:iam::{AWS_ACCOUNT_ID}:role/BreakGlass" + assert _run([_policy([statement])])[0].status == "FAIL" + + def test_statement_without_resource_or_not_resource_passes(self): + """A statement naming neither Resource nor NotResource grants no resource, so PASS.""" + policy = _policy([_passrole(None, _passed_to(AGENTCORE_PRINCIPAL))]) + assert _run([policy])[0].status == "PASS" + + def test_unconditional_deny_of_passrole_everywhere_clears_the_finding(self): + """An unconditional Deny of iam:PassRole on "*" removes the permission, so PASS.""" + policy = _policy( + [ + _passrole("*", _passed_to(AGENTCORE_PRINCIPAL)), + {"Effect": "Deny", "Action": "iam:PassRole", "Resource": "*"}, + ] + ) + assert _run([policy])[0].status == "PASS" + + @pytest.mark.parametrize("deny_action", ["*", "*:*"]) + def test_a_deny_of_every_action_clears_the_finding(self, deny_action): + """A Deny of EVERY action on "*" removes iam:PassRole, so the policy must PASS. + + Both spellings reported FAIL on a policy granting no PassRole at all. The Deny side reused + the Allow side's reader, which skips a bare "*" and "*:*" so this check does not duplicate + the administrative-privileges checks; on the Deny side that exclusion inverts and the + broadest possible Deny credited nothing. The Allow-side guard is deliberately untouched, + since relaxing it would reverse that settled decision. + """ + policy = _policy( + [ + _passrole("*", _passed_to(AGENTCORE_PRINCIPAL)), + {"Effect": "Deny", "Action": deny_action, "Resource": "*"}, + ] + ) + assert _run([policy])[0].status == "PASS" + + def test_a_deny_of_every_action_in_another_service_does_not_clear_the_finding(self): + """The guard on the fix above: `s3:*` removes no iam:PassRole, so this must still FAIL.""" + policy = _policy( + [ + _passrole("*", _passed_to(AGENTCORE_PRINCIPAL)), + {"Effect": "Deny", "Action": "s3:*", "Resource": "*"}, + ] + ) + assert _run([policy])[0].status == "FAIL" + + def test_deny_listing_the_wildcard_among_other_resources_clears_the_finding(self): + """A Deny whose Resource list contains "*" denies PassRole everywhere. + + _denies_passrole_everywhere tests the list with any(), not all(): IAM evaluates each + Resource independently, so one "*" entry is sufficient. Under all() this policy stops + clearing the finding and the check reports a false FAIL. Every other Deny fixture + supplies a single Resource string, so nothing else distinguishes the two operators. + """ + policy = _policy( + [ + _passrole("*", _passed_to(AGENTCORE_PRINCIPAL)), + { + "Effect": "Deny", + "Action": "iam:PassRole", + "Resource": ["*", ANY_ROLE_IN_ACCOUNT_ARN], + }, + ] + ) + assert _run([policy])[0].status == "PASS" + + def test_condition_block_that_is_not_a_mapping_is_skipped(self): + """A Condition operator whose block is not a mapping must be stepped over. + + The guard is `not isinstance(operator, str) or not isinstance(block, dict)`. Under + `and` it only skips when *both* are malformed, so a valid operator with a string + block reaches `block.items()` and raises AttributeError -- which the framework + swallows, turning the check into one that silently reports nothing. Policy documents + come from the account, so a malformed block is account input rather than a + hypothetical. + """ + policy = _policy([_passrole("*", {"StringEquals": "iam:PassedToService"})]) + result = _run([policy]) + # What this pins is that the check still REACHES a verdict. The malformed block + # yields no iam:PassedToService value, so no statement is a relevant AgentCore + # grant and the policy passes. Under `and` the guard stops firing, block.items() + # raises AttributeError, the framework swallows it and the check reports nothing at + # all -- so the finding count is the assertion that matters here. + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_conditional_deny_does_not_clear_the_finding(self): + """A CONDITIONAL Deny must not clear the finding: it only bites when the condition holds. + + The Allow still stands for every request outside the condition, so the capability remains. + """ + policy = _policy( + [ + _passrole("*", _passed_to(AGENTCORE_PRINCIPAL)), + { + "Effect": "Deny", + "Action": "iam:PassRole", + "Resource": "*", + "Condition": {"StringEquals": {"aws:PrincipalTag/team": "agents"}}, + }, + ] + ) + assert _run([policy])[0].status == "FAIL" + + def test_deny_scoped_to_one_role_does_not_clear_the_finding(self): + """A Deny naming one role does not answer an Allow that reaches every other one.""" + policy = _policy( + [ + _passrole("*", _passed_to(AGENTCORE_PRINCIPAL)), + { + "Effect": "Deny", + "Action": "iam:PassRole", + "Resource": f"arn:aws:iam::{AWS_ACCOUNT_ID}:role/BreakGlass", + }, + ] + ) + assert _run([policy])[0].status == "FAIL" + + def test_deny_only_policy_passes(self): + """A policy containing only a Deny grants nothing, so it must PASS.""" + policy = _policy( + [{"Effect": "Deny", "Action": "iam:PassRole", "Resource": "*"}] + ) + assert _run([policy])[0].status == "PASS" + + def test_conditional_deny_only_policy_passes(self): + """A conditional Deny alone still grants nothing, so it must PASS. + + Reading Effect is what stops the statement being treated as though it allowed PassRole. + """ + policy = _policy( + [ + { + "Effect": "Deny", + "Action": "iam:PassRole", + "Resource": "*", + "Condition": { + "StringEquals": {"iam:PassedToService": AGENTCORE_PRINCIPAL} + }, + } + ] + ) + assert _run([policy])[0].status == "PASS" + + def test_single_statement_dict_is_evaluated(self): + """A Statement given as a single dict rather than a list is still evaluated. + + IAM accepts both shapes, so a check reading only lists would silently skip the policy. + """ + policy = _policy(None) + policy.document = { + "Version": "2012-10-17", + "Statement": _passrole("*", _passed_to(AGENTCORE_PRINCIPAL)), + } + assert _run([policy])[0].status == "FAIL" + + def test_malformed_statements_do_not_raise(self): + """Malformed statements must not raise, and must not mask the real grant beside them. + + A bare string, non-string actions, an action with no colon and a non-mapping Condition all + coexist with one genuine unbounded grant, so the FAIL proves the parser survived rather + than short-circuited. + """ + policy = _policy( + [ + "not-a-statement", + {"Effect": "Allow", "Action": [None, 7], "Resource": [None]}, + {"Effect": "Allow", "Action": "iam", "Resource": "*"}, + {"Effect": "Allow", "Action": "iam:PassRole", "Condition": "broken"}, + _passrole("*", _passed_to(AGENTCORE_PRINCIPAL)), + ] + ) + assert _run([policy])[0].status == "FAIL" + + def test_every_policy_gets_one_finding(self): + """Three policies get one report each, judged on their own document. + + FAIL, PASS and MANUAL from one run, so a check reporting only the first policy or carrying + a verdict between them would not produce this mapping. + """ + policies = [ + _policy([_passrole("*", _passed_to(AGENTCORE_PRINCIPAL))], name="broad"), + _policy( + [ + _passrole( + EVALUATION_ROLE_PREFIX_ARN, _passed_to(AGENTCORE_PRINCIPAL) + ) + ], + name="scoped", + ), + _policy([], name="unreadable", document_present=False), + ] + result = _run(policies) + assert {report.resource_id: report.status for report in result} == { + "broad": "FAIL", + "scoped": "PASS", + "unreadable": "MANUAL", + } + + +class Test_iam_policy_passrole_to_bedrock_agentcore_restricted_notaction: + """NotAction under Effect Allow is IAM-valid and grants everything it does not list.""" + + def test_notaction_allow_statement_is_manual(self): + """The check reads only Action, so a NotAction statement looked like no grant at all. + + `{"Effect": "Allow", "NotAction": ["s3:*"], "Resource": "*"}` grants every action + except S3 -- including the ones this check exists to find -- while carrying no Action + key. IAM Access Analyzer ValidatePolicy accepts the shape, so it is reachable in a + stored policy. Inverting NotAction correctly means resolving it against the whole + action namespace and its interaction with Resource and NotResource, which is more + than this check can claim, so it reports the policy as unevaluated instead. + """ + policy = _policy( + [{"Effect": "Allow", "NotAction": ["s3:*"], "Resource": "*"}], + name="notaction-policy", + ) + result = _run([policy]) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "NotAction" in result[0].status_extended diff --git a/tests/providers/aws/services/iam/iam_role_service_trust_restricts_source_to_account/iam_role_service_trust_restricts_source_to_account_test.py b/tests/providers/aws/services/iam/iam_role_service_trust_restricts_source_to_account/iam_role_service_trust_restricts_source_to_account_test.py new file mode 100644 index 0000000000..66e96c571d --- /dev/null +++ b/tests/providers/aws/services/iam/iam_role_service_trust_restricts_source_to_account/iam_role_service_trust_restricts_source_to_account_test.py @@ -0,0 +1,1414 @@ +from unittest import mock + +import pytest + +from prowler.providers.aws.services.iam.iam_service import Role +from tests.providers.aws.utils import AWS_REGION_US_EAST_1, set_mocked_aws_provider + +AWS_ACCOUNT_ID = "123456789012" +OTHER_ACCOUNT_ID = "999988887777" +SERVICE = "sagemaker.amazonaws.com" +SCOPED_ARN = f"arn:aws:sagemaker:us-east-1:{AWS_ACCOUNT_ID}:training-job/my-job" +# An S3 bucket ARN has an EMPTY account field, which is why AWS documents needing +# aws:SourceAccount alongside it. +BUCKET_ARN = "arn:aws:s3:::amzn-s3-demo-bucket" + +CHECK_MODULE = "prowler.providers.aws.services.iam.iam_role_service_trust_restricts_source_to_account.iam_role_service_trust_restricts_source_to_account" + + +def _trust_policy(statements: list) -> dict: + """Wrap statements in a trust policy document.""" + return {"Version": "2012-10-17", "Statement": statements} + + +def _service_statement(condition: dict = None, principal: dict = None) -> dict: + """Build an Allow of sts:AssumeRole to a service principal, condition optional. + + Omitting the condition produces the wholly-unconditional shape, which this check treats as + out of scope; `principal` overrides the default so a test can build a hybrid principal. + """ + statement = { + "Effect": "Allow", + "Principal": principal if principal is not None else {"Service": SERVICE}, + "Action": "sts:AssumeRole", + } + if condition is not None: + statement["Condition"] = condition + return statement + + +def _role(name: str, statements: list, arn: str = None, is_service_role: bool = True): + """Build a Role carrying the given trust statements. + + `arn` is overridable because the service-linked exclusion is keyed on the ARN path, not on + `is_service_role`. + """ + return Role( + name=name, + arn=arn or f"arn:aws:iam::{AWS_ACCOUNT_ID}:role/{name}", + assume_role_policy=_trust_policy(statements), + is_service_role=is_service_role, + tags=[], + ) + + +def _run(roles: list): + """Execute the check against the given roles and return its reports. + + The roles are model objects, so the reports exercise the check's own trust-policy parsing + without any IAM API call. + """ + iam_client = mock.MagicMock() + iam_client.roles = roles + iam_client.region = AWS_REGION_US_EAST_1 + iam_client.audited_account = AWS_ACCOUNT_ID + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + aws_provider.identity.account = AWS_ACCOUNT_ID + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam_client), + ): + from prowler.providers.aws.services.iam.iam_role_service_trust_restricts_source_to_account.iam_role_service_trust_restricts_source_to_account import ( + iam_role_service_trust_restricts_source_to_account, + ) + + return iam_role_service_trust_restricts_source_to_account().execute() + + +ACCOUNT_ONLY = {"StringEquals": {"aws:SourceAccount": AWS_ACCOUNT_ID}} +ARN_ONLY = {"ArnLike": {"aws:SourceArn": SCOPED_ARN}} +BOTH_SCOPED = { + "StringEquals": {"aws:SourceAccount": AWS_ACCOUNT_ID}, + "ArnLike": {"aws:SourceArn": SCOPED_ARN}, +} + + +class Test_iam_role_service_trust_restricts_source_to_account: + def test_no_roles(self): + """An account with no roles produces no reports at all.""" + assert len(_run([])) == 0 + + def test_unlisted_roles_produce_no_reports(self): + # iam:ListRoles denied leaves iam_client.roles as None. + assert len(_run(None)) == 0 + + def test_service_linked_role_skipped(self): + """A service-linked role is excluded even when its trust policy would FAIL. + + Its trust relationship is managed by the service and cannot be edited, so a finding would + not be actionable. The fixture is a failing one, so the zero measures the exclusion rather + than the scope gate. + """ + role = _role( + "AWSServiceRoleForAmazonSageMaker", + [ + _service_statement( + {"ArnLike": {"aws:SourceArn": "arn:aws:sagemaker:*:*:*"}} + ) + ], + arn=f"arn:aws:iam::{AWS_ACCOUNT_ID}:role/aws-service-role/{SERVICE}/AWSServiceRoleForAmazonSageMaker", + ) + assert len(_run([role])) == 0 + + def test_role_without_service_principal_skipped(self): + """A role trusting only an AWS principal is out of scope: no service is involved.""" + role = _role( + "human-role", + [ + { + "Effect": "Allow", + "Principal": {"AWS": f"arn:aws:iam::{AWS_ACCOUNT_ID}:root"}, + "Action": "sts:AssumeRole", + } + ], + is_service_role=False, + ) + assert len(_run([role])) == 0 + + def test_non_assume_role_action_skipped(self): + """A statement granting only sts:TagSession cannot be used to assume the role, so no finding.""" + role = _role( + "tag-only-role", + [ + { + "Effect": "Allow", + "Principal": {"Service": SERVICE}, + "Action": "sts:TagSession", + } + ], + ) + assert len(_run([role])) == 0 + + def test_wildcard_action_that_cannot_reach_assume_role_skipped(self): + """A wildcard action whose prefix does not lead to sts:AssumeRole is out of scope. + + `sts:Tag*` ends in a wildcard but expands to no assume-role action, so the statement + grants nobody the ability to assume this role and belongs outside the population. The + condition is present and enforced but pins no source, so were the statement wrongly + admitted the role would FAIL -- which is what makes 0 findings here a real assertion + rather than an absence that any exclusion would produce. `sts:TagSession` alone cannot + make this claim: it carries no wildcard, so it is excluded by the literal test that + precedes the wildcard test and leaves the wildcard branch unexercised. + """ + role = _role( + "tag-wildcard-role", + [ + { + "Effect": "Allow", + "Principal": {"Service": SERVICE}, + "Action": "sts:Tag*", + "Condition": { + "StringEquals": {"aws:PrincipalOrgID": "o-abcdefghij"} + }, + } + ], + ) + assert len(_run([role])) == 0 + + @pytest.mark.parametrize( + "action", + ["sts:*Role", "sts:A*Role", "sts:Assume?ole", "sts:AssumeRol?", "sts:As*me*le"], + ) + def test_a_wildcard_not_at_the_end_still_grants_assume_role(self, action): + """Every IAM spelling that reaches sts:AssumeRole must bring the statement into scope. + + A literal tuple plus a trailing-star test recognised only a star at the END, so each of + these granted the action while the statement fell out of the evaluated population and the + role produced NO REPORT. The condition here is present and enforced but pins no source, so + an admitted statement FAILs -- which is what makes this a verdict assertion rather than an + absence any exclusion would produce. + """ + role = _role( + "midstar-role", + [ + { + "Effect": "Allow", + "Principal": {"Service": SERVICE}, + "Action": action, + "Condition": { + "StringEquals": {"aws:PrincipalOrgID": "o-abcdefghij"} + }, + } + ], + ) + result = _run([role]) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_a_midstar_statement_beside_a_scoped_one_is_not_a_pass(self): + """The two-statement form of the miss above, which was an affirmative false PASS. + + The unscoped statement went unseen, leaving only the properly scoped one in the + population, so the check asserted the role confines every service principal to an account + while a second statement trusted one with no source binding at all. Silence would have + been better than this; the full sentence is asserted so the claim cannot be made quietly. + """ + role = _role( + "midstar-and-scoped-role", + [ + { + "Effect": "Allow", + "Principal": {"Service": SERVICE}, + "Action": "sts:*Role", + "Condition": { + "StringEquals": {"aws:PrincipalOrgID": "o-abcdefghij"} + }, + }, + _service_statement( + {"StringEquals": {"aws:SourceAccount": AWS_ACCOUNT_ID}} + ), + ], + ) + result = _run([role]) + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "IAM Role midstar-and-scoped-role trusts an AWS service principal without confining " + "the request source, since no condition pins aws:SourceAccount to a literal account " + "ID, aws:SourceArn to an ARN carrying one, or aws:SourceOrgID or " + "aws:SourceOrgPaths to an organization." + ) + + @pytest.mark.parametrize( + "statement", + [ + {"Effect": "Allow", "Principal": {"Service": SERVICE}, "Action": None}, + { + "Effect": "Allow", + "Principal": {"Service": None}, + "Action": "sts:AssumeRole", + }, + ], + ids=["null-action", "null-service"], + ) + def test_a_present_but_null_key_does_not_abort_the_check(self, statement): + """A key present with a null value must not raise out of execute(). + + `.get("Action", [])` returns None for `"Action": null` rather than the default, and + iterating None raises TypeError, which check.py's bare except cannot usefully contain: it + discards every finding for the ACCOUNT, not one role. Both sibling checks normalise + through _as_list and were immune; this one had two such reads. IAM rejects these documents + so the state is unreachable through GetRole -- the assertion is that the check behaves like + its siblings, not that a real policy can reach it. + """ + assert len(_run([_role("null-key-role", [statement])])) == 0 + + def test_deny_statement_skipped(self): + """A Deny statement grants nothing, so a trust policy of only Denies yields no finding.""" + role = _role( + "deny-role", + [ + { + "Effect": "Deny", + "Principal": {"Service": SERVICE}, + "Action": "sts:AssumeRole", + } + ], + ) + assert len(_run([role])) == 0 + + def test_empty_trust_policy_skipped(self): + """A role whose trust policy is empty has no statement to evaluate.""" + role = Role( + name="empty-role", + arn=f"arn:aws:iam::{AWS_ACCOUNT_ID}:role/empty-role", + assume_role_policy={}, + is_service_role=True, + tags=[], + ) + assert len(_run([role])) == 0 + + # ---------------- SCOPE: the wholly-unconditional posture is not ours ---------------- + + def test_plain_service_role_without_any_condition_is_out_of_scope(self): + """The fully-unprotected posture belongs to the sibling check, not to this one. + + Reporting it here would make this check a strict superset of + iam_role_cross_service_confused_deputy_prevention: measured against 366 real roles, + 195 of its 199 findings are exactly this shape. Staying silent is what keeps the + two checks complementary. + """ + assert len(_run([_role("unscoped-role", [_service_statement()])])) == 0 + + def test_deny_statement_brings_the_allow_statement_into_scope(self): + """A Deny statement puts the policy outside every existing service-role check. + + Modelled on a real role: upstream's is_service_role requires Effect == "Allow" on + EVERY statement, so adding a Deny hardening statement silently removes the role + from the sibling's evaluated population. No other check assesses it. + """ + role = _role( + "allow-plus-deny-role", + [ + _service_statement(), + { + "Effect": "Deny", + "Principal": {"Service": SERVICE}, + "Action": "sts:AssumeRole", + "Condition": {"Null": {"aws:RequestTag/Attr": "false"}}, + }, + ], + ) + findings = _run([role]) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + + def test_non_assume_action_statement_brings_the_policy_into_scope(self): + """Modelled on a real role whose second statement grants sts:SetContext. + + is_service_role matches its action list by substring against sts:AssumeRole, sts:* + and *, none of which appear in "sts:SetContext", so the whole role drops out of the + sibling's population. + """ + role = _role( + "assume-plus-setcontext-role", + [ + _service_statement(), + { + "Effect": "Allow", + "Principal": {"Service": SERVICE}, + "Action": "sts:SetContext", + }, + ], + ) + findings = _run([role]) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + + # ---------------- FAIL: the source is not confined to an account ---------------- + + def test_source_arn_without_account_field_alone_fails(self): + """A bucket ARN pins the resource but not the account -- AWS requires both here.""" + role = _role( + "bucket-arn-role", + [_service_statement({"ArnLike": {"aws:SourceArn": BUCKET_ARN}})], + ) + findings = _run([role]) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + + def test_source_arn_global_wildcard_fails(self): + """aws:SourceArn of "*" pins nothing, so the source is unconfined and it FAILs.""" + role = _role( + "wildcard-arn-role", + [_service_statement({"ArnLike": {"aws:SourceArn": "*"}})], + ) + assert _run([role])[0].status == "FAIL" + + def test_source_arn_wildcard_account_fails(self): + """An ARN whose account field is a wildcard confines the caller to no account, so FAIL.""" + role = _role( + "wildcard-account-arn-role", + [ + _service_statement( + {"ArnLike": {"aws:SourceArn": "arn:aws:sagemaker:*:*:*"}} + ) + ], + ) + assert _run([role])[0].status == "FAIL" + + def test_source_arn_list_with_one_accountless_value_fails(self): + """One accountless value in a SourceArn list is enough to FAIL. + + Every value must bear an account: IAM satisfies the condition with any one of them, so a + scoped sibling does not narrow the accountless entry. + """ + role = _role( + "mixed-arn-list-role", + [ + _service_statement( + {"ArnLike": {"aws:SourceArn": [SCOPED_ARN, BUCKET_ARN]}} + ) + ], + ) + assert _run([role])[0].status == "FAIL" + + def test_source_account_wildcard_value_fails(self): + """aws:SourceAccount of "*" names no account, so it FAILs.""" + role = _role( + "wildcard-account-role", + [_service_statement({"StringLike": {"aws:SourceAccount": "*"}})], + ) + assert _run([role])[0].status == "FAIL" + + def test_source_account_partial_value_fails(self): + """A partial account value such as 1234* is not a literal 12-digit account, so it FAILs.""" + role = _role( + "partial-account-role", + [_service_statement({"StringLike": {"aws:SourceAccount": "1234*"}})], + ) + assert _run([role])[0].status == "FAIL" + + @pytest.mark.parametrize( + "condition", + [ + { + "StringEquals": {"aws:SourceAccount": AWS_ACCOUNT_ID}, + "StringLike": {"aws:SourceAccount": "*"}, + }, + { + "StringEquals": {"aws:SourceOrgID": "o-abcdefghij"}, + "StringEqualsIgnoreCase": {"aws:SourceOrgID": "O-ABCDEFGHIJ"}, + }, + { + "ArnEquals": {"aws:SourceArn": SCOPED_ARN}, + "ArnLike": {"aws:SourceArn": "arn:aws:sagemaker:*:*:*"}, + }, + ], + ids=[ + "account-plus-broad-stringlike", + "org-id-pair", + "arn-equals-plus-arn-like", + ], + ) + def test_a_second_operator_cannot_make_a_pinned_statement_fail(self, condition): + """Adding an ANDed operator must never turn a confined statement into a finding. + + Every value was pooled into one list and then required to qualify, so a statement pinning + the account with StringEquals FAILed as soon as a broader StringLike sat beside it -- while + the same statement with that operator DELETED PASSed. An ANDed operator can only narrow the + set of requests that satisfy the statement, so no addition can make it less confined. + + The org-id pair carries no wildcard at all, which shows the cause was the pooling and not + the wildcard. It needs the second value differently cased to reproduce, and that is not + contrivance: ORGANIZATION_ID_PATTERN is case-sensitive, so an upper-case org ID is exactly + what StringEqualsIgnoreCase is FOR, and pooling it beside the exact pin dragged the whole + statement down. An identically-cased pair does not reproduce it -- I wrote that fixture + first and the control showed it passing before the fix, measuring nothing. + """ + role = _role("pooled-operators-role", [_service_statement(condition)]) + assert _run([role])[0].status == "PASS" + + def test_two_values_under_one_operator_must_both_qualify(self): + """Within a single operator, one unqualified value still FAILs. + + The guard on the fix above. IAM lets a request match ANY value listed under one operator, + so a real account ID beside "*" confines nothing -- and it would be easy to fix the pooling + by relaxing this from all() to any(), which would clear exactly this policy. + """ + role = _role( + "mixed-values-role", + [ + _service_statement( + {"StringLike": {"aws:SourceAccount": [AWS_ACCOUNT_ID, "*"]}} + ) + ], + ) + assert _run([role])[0].status == "FAIL" + + def test_if_exists_operator_is_not_a_binding(self): + """*IfExists is skipped when the calling service omits the key, so it binds nothing. + + On a plain service role this is out of scope -- an unenforced operator leaves the + statement wholly unconstrained, which is the sibling's finding. The assertion that + *IfExists is not a binding is made on a role that IS in scope, below. + """ + role = _role( + "ifexists-role", + [ + _service_statement( + {"StringEqualsIfExists": {"aws:SourceAccount": AWS_ACCOUNT_ID}} + ) + ], + ) + assert len(_run([role])) == 0 + + def test_if_exists_operator_fails_when_in_scope(self): + """*IfExists binds nothing, and on an in-scope role that is a FAIL rather than silence. + + The hybrid principal is what puts the role in scope, so this makes the assertion the plain + service role above cannot: that an unenforced operator is not a binding. + """ + role = _role( + "ifexists-hybrid-role", + [ + _service_statement( + {"StringEqualsIfExists": {"aws:SourceAccount": AWS_ACCOUNT_ID}}, + principal={ + "Service": SERVICE, + "AWS": f"arn:aws:iam::{OTHER_ACCOUNT_ID}:root", + }, + ) + ], + ) + assert _run([role])[0].status == "FAIL" + + def test_negated_operator_is_not_a_binding(self): + """StringNotEquals inverts the match, so on a plain service role the statement is unconstrained. + + That wholly-unconstrained posture belongs to the sibling check, so this yields no finding. + """ + role = _role( + "negated-role", + [ + _service_statement( + {"StringNotEquals": {"aws:SourceAccount": OTHER_ACCOUNT_ID}} + ) + ], + ) + assert len(_run([role])) == 0 + + def test_negated_operator_fails_when_in_scope(self): + """A negated operator on an in-scope role FAILs rather than passing as a binding.""" + role = _role( + "negated-hybrid-role", + [ + _service_statement( + {"StringNotEquals": {"aws:SourceAccount": OTHER_ACCOUNT_ID}}, + principal={ + "Service": SERVICE, + "AWS": f"arn:aws:iam::{OTHER_ACCOUNT_ID}:root", + }, + ) + ], + ) + assert _run([role])[0].status == "FAIL" + + def test_unrelated_condition_key_fails(self): + """A condition on an unrelated key is present but confines no source, so it FAILs. + + aws:PrincipalOrgID constrains who calls, not which resource's service call it came from, + which is the confused-deputy question. + """ + role = _role( + "org-principal-role", + [ + _service_statement( + {"StringEquals": {"aws:PrincipalOrgID": "o-abc123defg"}} + ) + ], + ) + assert _run([role])[0].status == "FAIL" + + def test_hybrid_principal_unconditional_fails(self): + """The blind spot: a Service principal paired with a cross-account AWS principal.""" + role = _role( + "hybrid-role", + [ + _service_statement( + principal={ + "Service": SERVICE, + "AWS": f"arn:aws:iam::{OTHER_ACCOUNT_ID}:root", + } + ) + ], + ) + findings = _run([role]) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + + def test_wildcard_action_unconditional_is_out_of_scope(self): + """An unconditional Action:* grant is the sibling's most severe finding, not ours.""" + role = _role( + "wildcard-action-role", + [{"Effect": "Allow", "Principal": {"Service": SERVICE}, "Action": "*"}], + ) + assert len(_run([role])) == 0 + + def test_scoped_statement_beside_an_unconditional_one_is_out_of_scope(self): + """Modelled on two real AgentCore gateway roles in the measured account. + + A redundant unconditional statement sitting beside a properly scoped one is a real + finding, but it is the sibling's: the unconditional statement has no constraint at + all. Both of those roles appear in the sibling's FAIL set. + """ + role = _role( + "partially-scoped-role", + [_service_statement(BOTH_SCOPED), _service_statement()], + ) + findings = _run([role]) + assert len(findings) == 1 + assert findings[0].status == "PASS" + + def test_one_conditioned_but_unbound_statement_of_many_fails(self): + """A present-but-insufficient constraint on any one statement is ours to report.""" + role = _role( + "partially-scoped-role", + [ + _service_statement(BOTH_SCOPED), + _service_statement({"ArnLike": {"aws:SourceArn": BUCKET_ARN}}), + ], + ) + findings = _run([role]) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + + # ---------------- PASS: the source is confined to an account ---------------- + + def test_source_account_alone_passes(self): + """aws:SourceAccount alone confines the caller to one account.""" + findings = _run( + [_role("account-only-role", [_service_statement(ACCOUNT_ONLY)])] + ) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "confines every AWS service principal" in findings[0].status_extended + + def test_account_bearing_source_arn_alone_passes(self): + """AWS calls a full aws:SourceArn the most effective protection -- no SourceAccount needed.""" + findings = _run([_role("arn-only-role", [_service_statement(ARN_ONLY)])]) + assert len(findings) == 1 + assert findings[0].status == "PASS" + + def test_account_bearing_source_arn_with_arn_equals_passes(self): + """ArnEquals is an enforcing operator, so an account-bearing SourceArn under it PASSes.""" + role = _role( + "arn-equals-role", + [_service_statement({"ArnEquals": {"aws:SourceArn": SCOPED_ARN}})], + ) + assert _run([role])[0].status == "PASS" + + def test_account_bearing_source_arn_with_resource_wildcard_passes(self): + """arn:...::* still confines the caller to that account.""" + role = _role( + "arn-resource-wildcard-role", + [ + _service_statement( + { + "ArnLike": { + "aws:SourceArn": f"arn:aws:sagemaker:us-east-1:{AWS_ACCOUNT_ID}:*" + } + } + ) + ], + ) + assert _run([role])[0].status == "PASS" + + def test_bucket_arn_with_source_account_passes(self): + """An accountless bucket ARN paired with aws:SourceAccount PASSes. + + This is AWS's documented remedy for ARNs that carry no account field: the two keys together + confine the source even though neither does alone. + """ + role = _role( + "bucket-arn-scoped-role", + [ + _service_statement( + { + "ArnLike": {"aws:SourceArn": BUCKET_ARN}, + "StringEquals": {"aws:SourceAccount": AWS_ACCOUNT_ID}, + } + ) + ], + ) + assert _run([role])[0].status == "PASS" + + def test_source_account_with_a_same_key_null_guard_passes(self): + """AWS's prescribed pairing -- a comparison plus Null:"false" on the same key -- must PASS. + + `Null` is a presence test, not a comparison, so it pins no value. Reading it as one put the + literal string "false" beside a real account ID, and _pins_source_account requires EVERY + value to be a 12-digit account, so the shape test failed and the role FAILed. That made the + more secure policy score worse than the same policy without the guard, which is the worst + possible direction for a check to be wrong in. + """ + role = _role( + "account-with-null-guard", + [ + _service_statement( + { + "StringEquals": {"aws:SourceAccount": AWS_ACCOUNT_ID}, + "Null": {"aws:SourceAccount": "false"}, + } + ) + ], + ) + assert _run([role])[0].status == "PASS" + + def test_null_guard_alone_pins_nothing_and_fails(self): + """A Null guard with no comparison beside it confines the source to nothing, so FAIL. + + The guard forces the key to be present; it says nothing about which account. Crediting it + would clear a statement bound to no account at all, and the guard's own marker value is not + an account ID. + """ + role = _role( + "null-only", + [_service_statement({"Null": {"aws:SourceAccount": "false"}})], + ) + assert _run([role])[0].status == "FAIL" + + def test_for_all_values_without_a_null_guard_fails(self): + """Unguarded ForAllValues is vacuous on an Allow, so it must not be credited. + + AWS documents that it "returns true if there are no context keys in the request", with an + explicit warning against pairing it with an Allow effect -- and this check evaluates Allow + statements only, so that is the reachable case. A caller who simply omits aws:SourceAccount + satisfies it, which is the same vacuity *IfExists has; both are credited only under the + Null:"false" guard, and neither without it. + """ + role = _role( + "for-all-values-unguarded", + [ + _service_statement( + {"ForAllValues:StringEquals": {"aws:SourceAccount": AWS_ACCOUNT_ID}} + ) + ], + ) + assert _run([role])[0].status == "FAIL" + + def test_for_all_values_with_a_same_key_null_guard_passes(self): + """ForAllValues paired with Null:"false" on the SAME key is not vacuous, so it PASSes. + + The guard forces the key to be present, which removes the no-context-keys escape. Refusing + the guarded spelling outright would be wrong rather than merely strict: aws:SourceOrgPaths is + multivalued, so a set operator is the only correct way to write a binding for it. The guard + must be on the same key -- one on a different key leaves this one vacuous. + """ + role = _role( + "for-all-values-guarded", + [ + _service_statement( + { + "ForAllValues:StringEquals": { + "aws:SourceAccount": AWS_ACCOUNT_ID + }, + "Null": {"aws:SourceAccount": "false"}, + } + ) + ], + ) + assert _run([role])[0].status == "PASS" + + def test_for_all_values_with_a_null_guard_on_a_different_key_fails(self): + """A Null guard rescues only the key it names, so a guard elsewhere leaves this one vacuous. + + The guard on aws:SourceArn forces THAT key to be present; it says nothing about + aws:SourceAccount, so the ForAllValues binding on the account is still satisfied by a caller + who simply omits it. The same-key test above cannot make this assertion: it uses one key for + both, so a rule requiring a guard on ANY key would pass it just as happily. + """ + role = _role( + "for-all-values-guard-on-another-key", + [ + _service_statement( + { + "ForAllValues:StringEquals": { + "aws:SourceAccount": AWS_ACCOUNT_ID + }, + "Null": {"aws:SourceArn": "false"}, + } + ) + ], + ) + assert _run([role])[0].status == "FAIL" + + @pytest.mark.parametrize( + "guard", + [["true", "false"], ["false", "true"]], + ids=["ors-to-always-true", "ors-to-always-true-reversed"], + ) + def test_a_null_guard_list_containing_true_rescues_nothing(self, guard): + """A Null list containing "true" binds NOTHING, because IAM ORs the values in one operator. + + `Null: {aws:SourceAccount: ["true","false"]}` reads as "key absent OR key present", which is + always satisfied, so it cannot force the key to be present and cannot rescue the ForAllValues + binding beside it. Reading the list with `any` credited it anyway, which meant ADDING the word + "true" to a guard list moved the verdict from FAIL to PASS -- a strictly weaker policy scoring + better, which is the same inversion this check was already fixed for once. + + Measured on IAM's own evaluator with the key omitted: `allowed` for both orderings, + indistinguishable from carrying no Null block at all, against `implicitDeny` for "false", + ["false"] and ["false","false"]. Reachable on THIS surface specifically: create_role stores + ["true","false"] and get_role returns it as a list, even though a single-element ["false"] is + collapsed to the scalar "false" here -- which is why the multi-value spelling is the one that + matters and the single-value one cannot be tested end-to-end. + + Both orderings are pinned because a fix reading only the first element would satisfy one of + them and not the other. + """ + role = _role( + "null-guard-list-with-true", + [ + _service_statement( + { + "ForAllValues:StringEquals": { + "aws:SourceAccount": AWS_ACCOUNT_ID + }, + "Null": {"aws:SourceAccount": guard}, + } + ) + ], + ) + assert _run([role])[0].status == "FAIL" + + def test_null_guard_demanding_the_key_be_absent_does_not_rescue_for_all_values( + self, + ): + """`Null: "true"` demands the key be ABSENT, which makes ForAllValues vacuous by design. + + Only `"false"` forces presence. Accepting any value as a guard would read this policy -- which + requires aws:SourceAccount NOT to be supplied -- as though it required the opposite, and the + two guarded tests above use `"false"` so neither distinguishes the values. + """ + role = _role( + "for-all-values-null-true", + [ + _service_statement( + { + "ForAllValues:StringEquals": { + "aws:SourceAccount": AWS_ACCOUNT_ID + }, + "Null": {"aws:SourceAccount": "true"}, + } + ) + ], + ) + assert _run([role])[0].status == "FAIL" + + @pytest.mark.parametrize( + "condition", + [ + { + "StringEqualsIfExists": {"aws:SourceAccount": AWS_ACCOUNT_ID}, + "Null": {"aws:SourceAccount": "false"}, + }, + { + "ArnLikeIfExists": {"aws:SourceArn": SCOPED_ARN}, + "Null": {"aws:SourceArn": "false"}, + }, + { + "ForAllValues:StringLikeIfExists": { + "aws:SourceAccount": AWS_ACCOUNT_ID + }, + "Null": {"aws:SourceAccount": "false"}, + }, + ], + ids=["string-equals", "arn-like", "for-all-values-string-like"], + ) + def test_a_guarded_if_exists_operator_is_a_binding(self, condition): + """A guarded *IfExists must PASS, exactly as a guarded ForAllValues does. + + *IfExists and ForAllValues are two spellings of one trap -- a caller who omits the key + satisfies both -- and `Null: "false"` forces the key to be present, which defeats the + vacuity identically in either. Rescuing only ForAllValues meant these three FAILed while + `ForAllValues:StringEquals` with the identical guard PASSed. That asymmetry punished + policies for the operator they chose rather than for what they constrain, and + secretsmanager_has_restrictive_resource_policy already accepts IfExists paired with Null. + """ + role = _role("guarded-ifexists", [_service_statement(condition)]) + assert _run([role])[0].status == "PASS" + + @pytest.mark.parametrize( + "condition", + [ + { + "StringEqualsIfExists": {"aws:SourceAccount": AWS_ACCOUNT_ID}, + "Null": {"aws:SourceArn": "false"}, + }, + { + "StringEqualsIfExists": {"aws:SourceAccount": AWS_ACCOUNT_ID}, + "Null": {"aws:SourceAccount": "true"}, + }, + ], + ids=["guard-on-a-different-key", "guard-demanding-absence"], + ) + def test_an_if_exists_operator_needs_the_same_guard_for_all_values_needs( + self, condition + ): + """The rescue must require the SAME guard, not merely the presence of a Null block. + + A guard on a different key leaves aws:SourceAccount omissible, and `Null: "true"` demands + the key be absent, which makes the operator vacuous by design rather than rescuing it. Both + mirror the ForAllValues cases above, so the two families are now pinned to one rule in both + directions -- without these, "credit a guarded IfExists" could be satisfied by any Null + block anywhere in the statement. + """ + role = _role("ifexists-bad-guard", [_service_statement(condition)]) + assert _run([role])[0].status == "FAIL" + + @pytest.mark.parametrize( + "condition", + [ + {"StringLike": {"aws:SourceAccount": "1234*"}}, + {"ForAllValues:StringEquals": {"aws:SourceAccount": AWS_ACCOUNT_ID}}, + {"Null": {"aws:SourceAccount": "false"}}, + ], + ids=["partial-value", "unguarded-for-all-values", "null-guard-only"], + ) + def test_the_fail_message_does_not_claim_the_key_is_unset(self, condition): + """The FAIL sentence must describe what was measured, on inputs that SET the key. + + All three of these set aws:SourceAccount, so the previous wording -- "it sets neither + aws:SourceAccount nor an account-bearing aws:SourceArn" -- was false of every one of them. + What the code measures is that no condition PINS the key to a literal value of the right + shape, which is weaker and true. The string is asserted in full because a status_extended + change is invisible to a suite that only checks the status, and it ships in every CSV and + OCSF row. + """ + role = _role("fail-wording", [_service_statement(condition)]) + result = _run([role]) + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "IAM Role fail-wording trusts an AWS service principal without confining the request " + "source, since no condition pins aws:SourceAccount to a literal account ID, " + "aws:SourceArn to an ARN carrying one, or aws:SourceOrgID or aws:SourceOrgPaths to an " + "organization." + ) + + @pytest.mark.parametrize( + "condition", + [ + {"StringEquals": {"aws:SourceAccount": AWS_ACCOUNT_ID}}, + {"ArnLike": {"aws:SourceArn": SCOPED_ARN}}, + ], + ids=["source-account", "account-bearing-source-arn"], + ) + def test_the_account_pass_message_is_used_for_account_scoping(self, condition): + """An account-confined PASS keeps the account sentence.""" + role = _role("account-pass", [_service_statement(condition)]) + result = _run([role]) + assert result[0].status == "PASS" + assert result[0].status_extended == ( + "IAM Role account-pass confines every AWS service principal in its trust policy to a " + "specific account." + ) + + @pytest.mark.parametrize( + "condition", + [ + {"StringEquals": {"aws:SourceOrgID": "o-abcdefghij"}}, + { + "StringLike": { + "aws:SourceOrgPaths": "o-abcdefghij/r-abc1/ou-abc1-12345678/" + } + }, + ], + ids=["source-org-id", "source-org-paths"], + ) + def test_an_org_scoped_pass_does_not_claim_a_specific_account(self, condition): + """An organization-confined PASS must NOT claim confinement to a specific account. + + Both of these reach PASS through _pins_source_organization, and an organization may hold + hundreds of accounts. The account sentence fired verbatim on them, telling an operator + something categorically stronger than the check verified -- and over-claiming on a PASS is + the worse direction, because a PASS is filed as clean and nobody looks again. + """ + role = _role("org-pass", [_service_statement(condition)]) + result = _run([role]) + assert result[0].status == "PASS" + assert result[0].status_extended == ( + "IAM Role org-pass confines every AWS service principal in its trust policy, but at " + "least one statement is scoped to an organization rather than to a single account, so " + "the trusted source may be any account within it." + ) + assert "to a specific account" not in result[0].status_extended + + @pytest.mark.parametrize( + "value", + ["*", "o-abcdefghij", "not-an-ou-path"], + ids=["star", "org-id-not-a-path", "junk"], + ) + def test_a_source_org_paths_value_that_is_not_an_ou_path_fails(self, value): + """aws:SourceOrgPaths must hold an OU PATH, so these three must FAIL. + + Replacing the org-paths arm's shape test with `bool(org_paths)` left the whole suite green + while a statement carrying `aws:SourceOrgPaths: "*"` reported PASS -- the same false PASS the + o-* negative already prevents for aws:SourceOrgID, unpinned on this arm. The middle value is + the discriminating one: a bare org ID is a valid ORG id and not a valid org PATH, so it + separates the path pattern from the id pattern rather than merely rejecting junk. + """ + role = _role( + "org-paths-shape", + [ + _service_statement( + {"ForAnyValue:StringLike": {"aws:SourceOrgPaths": value}} + ) + ], + ) + assert _run([role])[0].status == "FAIL" + + def test_both_keys_pass(self): + """Both source keys present and well formed PASSes.""" + findings = _run([_role("both-role", [_service_statement(BOTH_SCOPED)])]) + assert len(findings) == 1 + assert findings[0].status == "PASS" + + def test_string_like_source_account_passes(self): + """StringLike carrying a literal 12-digit account still pins it, so PASS. + + The operator permits wildcards but this value uses none, so the account is confined. + """ + role = _role( + "string-like-role", + [_service_statement({"StringLike": {"aws:SourceAccount": AWS_ACCOUNT_ID}})], + ) + assert _run([role])[0].status == "PASS" + + def test_lowercase_condition_keys_pass(self): + """Condition key names are case-insensitive in IAM.""" + role = _role( + "lowercase-role", + [ + _service_statement( + {"StringEquals": {"aws:sourceaccount": AWS_ACCOUNT_ID}} + ) + ], + ) + assert _run([role])[0].status == "PASS" + + def test_source_account_list_passes(self): + """A list of literal account IDs confines the source to those accounts, so PASS.""" + role = _role( + "account-list-role", + [ + _service_statement( + { + "StringEquals": { + "aws:SourceAccount": [AWS_ACCOUNT_ID, OTHER_ACCOUNT_ID] + } + } + ) + ], + ) + assert _run([role])[0].status == "PASS" + + def test_source_org_id_passes(self): + """AWS documents aws:SourceOrgID as an alternative source key.""" + role = _role( + "org-id-role", + [_service_statement({"StringEquals": {"aws:SourceOrgID": "o-a1b2c3d4e5"}})], + ) + assert _run([role])[0].status == "PASS" + + def test_source_org_paths_passes(self): + """aws:SourceOrgPaths under ForAnyValue:StringLike confines the source to an OU path, so PASS.""" + role = _role( + "org-paths-role", + [ + _service_statement( + { + "ForAnyValue:StringLike": { + "aws:SourceOrgPaths": "o-a1b2c3d4e5/r-f6g7h8i9j0/ou-f6g7-11112222/*" + } + } + ) + ], + ) + assert _run([role])[0].status == "PASS" + + def test_source_org_id_wildcard_fails(self): + """aws:SourceOrgID of "o-*" matches every organization, so it confines nothing and FAILs. + + The organization keys are accepted only when the value is a real identifier; accepting the + key's presence alone would clear a statement bound to nothing. + """ + role = _role( + "org-id-wildcard-role", + [_service_statement({"StringLike": {"aws:SourceOrgID": "o-*"}})], + ) + assert _run([role])[0].status == "FAIL" + + def test_external_id_does_not_bind_the_source(self): + """A concrete sts:ExternalId is not a cross-service mitigation. + + AWS documents the external ID for third-party access only -- it is a value the + third party supplies -- and the cross-service guidance names aws:SourceArn and + aws:SourceAccount. An AWS service passes neither an external ID nor anything that + satisfies this condition, so crediting it would accept a control the calling + service can never meet. The statement is in scope because a condition is present. + """ + role = _role( + "external-id-role", + [ + _service_statement( + {"StringEquals": {"sts:ExternalId": "unique-tenant-id"}} + ) + ], + ) + assert _run([role])[0].status == "FAIL" + + def test_hybrid_principal_scoped_passes(self): + """A hybrid principal is in scope, and PASSes when the source IS confined. + + So the hybrid shape is not treated as a finding in itself -- only as one no other check + evaluates. + """ + role = _role( + "hybrid-scoped-role", + [ + _service_statement( + ACCOUNT_ONLY, + principal={ + "Service": SERVICE, + "AWS": f"arn:aws:iam::{OTHER_ACCOUNT_ID}:root", + }, + ) + ], + ) + assert _run([role])[0].status == "PASS" + + def test_service_principal_list_all_scoped_passes(self): + """Several service principals in one statement PASS when the shared condition confines them.""" + role = _role( + "service-list-role", + [ + _service_statement( + ACCOUNT_ONLY, + principal={"Service": [SERVICE, "events.amazonaws.com"]}, + ) + ], + ) + assert _run([role])[0].status == "PASS" + + def test_service_principal_list_unscoped_fails(self): + """Several service principals under an unconfining condition FAIL together. + + The condition applies to the whole statement, so every principal in the list inherits it. + """ + role = _role( + "service-list-unscoped-role", + [ + _service_statement( + {"ArnLike": {"aws:SourceArn": BUCKET_ARN}}, + principal={"Service": [SERVICE, "events.amazonaws.com"]}, + ) + ], + ) + assert _run([role])[0].status == "FAIL" + + def test_single_statement_dict_is_handled(self): + """A Statement given as a single dict rather than a list is still evaluated. + + IAM accepts both shapes, so a check reading only lists would silently skip the role. + """ + role = Role( + name="single-statement-role", + arn=f"arn:aws:iam::{AWS_ACCOUNT_ID}:role/single-statement-role", + assume_role_policy={ + "Version": "2012-10-17", + "Statement": _service_statement( + {"ArnLike": {"aws:SourceArn": BUCKET_ARN}} + ), + }, + is_service_role=True, + tags=[], + ) + findings = _run([role]) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + + def test_mixed_action_list_containing_assume_role_is_in_scope(self): + """One assume-role action among others still brings the statement into scope. + + _grants_assume_role uses any(), not all(): a statement granting + ["sts:AssumeRole", "s3:GetObject"] does grant assume-role. Under all() the statement + is skipped entirely and an unbound source goes unreported. No other fixture supplies + a multi-action list mixing an assume-role action with an unrelated one. + """ + role = _role( + "mixed-action", + [ + { + "Effect": "Allow", + "Principal": {"Service": SERVICE}, + "Action": ["sts:AssumeRole", "s3:GetObject"], + "Condition": {"StringEquals": {"sts:ExternalId": "abc"}}, + } + ], + ) + result = _run([role]) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_service_list_with_an_empty_entry_is_still_service_trust(self): + """A service list containing an empty string still trusts the real service. + + _trusts_service_principal uses any(): one usable entry is enough. Under all() the + empty string makes the whole list falsy, the statement stops counting as service + trust, and the unbound source goes unreported. + """ + role = _role( + "empty-service-entry", + [ + _service_statement( + {"StringEquals": {"sts:ExternalId": "abc"}}, + principal={"Service": [SERVICE, ""]}, + ) + ], + ) + result = _run([role]) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_non_string_service_principal_is_not_service_trust(self): + """A malformed Service entry is not a service principal. + + The predicate is `isinstance(service, str) and service`. Under `or` a non-string + truthy value -- arbitrary JSON reaches this from a real account -- would be read as + a trusted service and the role reported, inventing a finding from malformed input. + """ + role = _role( + "non-string-service", + [ + _service_statement( + {"StringEquals": {"sts:ExternalId": "abc"}}, + principal={"Service": [{"unexpected": "object"}]}, + ) + ], + ) + assert _run([role]) == [] + + def test_empty_condition_block_is_wholly_unconditional(self): + """`{"StringEquals": {}}` pins nothing, so the statement is out of scope. + + _has_enforced_condition tests the block's keys with any(): an empty block yields + False, which is correct -- a statement with no enforced key is wholly unconditional + and belongs to iam_role_cross_service_confused_deputy_prevention, not here. Under + all() an empty block reports True (all() of nothing is True) and this check invents + a FAIL for a statement it should never have evaluated. + """ + role = _role( + "empty-condition-block", [_service_statement({"StringEquals": {}})] + ) + assert _run([role]) == [] + + def test_report_fields(self): + """The report carries the role's id, ARN, region and tags. + + Pinned once here, since every other case in this file asserts only the status. + """ + role = _role( + "reported-role", + [_service_statement({"ArnLike": {"aws:SourceArn": BUCKET_ARN}})], + ) + finding = _run([role])[0] + assert finding.resource_id == "reported-role" + assert finding.resource_arn == role.arn + assert finding.region == AWS_REGION_US_EAST_1 + assert finding.resource_tags == [] + + def test_every_role_gets_one_finding(self): + """Three roles get one report each, judged on their own trust policy. + + PASS, FAIL and MANUAL out of a single run. Without this, a check that reported only the + first role in the account would satisfy every other case in this file, because each one + passes exactly one role: an account's roles are its most numerous IAM resource, so the + silently-unreported remainder would be the whole estate bar one. + """ + roles = [ + _role("scoped", [_service_statement(ACCOUNT_ONLY)]), + _role( + "unbound", + [_service_statement({"ArnLike": {"aws:SourceArn": BUCKET_ARN}})], + ), + _role( + "notaction", + [ + { + "Effect": "Allow", + "Principal": {"Service": SERVICE}, + "NotAction": "s3:*", + } + ], + ), + ] + result = _run(roles) + assert {report.resource_id: report.status for report in result} == { + "scoped": "PASS", + "unbound": "FAIL", + "notaction": "MANUAL", + } + + +class Test_differential_against_existing_sibling_check: + """Pins the two states iam_role_cross_service_confused_deputy_prevention leaves unreported. + + These assertions describe upstream behaviour, so they hold on master too -- they document + the gap this check closes rather than the new check's own logic. + """ + + def test_sibling_passes_a_source_arn_that_binds_no_account(self): + """The sibling reads an accountless SourceArn as protected while this check FAILs it. + + Both halves are asserted in one test, so the gap is demonstrated rather than described. The + is_policy_public assertion describes upstream behaviour and holds on master too. + """ + from prowler.providers.aws.services.iam.lib.policy import is_policy_public + + policy = _trust_policy( + [_service_statement({"ArnLike": {"aws:SourceArn": BUCKET_ARN}})] + ) + # The sibling reads this as protected (not public) and reports PASS... + assert ( + is_policy_public( + policy, + AWS_ACCOUNT_ID, + check_cross_service_confused_deputy=True, + not_allowed_actions=["sts:AssumeRole", "sts:*"], + ) + is False + ) + # ...while the bucket ARN binds no account, so this check reports FAIL. + role = _role("bucket-arn-role", policy["Statement"]) + assert _run([role])[0].status == "FAIL" + + def test_sibling_skips_a_hybrid_principal_entirely(self): + """The sibling's is_service_role gate rejects hybrid principals, so it reports nothing. + + This check reports the unconfined cross-account assume path instead, which is the second + state the sibling leaves unreported. + """ + from prowler.providers.aws.services.iam.iam_service import is_service_role + + hybrid_statement = _service_statement( + principal={ + "Service": SERVICE, + "AWS": f"arn:aws:iam::{OTHER_ACCOUNT_ID}:root", + } + ) + # is_service_role gates the sibling, and it rejects hybrid principals, so the + # sibling emits no finding for this role at all... + assert ( + is_service_role( + {"AssumeRolePolicyDocument": _trust_policy([hybrid_statement])} + ) + is False + ) + # ...while this check reports the unconfined cross-account assume path. + role = _role("hybrid-role", [hybrid_statement]) + findings = _run([role]) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + + +class Test_trust_unevaluated_and_wildcard_shapes: + """Shapes IAM accepts that the check previously read as granting nothing. + + Each one made a role disappear from the population or PASS on an absence, and each is + reachable: IAM Access Analyzer ValidatePolicy accepts all three. + """ + + def test_assume_role_action_wildcard_is_not_a_pass(self): + """sts:Assume* reaches AssumeRole while matching no literal in ASSUME_ROLE_ACTIONS. + + An exact membership test dropped the statement from the population, so the role + PASSed claiming it confines every AWS service principal -- on the strength of a + statement the check never read. + """ + # A non-confining condition puts the statement in scope. This check deliberately + # judges a constraint that IS present rather than its absence -- a wholly + # unconditional trust is iam_role_cross_service_confused_deputy_prevention's. + statement = { + "Effect": "Allow", + "Principal": {"Service": SERVICE}, + "Action": "sts:Assume*", + "Condition": {"ArnLike": {"aws:SourceArn": "*"}}, + } + result = _run([_role("wildcard-action", [statement])]) + + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_leading_whitespace_action_is_not_a_pass(self): + """IAM tolerates surrounding whitespace; both sibling checks in this PR strip it.""" + # A non-confining condition puts the statement in scope. This check deliberately + # judges a constraint that IS present rather than its absence -- a wholly + # unconditional trust is iam_role_cross_service_confused_deputy_prevention's. + statement = { + "Effect": "Allow", + "Principal": {"Service": SERVICE}, + "Action": " sts:AssumeRole", + "Condition": {"ArnLike": {"aws:SourceArn": "*"}}, + } + result = _run([_role("padded-action", [statement])]) + + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_star_principal_is_not_silently_skipped(self): + """Principal "*" is a string, not a mapping, and trusts every principal there is. + + The isinstance guard returned False for it, so the role produced no finding at all -- + neither FAIL nor MANUAL -- which is the worst of the three outcomes. + """ + statement = { + "Effect": "Allow", + "Principal": "*", + "Action": "sts:AssumeRole", + } + result = _run([_role("star-principal", [statement])]) + + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_notaction_trust_statement_is_manual(self): + """NotAction under Allow can permit sts:AssumeRole while carrying no Action key. + + _grants_assume_role reads only Action, so the statement dropped out and the role + vanished. Inverting NotAction is more than this check can claim, so it says so. + """ + statement = { + "Effect": "Allow", + "Principal": {"Service": SERVICE}, + "NotAction": "s3:*", + } + result = _run([_role("notaction-trust", [statement])]) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "NotAction" in result[0].status_extended diff --git a/tests/providers/aws/services/iam/lib/policy_test.py b/tests/providers/aws/services/iam/lib/policy_test.py index bf64ff9211..d2c4a4aecc 100644 --- a/tests/providers/aws/services/iam/lib/policy_test.py +++ b/tests/providers/aws/services/iam/lib/policy_test.py @@ -8,6 +8,7 @@ from prowler.providers.aws.services.iam.lib.policy import ( has_codebuild_trusted_principal, has_public_principal, has_restrictive_source_arn_condition, + iam_pattern_matches, is_codebuild_using_allowed_github_org, is_condition_block_restrictive, is_condition_block_restrictive_organization, @@ -3062,3 +3063,102 @@ class Test_has_restrictive_source_arn_condition: }, } assert has_restrictive_source_arn_condition(statement) is True + + +class Test_iam_pattern_matches: + """IAM wildcard matching: only * and ? are metacharacters, and it must stay linear.""" + + @pytest.mark.parametrize( + "pattern,value,expected", + [ + ("bedrock-agentcore", "bedrock-agentcore", True), + ("bedrock-*", "bedrock-agentcore", True), + ("*-agentcore", "bedrock-agentcore", True), + ("*agentcore*", "bedrock-agentcore", True), + ("bedrock-agentcor?", "bedrock-agentcore", True), + ("bedrock?agentcore", "bedrock-agentcore", True), + ("*", "bedrock-agentcore", True), + ("**", "bedrock-agentcore", True), + ("s3", "bedrock-agentcore", False), + ("bedrock", "bedrock-agentcore", False), + ("bedrock-agentcore?", "bedrock-agentcore", False), + ("", "bedrock-agentcore", False), + ("*", "", True), + ("", "", True), + ("?", "", False), + (" bedrock-* ", "bedrock-agentcore", True), + ("BEDROCK-AGENTCORE", "bedrock-agentcore", True), + ("bedrock-agentcore", "BEDROCK-AGENTCORE", True), + ("a.c", "abc", False), + ("[bs]3", "b3", False), + ("[bs]3", "[bs]3", True), + ], + ) + def test_semantics(self, pattern, value, expected): + """`*` and `?` are the only metacharacters; everything else is literal. + + `a.c` against `abc` must be False or a regex dot has leaked in, and `[bs]3` against `b3` + must be False or bracket classes have -- the latter being the false PASS that a `fnmatch` + implementation shipped earlier in this campaign, where a Deny of + `agent-registry:[Dd]eleteRegistry` denied nothing while appearing to deny everything. + """ + assert iam_pattern_matches(pattern, value) is expected + + def test_adversarial_pattern_stays_fast(self): + """A wildcard-dense pattern must not blow up: this is a DoS guard, not a style preference. + + Leading wildcards, a literal that cannot occur in the value, then more wildcards is the + shape that forces a backtracking engine to try every distribution of the value's characters + across the star groups. Translating to a regex and matching took 2287 ms on exactly this + 31-character pattern, growing about sevenfold per added wildcard pair, and a hang raises + nothing so `check.py`'s bare `except Exception` cannot catch it -- the account's findings are + discarded in silence. Policy values reach this from the account and managed policy documents + allow 6144 characters. + + The budget is deliberately loose: the linear form measures ~0.006 ms, so a 0.5 s ceiling + cannot flake under load while still failing hard on a regex reimplementation. + """ + import time + + pattern = "*" * 14 + "zzz" + "*" * 14 + assert len(pattern) == 31 + start = time.perf_counter() + assert iam_pattern_matches(pattern, "bedrock-agentcore") is False + assert time.perf_counter() - start < 0.5 + + @pytest.mark.parametrize( + "subject", + [ + "bedrock-agentcore", + "iam", + "PassRole", + "GetWorkloadAccessTokenForUserId", + "bedrock-agentcore.amazonaws.com", + "workload-identity-directory/default/workload-identity/another-workload", + ], + ) + def test_every_call_site_subject_stays_fast(self, subject): + """Each subject the iam checks match against must be fast, not just one of them. + + These six are every distinct value passed as the second argument across the eight call sites + in the two AgentCore policy checks -- an Action's service and operation field, an ARN's + service and resource field, and an iam:PassedToService condition value. Probing once against + a single constant would not do: cost rises with the subject's length as well as the pattern's, + so the 3-character `iam` is cheap enough to come back clean while the 17- and 70-character + subjects were taking seconds. + """ + import time + + pattern = "*" * 14 + "zzz" + "*" * 14 + start = time.perf_counter() + iam_pattern_matches(pattern, subject) + assert time.perf_counter() - start < 0.5 + + def test_long_pattern_stays_fast(self): + """Cost must grow with the product of the lengths, not as a power of them.""" + import time + + pattern = "*" * 2000 + "zzz" + "*" * 2000 + start = time.perf_counter() + assert iam_pattern_matches(pattern, "bedrock-agentcore.amazonaws.com") is False + assert time.perf_counter() - start < 0.5 diff --git a/tests/providers/aws/services/inspector2/inspector2_active_findings_exist/inspector2_active_findings_exist_test.py b/tests/providers/aws/services/inspector2/inspector2_active_findings_exist/inspector2_active_findings_exist_test.py index a489700e86..8d2e014c99 100644 --- a/tests/providers/aws/services/inspector2/inspector2_active_findings_exist/inspector2_active_findings_exist_test.py +++ b/tests/providers/aws/services/inspector2/inspector2_active_findings_exist/inspector2_active_findings_exist_test.py @@ -15,7 +15,7 @@ FINDING_ARN = ( class Test_inspector2_active_findings_exist: def test_enabled_no_finding(self): # Mock the inspector2 client - inspector2_client = mock.MagicMock + inspector2_client = mock.MagicMock() inspector2_client.provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) inspector2_client.audited_account = AWS_ACCOUNT_NUMBER @@ -69,7 +69,7 @@ class Test_inspector2_active_findings_exist: def test_enabled_with_no_active_finding(self): # Mock the inspector2 client - inspector2_client = mock.MagicMock + inspector2_client = mock.MagicMock() inspector2_client.provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) inspector2_client.audited_account = AWS_ACCOUNT_NUMBER @@ -123,7 +123,7 @@ class Test_inspector2_active_findings_exist: def test_enabled_with_active_finding(self): # Mock the inspector2 client - inspector2_client = mock.MagicMock + inspector2_client = mock.MagicMock() inspector2_client.provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) inspector2_client.audited_account = AWS_ACCOUNT_NUMBER @@ -176,7 +176,7 @@ class Test_inspector2_active_findings_exist: def test_enabled_with_none_finding(self): # Mock the inspector2 client - inspector2_client = mock.MagicMock + inspector2_client = mock.MagicMock() inspector2_client.provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) inspector2_client.audited_account = AWS_ACCOUNT_NUMBER @@ -219,14 +219,14 @@ class Test_inspector2_active_findings_exist: def test_inspector2_disabled_ignoring(self): # Mock the inspector2 client - inspector2_client = mock.MagicMock - awslambda_client = mock.MagicMock + inspector2_client = mock.MagicMock() + awslambda_client = mock.MagicMock() awslambda_client.functions = {} - ecr_client = mock.MagicMock + ecr_client = mock.MagicMock() ecr_client.registries = {} - ecr_client.registries[AWS_REGION_EU_WEST_1] = mock.MagicMock + ecr_client.registries[AWS_REGION_EU_WEST_1] = mock.MagicMock() ecr_client.registries[AWS_REGION_EU_WEST_1].repositories = [] - ec2_client = mock.MagicMock + ec2_client = mock.MagicMock() ec2_client.instances = [] ec2_client.provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) ecr_client.provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) diff --git a/tests/providers/aws/services/inspector2/inspector2_active_findings_kev_within_due_date/inspector2_active_findings_kev_within_due_date_test.py b/tests/providers/aws/services/inspector2/inspector2_active_findings_kev_within_due_date/inspector2_active_findings_kev_within_due_date_test.py new file mode 100644 index 0000000000..bf57b346c2 --- /dev/null +++ b/tests/providers/aws/services/inspector2/inspector2_active_findings_kev_within_due_date/inspector2_active_findings_kev_within_due_date_test.py @@ -0,0 +1,136 @@ +from datetime import datetime, timedelta, timezone +from unittest import mock + +from prowler.providers.aws.services.inspector2.inspector2_service import ( + Finding, + Inspector, + KnownExploitedVulnerability, +) +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + set_mocked_aws_provider, +) + +INSPECTOR_ARN = ( + f"arn:aws:inspector2:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:inspector2" +) +FINDING_ARN = f"arn:aws:inspector2:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:finding/0e436649379db5f327e3cf5bb4421d76" +KEV_ID = "CVE-2024-3400" +CHECK_MODULE = "prowler.providers.aws.services.inspector2.inspector2_active_findings_kev_within_due_date.inspector2_active_findings_kev_within_due_date" + + +def build_inspector(findings=None, status="ENABLED"): + return Inspector( + id="Inspector2", + arn=INSPECTOR_ARN, + region=AWS_REGION_EU_WEST_1, + status=status, + ec2_status="ENABLED", + ecr_status="ENABLED", + lambda_status="ENABLED", + lambda_code_status="ENABLED", + findings=findings, + ) + + +def build_finding(vulnerability_id=KEV_ID): + return Finding( + arn=FINDING_ARN, + type="PACKAGE_VULNERABILITY", + severity="CRITICAL", + first_observed_at=datetime.now(timezone.utc), + vulnerability_id=vulnerability_id, + resource_ids=["i-0123456789abcdef0"], + ) + + +def build_kev(date_due): + return KnownExploitedVulnerability( + id=KEV_ID, + date_added=datetime(2024, 4, 12, tzinfo=timezone.utc), + date_due=date_due, + ) + + +def execute_check(inspectors, known_exploited=None, lookup_failed=None): + inspector2_client = mock.MagicMock() + inspector2_client.inspectors = inspectors + inspector2_client.known_exploited_vulnerabilities = known_exploited or {} + inspector2_client.vulnerability_lookup_failed = lookup_failed or set() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider([AWS_REGION_EU_WEST_1]), + ), + mock.patch(f"{CHECK_MODULE}.inspector2_client", new=inspector2_client), + ): + from prowler.providers.aws.services.inspector2.inspector2_active_findings_kev_within_due_date.inspector2_active_findings_kev_within_due_date import ( + inspector2_active_findings_kev_within_due_date, + ) + + return inspector2_active_findings_kev_within_due_date().execute() + + +class Test_inspector2_active_findings_kev_within_due_date: + def test_no_resources(self): + assert execute_check([]) == [] + + def test_inspector_disabled(self): + assert execute_check([build_inspector(findings=[], status="DISABLED")]) == [] + + def test_kev_past_due_date(self): + result = execute_check( + [build_inspector(findings=[build_finding()])], + known_exploited={ + KEV_ID: build_kev(datetime(2024, 4, 19, tzinfo=timezone.utc)) + }, + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Inspector2 has active findings in region {AWS_REGION_EU_WEST_1} for CISA Known Exploited Vulnerabilities past their remediation due date: {KEV_ID} (due 2024-04-19)." + ) + assert result[0].resource_id == "Inspector2" + assert result[0].resource_arn == INSPECTOR_ARN + assert result[0].region == AWS_REGION_EU_WEST_1 + + def test_kev_within_due_date(self): + result = execute_check( + [build_inspector(findings=[build_finding()])], + known_exploited={ + KEV_ID: build_kev(datetime.now(timezone.utc) + timedelta(days=7)) + }, + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Inspector2 has no active findings in region {AWS_REGION_EU_WEST_1} for CISA Known Exploited Vulnerabilities past their remediation due date." + ) + + def test_no_kev_findings(self): + result = execute_check( + [build_inspector(findings=[build_finding("CVE-2022-40897")])] + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_kev_status_not_verified(self): + result = execute_check( + [build_inspector(findings=[build_finding()])], + lookup_failed={KEV_ID}, + ) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + + def test_findings_not_retrieved(self): + result = execute_check([build_inspector(findings=None)]) + + assert len(result) == 1 + assert result[0].status == "MANUAL" diff --git a/tests/providers/aws/services/inspector2/inspector2_active_findings_no_known_exploited_vulnerabilities/inspector2_active_findings_no_known_exploited_vulnerabilities_test.py b/tests/providers/aws/services/inspector2/inspector2_active_findings_no_known_exploited_vulnerabilities/inspector2_active_findings_no_known_exploited_vulnerabilities_test.py new file mode 100644 index 0000000000..288e7224ea --- /dev/null +++ b/tests/providers/aws/services/inspector2/inspector2_active_findings_no_known_exploited_vulnerabilities/inspector2_active_findings_no_known_exploited_vulnerabilities_test.py @@ -0,0 +1,149 @@ +from datetime import datetime, timezone +from unittest import mock + +from prowler.providers.aws.services.inspector2.inspector2_service import ( + Finding, + Inspector, + KnownExploitedVulnerability, +) +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + set_mocked_aws_provider, +) + +INSPECTOR_ARN = ( + f"arn:aws:inspector2:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:inspector2" +) +FINDING_ARN = f"arn:aws:inspector2:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:finding/0e436649379db5f327e3cf5bb4421d76" +KEV_ID = "CVE-2024-3400" +CHECK_MODULE = "prowler.providers.aws.services.inspector2.inspector2_active_findings_no_known_exploited_vulnerabilities.inspector2_active_findings_no_known_exploited_vulnerabilities" + + +def build_inspector(findings=None, status="ENABLED"): + return Inspector( + id="Inspector2", + arn=INSPECTOR_ARN, + region=AWS_REGION_EU_WEST_1, + status=status, + ec2_status="ENABLED", + ecr_status="ENABLED", + lambda_status="ENABLED", + lambda_code_status="ENABLED", + findings=findings, + ) + + +def build_finding(vulnerability_id): + return Finding( + arn=FINDING_ARN, + type="PACKAGE_VULNERABILITY", + severity="CRITICAL", + first_observed_at=datetime.now(timezone.utc), + vulnerability_id=vulnerability_id, + resource_ids=["i-0123456789abcdef0"], + ) + + +def build_kev(vulnerability_id): + return KnownExploitedVulnerability( + id=vulnerability_id, + date_added=datetime(2024, 4, 12, tzinfo=timezone.utc), + date_due=datetime(2024, 4, 19, tzinfo=timezone.utc), + ) + + +def execute_check(inspectors, known_exploited=None, lookup_failed=None): + inspector2_client = mock.MagicMock() + inspector2_client.inspectors = inspectors + inspector2_client.known_exploited_vulnerabilities = known_exploited or {} + inspector2_client.vulnerability_lookup_failed = lookup_failed or set() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider([AWS_REGION_EU_WEST_1]), + ), + mock.patch(f"{CHECK_MODULE}.inspector2_client", new=inspector2_client), + ): + from prowler.providers.aws.services.inspector2.inspector2_active_findings_no_known_exploited_vulnerabilities.inspector2_active_findings_no_known_exploited_vulnerabilities import ( + inspector2_active_findings_no_known_exploited_vulnerabilities, + ) + + return inspector2_active_findings_no_known_exploited_vulnerabilities().execute() + + +class Test_inspector2_active_findings_no_known_exploited_vulnerabilities: + def test_no_resources(self): + assert execute_check([]) == [] + + def test_inspector_disabled(self): + assert execute_check([build_inspector(findings=[], status="DISABLED")]) == [] + + def test_no_kev_findings(self): + result = execute_check( + [build_inspector(findings=[build_finding("CVE-2022-40897")])] + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Inspector2 has no active findings in region {AWS_REGION_EU_WEST_1} for CISA Known Exploited Vulnerabilities." + ) + assert result[0].resource_id == "Inspector2" + assert result[0].resource_arn == INSPECTOR_ARN + assert result[0].region == AWS_REGION_EU_WEST_1 + + def test_kev_finding(self): + result = execute_check( + [ + build_inspector( + findings=[build_finding(KEV_ID), build_finding("CVE-2022-40897")] + ) + ], + known_exploited={KEV_ID: build_kev(KEV_ID)}, + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Inspector2 has active findings in region {AWS_REGION_EU_WEST_1} for CISA Known Exploited Vulnerabilities: {KEV_ID}." + ) + + def test_many_kev_findings_are_truncated(self): + vulnerability_ids = [f"CVE-2024-{number:04d}" for number in range(1, 13)] + result = execute_check( + [ + build_inspector( + findings=[build_finding(vid) for vid in vulnerability_ids] + ) + ], + known_exploited={vid: build_kev(vid) for vid in vulnerability_ids}, + ) + + assert result[0].status == "FAIL" + assert result[0].status_extended.endswith("CVE-2024-0010 and 2 more.") + + def test_kev_status_not_verified(self): + result = execute_check( + [build_inspector(findings=[build_finding(KEV_ID)])], + lookup_failed={KEV_ID}, + ) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == f"Inspector2 could not verify the CISA Known Exploited Vulnerabilities status of {KEV_ID} in region {AWS_REGION_EU_WEST_1}; verify the inspector2:BatchGetFindingDetails permission." + ) + + def test_findings_not_retrieved(self): + result = execute_check([build_inspector(findings=None)]) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == f"Inspector2 findings could not be retrieved in region {AWS_REGION_EU_WEST_1}; verify the inspector2:ListFindings permission." + ) diff --git a/tests/providers/aws/services/inspector2/inspector2_active_findings_within_max_age/inspector2_active_findings_within_max_age_test.py b/tests/providers/aws/services/inspector2/inspector2_active_findings_within_max_age/inspector2_active_findings_within_max_age_test.py new file mode 100644 index 0000000000..8b847c9014 --- /dev/null +++ b/tests/providers/aws/services/inspector2/inspector2_active_findings_within_max_age/inspector2_active_findings_within_max_age_test.py @@ -0,0 +1,140 @@ +from datetime import datetime, timedelta, timezone +from unittest import mock + +from prowler.providers.aws.services.inspector2.inspector2_service import ( + Finding, + Inspector, +) +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + set_mocked_aws_provider, +) + +INSPECTOR_ARN = ( + f"arn:aws:inspector2:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:inspector2" +) +FINDING_ARN = f"arn:aws:inspector2:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:finding/0e436649379db5f327e3cf5bb4421d76" +CHECK_MODULE = "prowler.providers.aws.services.inspector2.inspector2_active_findings_within_max_age.inspector2_active_findings_within_max_age" + + +def build_inspector(findings=None, status="ENABLED"): + return Inspector( + id="Inspector2", + arn=INSPECTOR_ARN, + region=AWS_REGION_EU_WEST_1, + status=status, + ec2_status="ENABLED", + ecr_status="ENABLED", + lambda_status="ENABLED", + lambda_code_status="ENABLED", + findings=findings, + ) + + +def build_finding(age_days): + first_observed_at = ( + datetime.now(timezone.utc) - timedelta(days=age_days, hours=1) + if age_days is not None + else None + ) + return Finding( + arn=FINDING_ARN, + type="PACKAGE_VULNERABILITY", + severity="HIGH", + first_observed_at=first_observed_at, + vulnerability_id="CVE-2022-40897", + resource_ids=["i-0123456789abcdef0"], + ) + + +def execute_check(inspectors, audit_config=None): + inspector2_client = mock.MagicMock() + inspector2_client.inspectors = inspectors + inspector2_client.audit_config = audit_config or {} + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider([AWS_REGION_EU_WEST_1]), + ), + mock.patch(f"{CHECK_MODULE}.inspector2_client", new=inspector2_client), + ): + from prowler.providers.aws.services.inspector2.inspector2_active_findings_within_max_age.inspector2_active_findings_within_max_age import ( + inspector2_active_findings_within_max_age, + ) + + return inspector2_active_findings_within_max_age().execute() + + +class Test_inspector2_active_findings_within_max_age: + def test_no_resources(self): + assert execute_check([]) == [] + + def test_inspector_disabled(self): + assert execute_check([build_inspector(findings=[], status="DISABLED")]) == [] + + def test_no_active_findings(self): + result = execute_check([build_inspector(findings=[])]) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Inspector2 has no active findings in region {AWS_REGION_EU_WEST_1} first observed more than 192 days ago." + ) + assert result[0].resource_id == "Inspector2" + assert result[0].resource_arn == INSPECTOR_ARN + assert result[0].region == AWS_REGION_EU_WEST_1 + + def test_recent_findings(self): + result = execute_check([build_inspector(findings=[build_finding(30)])]) + + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_stale_findings(self): + result = execute_check( + [ + build_inspector( + findings=[ + build_finding(30), + build_finding(200), + build_finding(400), + ] + ) + ] + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Inspector2 has 2 active findings in region {AWS_REGION_EU_WEST_1} first observed more than 192 days ago, the oldest 400 days ago." + ) + + def test_finding_just_over_max_age(self): + result = execute_check([build_inspector(findings=[build_finding(192)])]) + + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_custom_max_age(self): + result = execute_check( + [build_inspector(findings=[build_finding(30)])], + audit_config={"inspector2_active_finding_max_age_days": 14}, + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_finding_without_first_observed_date_is_ignored(self): + result = execute_check([build_inspector(findings=[build_finding(None)])]) + + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_findings_not_retrieved(self): + result = execute_check([build_inspector(findings=None)]) + + assert len(result) == 1 + assert result[0].status == "MANUAL" diff --git a/tests/providers/aws/services/inspector2/inspector2_coverage_recently_scanned/inspector2_coverage_recently_scanned_test.py b/tests/providers/aws/services/inspector2/inspector2_coverage_recently_scanned/inspector2_coverage_recently_scanned_test.py new file mode 100644 index 0000000000..64d757feb2 --- /dev/null +++ b/tests/providers/aws/services/inspector2/inspector2_coverage_recently_scanned/inspector2_coverage_recently_scanned_test.py @@ -0,0 +1,170 @@ +from datetime import datetime, timedelta, timezone +from unittest import mock + +from prowler.providers.aws.services.inspector2.inspector2_service import ( + CoveredResource, + Inspector, +) +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + set_mocked_aws_provider, +) + +INSPECTOR_ARN = ( + f"arn:aws:inspector2:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:inspector2" +) +INSTANCE_ID = "i-0123456789abcdef0" +INSTANCE_ARN = ( + f"arn:aws:ec2:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:instance/{INSTANCE_ID}" +) +CHECK_MODULE = "prowler.providers.aws.services.inspector2.inspector2_coverage_recently_scanned.inspector2_coverage_recently_scanned" + + +def build_inspector(coverage=None, status="ENABLED"): + return Inspector( + id="Inspector2", + arn=INSPECTOR_ARN, + region=AWS_REGION_EU_WEST_1, + status=status, + ec2_status="ENABLED", + ecr_status="ENABLED", + lambda_status="ENABLED", + lambda_code_status="ENABLED", + coverage=coverage, + ) + + +def build_instance( + days_since_scan=None, scan_status_code="ACTIVE", scan_status_reason="SUCCESSFUL" +): + last_scanned_at = ( + datetime.now(timezone.utc) - timedelta(days=days_since_scan, hours=1) + if days_since_scan is not None + else None + ) + return CoveredResource( + id=INSTANCE_ID, + arn=INSTANCE_ARN, + region=AWS_REGION_EU_WEST_1, + resource_type="AWS_EC2_INSTANCE", + scan_type="PACKAGE", + scan_status_code=scan_status_code, + scan_status_reason=scan_status_reason, + last_scanned_at=last_scanned_at, + ) + + +def execute_check(inspectors, audit_config=None): + inspector2_client = mock.MagicMock() + inspector2_client.inspectors = inspectors + inspector2_client.audit_config = audit_config or {} + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider([AWS_REGION_EU_WEST_1]), + ), + mock.patch(f"{CHECK_MODULE}.inspector2_client", new=inspector2_client), + ): + from prowler.providers.aws.services.inspector2.inspector2_coverage_recently_scanned.inspector2_coverage_recently_scanned import ( + inspector2_coverage_recently_scanned, + ) + + return inspector2_coverage_recently_scanned().execute() + + +class Test_inspector2_coverage_recently_scanned: + def test_no_resources(self): + assert execute_check([]) == [] + + def test_inspector_disabled(self): + assert execute_check([build_inspector(status="DISABLED", coverage=[])]) == [] + + def test_recently_scanned_resource(self): + result = execute_check([build_inspector(coverage=[build_instance(1)])]) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"AWS_EC2_INSTANCE {INSTANCE_ID} was last scanned by Inspector2 within the last 3 days." + ) + assert result[0].resource_id == INSTANCE_ID + assert result[0].resource_arn == INSTANCE_ARN + assert result[0].region == AWS_REGION_EU_WEST_1 + + def test_stale_resource(self): + result = execute_check([build_inspector(coverage=[build_instance(10)])]) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"AWS_EC2_INSTANCE {INSTANCE_ID} was last scanned by Inspector2 more than 3 days ago." + ) + + def test_resource_scanned_just_over_max_days(self): + result = execute_check([build_inspector(coverage=[build_instance(3)])]) + + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_custom_max_days(self): + result = execute_check( + [build_inspector(coverage=[build_instance(10)])], + audit_config={"inspector2_max_days_since_last_scan": 14}, + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_resource_without_recorded_scan(self): + result = execute_check([build_inspector(coverage=[build_instance(None)])]) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"AWS_EC2_INSTANCE {INSTANCE_ID} has no recorded Inspector2 scan." + ) + + def test_pending_initial_scan_is_skipped(self): + assert ( + execute_check( + [ + build_inspector( + coverage=[ + build_instance( + None, scan_status_reason="PENDING_INITIAL_SCAN" + ) + ] + ) + ] + ) + == [] + ) + + def test_inactive_resource_is_skipped(self): + assert ( + execute_check( + [ + build_inspector( + coverage=[ + build_instance( + 10, + scan_status_code="INACTIVE", + scan_status_reason="NO_INVENTORY", + ) + ] + ) + ] + ) + == [] + ) + + def test_coverage_not_retrieved(self): + result = execute_check([build_inspector(coverage=None)]) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].resource_arn == INSPECTOR_ARN diff --git a/tests/providers/aws/services/inspector2/inspector2_coverage_scan_status_active/inspector2_coverage_scan_status_active_test.py b/tests/providers/aws/services/inspector2/inspector2_coverage_scan_status_active/inspector2_coverage_scan_status_active_test.py new file mode 100644 index 0000000000..1ce2e21c4a --- /dev/null +++ b/tests/providers/aws/services/inspector2/inspector2_coverage_scan_status_active/inspector2_coverage_scan_status_active_test.py @@ -0,0 +1,132 @@ +from datetime import datetime, timezone +from unittest import mock + +from prowler.providers.aws.services.inspector2.inspector2_service import ( + CoveredResource, + Inspector, +) +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + set_mocked_aws_provider, +) + +INSPECTOR_ARN = ( + f"arn:aws:inspector2:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:inspector2" +) +INSTANCE_ID = "i-0123456789abcdef0" +INSTANCE_ARN = ( + f"arn:aws:ec2:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:instance/{INSTANCE_ID}" +) +CHECK_MODULE = "prowler.providers.aws.services.inspector2.inspector2_coverage_scan_status_active.inspector2_coverage_scan_status_active" + + +def build_inspector(status="ENABLED", coverage=None): + return Inspector( + id="Inspector2", + arn=INSPECTOR_ARN, + region=AWS_REGION_EU_WEST_1, + status=status, + ec2_status="ENABLED", + ecr_status="ENABLED", + lambda_status="ENABLED", + lambda_code_status="ENABLED", + coverage=coverage, + ) + + +def build_instance(scan_status_code, scan_status_reason): + return CoveredResource( + id=INSTANCE_ID, + arn=INSTANCE_ARN, + region=AWS_REGION_EU_WEST_1, + resource_type="AWS_EC2_INSTANCE", + scan_type="PACKAGE", + scan_status_code=scan_status_code, + scan_status_reason=scan_status_reason, + last_scanned_at=datetime.now(timezone.utc), + ) + + +def execute_check(inspectors): + inspector2_client = mock.MagicMock() + inspector2_client.inspectors = inspectors + inspector2_client.audit_config = {} + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider([AWS_REGION_EU_WEST_1]), + ), + mock.patch(f"{CHECK_MODULE}.inspector2_client", new=inspector2_client), + ): + from prowler.providers.aws.services.inspector2.inspector2_coverage_scan_status_active.inspector2_coverage_scan_status_active import ( + inspector2_coverage_scan_status_active, + ) + + return inspector2_coverage_scan_status_active().execute() + + +class Test_inspector2_coverage_scan_status_active: + def test_no_resources(self): + assert execute_check([]) == [] + + def test_inspector_disabled(self): + assert execute_check([build_inspector(status="DISABLED", coverage=[])]) == [] + + def test_no_covered_resources(self): + assert execute_check([build_inspector(coverage=[])]) == [] + + def test_active_resource(self): + result = execute_check( + [build_inspector(coverage=[build_instance("ACTIVE", "SUCCESSFUL")])] + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Inspector2 is actively scanning AWS_EC2_INSTANCE {INSTANCE_ID}." + ) + assert result[0].resource_id == INSTANCE_ID + assert result[0].resource_arn == INSTANCE_ARN + assert result[0].region == AWS_REGION_EU_WEST_1 + + def test_inactive_resource(self): + result = execute_check( + [ + build_inspector( + coverage=[build_instance("INACTIVE", "UNMANAGED_EC2_INSTANCE")] + ) + ] + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Inspector2 is not scanning AWS_EC2_INSTANCE {INSTANCE_ID}: UNMANAGED_EC2_INSTANCE." + ) + assert result[0].resource_id == INSTANCE_ID + + def test_not_applicable_resource_is_skipped(self): + assert ( + execute_check( + [ + build_inspector( + coverage=[build_instance("INACTIVE", "EC2_INSTANCE_STOPPED")] + ) + ] + ) + == [] + ) + + def test_coverage_not_retrieved(self): + result = execute_check([build_inspector(coverage=None)]) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == f"Inspector2 coverage could not be retrieved in region {AWS_REGION_EU_WEST_1}; verify the inspector2:ListCoverage permission." + ) + assert result[0].resource_arn == INSPECTOR_ARN diff --git a/tests/providers/aws/services/inspector2/inspector2_is_enabled/inspector2_is_enabled_test.py b/tests/providers/aws/services/inspector2/inspector2_is_enabled/inspector2_is_enabled_test.py index 66eb2ceaa3..7383f312c1 100644 --- a/tests/providers/aws/services/inspector2/inspector2_is_enabled/inspector2_is_enabled_test.py +++ b/tests/providers/aws/services/inspector2/inspector2_is_enabled/inspector2_is_enabled_test.py @@ -75,10 +75,10 @@ class Test_inspector2_is_enabled: def test_inspector2_disabled(self): # Mock the inspector2 client - inspector2_client = mock.MagicMock - awslambda_client = mock.MagicMock - ecr_client = mock.MagicMock - ec2_client = mock.MagicMock + inspector2_client = mock.MagicMock() + awslambda_client = mock.MagicMock() + ecr_client = mock.MagicMock() + ec2_client = mock.MagicMock() ec2_client.provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) ecr_client.provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) awslambda_client.provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) @@ -133,7 +133,7 @@ class Test_inspector2_is_enabled: def test_all_enabled(self): # Mock the inspector2 client - inspector2_client = mock.MagicMock + inspector2_client = mock.MagicMock() inspector2_client.provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) inspector2_client.audited_account = AWS_ACCOUNT_NUMBER inspector2_client.audited_account_arn = ( @@ -235,7 +235,7 @@ class Test_inspector2_is_enabled: assert result[0].region == AWS_REGION_EU_WEST_1 def test_ec2_disabled(self): - inspector2_client = mock.MagicMock + inspector2_client = mock.MagicMock() inspector2_client.provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) inspector2_client.audited_account = AWS_ACCOUNT_NUMBER inspector2_client.audited_account_arn = ( @@ -286,7 +286,7 @@ class Test_inspector2_is_enabled: assert result[0].region == AWS_REGION_EU_WEST_1 def test_ecr_disabled(self): - inspector2_client = mock.MagicMock + inspector2_client = mock.MagicMock() inspector2_client.provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) inspector2_client.audited_account = AWS_ACCOUNT_NUMBER inspector2_client.audited_account_arn = ( @@ -337,7 +337,7 @@ class Test_inspector2_is_enabled: assert result[0].region == AWS_REGION_EU_WEST_1 def test_lambda_disabled(self): - inspector2_client = mock.MagicMock + inspector2_client = mock.MagicMock() inspector2_client.provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) inspector2_client.audited_account = AWS_ACCOUNT_NUMBER inspector2_client.audited_account_arn = ( @@ -388,7 +388,7 @@ class Test_inspector2_is_enabled: assert result[0].region == AWS_REGION_EU_WEST_1 def test_lambda_code_disabled(self): - inspector2_client = mock.MagicMock + inspector2_client = mock.MagicMock() inspector2_client.provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) inspector2_client.audited_account = AWS_ACCOUNT_NUMBER inspector2_client.audited_account_arn = ( @@ -439,7 +439,7 @@ class Test_inspector2_is_enabled: assert result[0].region == AWS_REGION_EU_WEST_1 def test_ec2_ecr_disabled(self): - inspector2_client = mock.MagicMock + inspector2_client = mock.MagicMock() inspector2_client.provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) inspector2_client.audited_account = AWS_ACCOUNT_NUMBER inspector2_client.audited_account_arn = ( @@ -490,7 +490,7 @@ class Test_inspector2_is_enabled: assert result[0].region == AWS_REGION_EU_WEST_1 def test_ec2_lambda_disabled(self): - inspector2_client = mock.MagicMock + inspector2_client = mock.MagicMock() inspector2_client.provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) inspector2_client.audited_account = AWS_ACCOUNT_NUMBER inspector2_client.audited_account_arn = ( @@ -541,7 +541,7 @@ class Test_inspector2_is_enabled: assert result[0].region == AWS_REGION_EU_WEST_1 def test_ec2_lambda_code_disabled(self): - inspector2_client = mock.MagicMock + inspector2_client = mock.MagicMock() inspector2_client.provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) inspector2_client.audited_account = AWS_ACCOUNT_NUMBER inspector2_client.audited_account_arn = ( @@ -592,7 +592,7 @@ class Test_inspector2_is_enabled: assert result[0].region == AWS_REGION_EU_WEST_1 def test_ecr_lambda_disabled(self): - inspector2_client = mock.MagicMock + inspector2_client = mock.MagicMock() inspector2_client.provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) inspector2_client.audited_account = AWS_ACCOUNT_NUMBER inspector2_client.audited_account_arn = ( @@ -643,7 +643,7 @@ class Test_inspector2_is_enabled: assert result[0].region == AWS_REGION_EU_WEST_1 def test_ecr_lambda_code_disabled(self): - inspector2_client = mock.MagicMock + inspector2_client = mock.MagicMock() inspector2_client.provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) inspector2_client.audited_account = AWS_ACCOUNT_NUMBER inspector2_client.audited_account_arn = ( @@ -694,7 +694,7 @@ class Test_inspector2_is_enabled: assert result[0].region == AWS_REGION_EU_WEST_1 def test_lambda_lambda_code_disabled(self): - inspector2_client = mock.MagicMock + inspector2_client = mock.MagicMock() inspector2_client.provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) inspector2_client.audited_account = AWS_ACCOUNT_NUMBER inspector2_client.audited_account_arn = ( @@ -745,7 +745,7 @@ class Test_inspector2_is_enabled: assert result[0].region == AWS_REGION_EU_WEST_1 def test_ec2_ecr_lambda_disabled(self): - inspector2_client = mock.MagicMock + inspector2_client = mock.MagicMock() inspector2_client.provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) inspector2_client.audited_account = AWS_ACCOUNT_NUMBER inspector2_client.audited_account_arn = ( @@ -796,7 +796,7 @@ class Test_inspector2_is_enabled: assert result[0].region == AWS_REGION_EU_WEST_1 def test_ec2_ecr_lambda_code_disabled(self): - inspector2_client = mock.MagicMock + inspector2_client = mock.MagicMock() inspector2_client.provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) inspector2_client.audited_account = AWS_ACCOUNT_NUMBER inspector2_client.audited_account_arn = ( @@ -847,7 +847,7 @@ class Test_inspector2_is_enabled: assert result[0].region == AWS_REGION_EU_WEST_1 def test_ec2_lambda_lambda_code_disabled(self): - inspector2_client = mock.MagicMock + inspector2_client = mock.MagicMock() inspector2_client.provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) inspector2_client.audited_account = AWS_ACCOUNT_NUMBER inspector2_client.audited_account_arn = ( @@ -898,7 +898,7 @@ class Test_inspector2_is_enabled: assert result[0].region == AWS_REGION_EU_WEST_1 def test_ecr_lambda_lambda_code_disabled(self): - inspector2_client = mock.MagicMock + inspector2_client = mock.MagicMock() inspector2_client.provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) inspector2_client.audited_account = AWS_ACCOUNT_NUMBER inspector2_client.audited_account_arn = ( diff --git a/tests/providers/aws/services/inspector2/inspector2_service_test.py b/tests/providers/aws/services/inspector2/inspector2_service_test.py index c84797eacd..7695e889eb 100644 --- a/tests/providers/aws/services/inspector2/inspector2_service_test.py +++ b/tests/providers/aws/services/inspector2/inspector2_service_test.py @@ -1,18 +1,30 @@ -from datetime import datetime +from datetime import datetime, timezone from unittest.mock import patch import botocore +from botocore.exceptions import ClientError -from prowler.providers.aws.services.inspector2.inspector2_service import Inspector2 +from prowler.providers.aws.services.inspector2.inspector2_service import ( + Finding, + Inspector, + Inspector2, +) from tests.providers.aws.utils import ( AWS_ACCOUNT_NUMBER, AWS_REGION_EU_WEST_1, + AWS_REGION_US_EAST_1, set_mocked_aws_provider, ) FINDING_ARN = ( "arn:aws:inspector2:us-east-1:123456789012:finding/0e436649379db5f327e3cf5bb4421d76" ) +VULNERABILITY_ID = "CVE-2022-40897" +INSTANCE_ID = "i-0123456789abcdef0" +FIRST_OBSERVED_AT = datetime(2024, 1, 1, tzinfo=timezone.utc) +LAST_SCANNED_AT = datetime(2024, 6, 1, tzinfo=timezone.utc) +KEV_DATE_ADDED = datetime(2024, 4, 12, tzinfo=timezone.utc) +KEV_DATE_DUE = datetime(2024, 5, 3, tzinfo=timezone.utc) # Mocking Calls make_api_call = botocore.client.BaseClient._make_api_call @@ -64,16 +76,83 @@ def mock_make_api_call(self, operation_name, kwargs): "description": "Finding Description", "severity": "MEDIUM", "status": "ACTIVE", - "title": "CVE-2022-40897 - setuptools", + "title": f"{VULNERABILITY_ID} - setuptools", "type": "PACKAGE_VULNERABILITY", + "firstObservedAt": FIRST_OBSERVED_AT, "updatedAt": datetime(2024, 1, 1), + "packageVulnerabilityDetails": { + "vulnerabilityId": VULNERABILITY_ID + }, + "resources": [{"id": INSTANCE_ID, "type": "AWS_EC2_INSTANCE"}], } ] } + if operation_name == "ListCoverage": + return { + "coveredResources": [ + { + "resourceId": INSTANCE_ID, + "resourceType": "AWS_EC2_INSTANCE", + "accountId": AWS_ACCOUNT_NUMBER, + "scanType": "PACKAGE", + "scanStatus": {"statusCode": "ACTIVE", "reason": "SUCCESSFUL"}, + "lastScannedAt": LAST_SCANNED_AT, + } + ] + } + if operation_name == "BatchGetFindingDetails": + return { + "findingDetails": [ + { + "findingArn": FINDING_ARN, + "cisaData": { + "dateAdded": KEV_DATE_ADDED, + "dateDue": KEV_DATE_DUE, + }, + } + ], + "errors": [], + } return make_api_call(self, operation_name, kwargs) +def mock_make_api_call_finding_details_denied(self, operation_name, kwargs): + if operation_name == "BatchGetFindingDetails": + raise ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "denied"}}, + operation_name, + ) + return mock_make_api_call(self, operation_name, kwargs) + + +def mock_finding_details_error(error_code): + def _mock(self, operation_name, kwargs): + if operation_name == "BatchGetFindingDetails": + return { + "findingDetails": [], + "errors": [ + { + "findingArn": FINDING_ARN, + "errorCode": error_code, + "errorMessage": "error", + } + ], + } + return mock_make_api_call(self, operation_name, kwargs) + + return _mock + + +def mock_make_api_call_list_denied(self, operation_name, kwargs): + if operation_name in ("ListFindings", "ListCoverage"): + raise ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "denied"}}, + operation_name, + ) + return mock_make_api_call(self, operation_name, kwargs) + + def mock_generate_regional_clients(provider, service): regional_client = provider._session.current_session.client( service, region_name=AWS_REGION_EU_WEST_1 @@ -82,6 +161,29 @@ def mock_generate_regional_clients(provider, service): return {AWS_REGION_EU_WEST_1: regional_client} +def build_inspector(region, vulnerability_ids): + return Inspector( + id="Inspector2", + arn=f"arn:aws:inspector2:{region}:{AWS_ACCOUNT_NUMBER}:inspector2", + region=region, + status="ENABLED", + ec2_status="ENABLED", + ecr_status="ENABLED", + lambda_status="ENABLED", + lambda_code_status="ENABLED", + findings=[ + Finding( + arn=f"arn:aws:inspector2:{region}:{AWS_ACCOUNT_NUMBER}:finding/{index}", + type="PACKAGE_VULNERABILITY", + severity="HIGH", + first_observed_at=FIRST_OBSERVED_AT, + vulnerability_id=vulnerability_id, + ) + for index, vulnerability_id in enumerate(vulnerability_ids) + ], + ) + + # Patch every AWS call using Boto3 and generate_regional_clients to have 1 client @patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call) @patch( @@ -118,3 +220,115 @@ class Test_Inspector2_Service: aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) inspector2 = Inspector2(aws_provider) assert inspector2.inspectors[0].active_findings + + def test_list_findings(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + inspector2 = Inspector2(aws_provider) + findings = inspector2.inspectors[0].findings + assert len(findings) == 1 + assert findings[0].arn == FINDING_ARN + assert findings[0].type == "PACKAGE_VULNERABILITY" + assert findings[0].severity == "MEDIUM" + assert findings[0].first_observed_at == FIRST_OBSERVED_AT + assert findings[0].vulnerability_id == VULNERABILITY_ID + assert findings[0].resource_ids == [INSTANCE_ID] + + def test_list_coverage(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + inspector2 = Inspector2(aws_provider) + coverage = inspector2.inspectors[0].coverage + assert len(coverage) == 1 + assert coverage[0].id == INSTANCE_ID + assert ( + coverage[0].arn + == f"arn:aws:ec2:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:instance/{INSTANCE_ID}" + ) + assert coverage[0].region == AWS_REGION_EU_WEST_1 + assert coverage[0].resource_type == "AWS_EC2_INSTANCE" + assert coverage[0].scan_type == "PACKAGE" + assert coverage[0].scan_status_code == "ACTIVE" + assert coverage[0].scan_status_reason == "SUCCESSFUL" + assert coverage[0].last_scanned_at == LAST_SCANNED_AT + + def test_list_coverage_keeps_audited_resources(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + aws_provider._audit_resources = [ + f"arn:aws:ec2:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:instance/{INSTANCE_ID}" + ] + inspector2 = Inspector2(aws_provider) + assert len(inspector2.inspectors[0].coverage) == 1 + + def test_list_coverage_skips_non_audited_resources(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + aws_provider._audit_resources = [ + f"arn:aws:ec2:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:instance/i-0fedcba9876543210" + ] + inspector2 = Inspector2(aws_provider) + assert inspector2.inspectors[0].coverage == [] + + def test_batch_get_finding_details(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + inspector2 = Inspector2(aws_provider) + known_exploited = inspector2.known_exploited_vulnerabilities[VULNERABILITY_ID] + assert known_exploited.id == VULNERABILITY_ID + assert known_exploited.date_added == KEV_DATE_ADDED + assert known_exploited.date_due == KEV_DATE_DUE + assert inspector2.vulnerability_lookup_failed == set() + + def test_batch_get_finding_details_denied(self): + with patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_finding_details_denied, + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + inspector2 = Inspector2(aws_provider) + assert inspector2.known_exploited_vulnerabilities == {} + assert inspector2.vulnerability_lookup_failed == {VULNERABILITY_ID} + + def test_finding_details_not_found_is_not_a_lookup_failure(self): + with patch( + "botocore.client.BaseClient._make_api_call", + new=mock_finding_details_error("FINDING_DETAILS_NOT_FOUND"), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + inspector2 = Inspector2(aws_provider) + assert inspector2.known_exploited_vulnerabilities == {} + assert inspector2.vulnerability_lookup_failed == set() + + def test_finding_details_error_is_a_lookup_failure(self): + with patch( + "botocore.client.BaseClient._make_api_call", + new=mock_finding_details_error("INTERNAL_ERROR"), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + inspector2 = Inspector2(aws_provider) + assert inspector2.known_exploited_vulnerabilities == {} + assert inspector2.vulnerability_lookup_failed == {VULNERABILITY_ID} + + def test_list_findings_and_coverage_denied(self): + with patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_list_denied, + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + inspector2 = Inspector2(aws_provider) + assert inspector2.inspectors[0].findings is None + assert inspector2.inspectors[0].coverage is None + assert inspector2.known_exploited_vulnerabilities == {} + + def test_finding_detail_batches_use_one_finding_per_cve(self): + vulnerability_ids = [f"CVE-2024-{number:04d}" for number in range(25)] + batches = Inspector2._get_finding_detail_batches( + [ + build_inspector( + AWS_REGION_EU_WEST_1, + vulnerability_ids + vulnerability_ids[:5] + ["GHSA-xxxx-yyyy-zzzz"], + ), + build_inspector(AWS_REGION_US_EAST_1, vulnerability_ids[:3]), + ] + ) + assert [region for region, _ in batches] == [AWS_REGION_EU_WEST_1] * 3 + assert [len(findings) for _, findings in batches] == [10, 10, 5] + assert sorted(cve for _, findings in batches for _, cve in findings) == sorted( + vulnerability_ids + ) diff --git a/tests/providers/aws/services/macie/macie_automated_sensitive_data_discovery_enabled/macie_automated_sensitive_data_discovery_enabled_test.py b/tests/providers/aws/services/macie/macie_automated_sensitive_data_discovery_enabled/macie_automated_sensitive_data_discovery_enabled_test.py index d196c39242..f8f3d82527 100644 --- a/tests/providers/aws/services/macie/macie_automated_sensitive_data_discovery_enabled/macie_automated_sensitive_data_discovery_enabled_test.py +++ b/tests/providers/aws/services/macie/macie_automated_sensitive_data_discovery_enabled/macie_automated_sensitive_data_discovery_enabled_test.py @@ -13,7 +13,7 @@ from tests.providers.aws.utils import ( class Test_macie_automated_sensitive_data_discovery_enabled: @mock_aws def test_macie_disabled(self): - macie_client = mock.MagicMock + macie_client = mock.MagicMock() macie_client.provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) macie_client.audited_account = AWS_ACCOUNT_NUMBER macie_client.audited_account_arn = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:root" @@ -56,7 +56,7 @@ class Test_macie_automated_sensitive_data_discovery_enabled: @mock_aws def test_macie_enabled_automated_discovery_disabled(self): - macie_client = mock.MagicMock + macie_client = mock.MagicMock() macie_client.provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) macie_client.audited_account = AWS_ACCOUNT_NUMBER macie_client.audited_account_arn = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:root" @@ -109,7 +109,7 @@ class Test_macie_automated_sensitive_data_discovery_enabled: @mock_aws def test_macie_enabled_automated_discovery_enabled(self): - macie_client = mock.MagicMock + macie_client = mock.MagicMock() macie_client.provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) macie_client.audited_account = AWS_ACCOUNT_NUMBER macie_client.audited_account_arn = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:root" diff --git a/tests/providers/aws/services/macie/macie_is_enabled/macie_is_enabled_test.py b/tests/providers/aws/services/macie/macie_is_enabled/macie_is_enabled_test.py index bf0339391a..0654f88023 100644 --- a/tests/providers/aws/services/macie/macie_is_enabled/macie_is_enabled_test.py +++ b/tests/providers/aws/services/macie/macie_is_enabled/macie_is_enabled_test.py @@ -14,12 +14,12 @@ from tests.providers.aws.utils import ( class Test_macie_is_enabled: @mock_aws def test_macie_disabled(self): - s3_client = mock.MagicMock + s3_client = mock.MagicMock() s3_client.provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) s3_client.buckets = {} s3_client.regions_with_buckets = [] - macie_client = mock.MagicMock + macie_client = mock.MagicMock() macie_client.provider = set_mocked_aws_provider( [AWS_REGION_EU_WEST_1], create_default_organization=False ) @@ -74,12 +74,12 @@ class Test_macie_is_enabled: @mock_aws def test_macie_enabled(self): - s3_client = mock.MagicMock + s3_client = mock.MagicMock() s3_client.provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) s3_client.buckets = {} s3_client.regions_with_buckets = [] - macie_client = mock.MagicMock + macie_client = mock.MagicMock() macie_client.provider = set_mocked_aws_provider( [AWS_REGION_EU_WEST_1], create_default_organization=False ) @@ -134,12 +134,12 @@ class Test_macie_is_enabled: @mock_aws def test_macie_suspended_ignored(self): - s3_client = mock.MagicMock + s3_client = mock.MagicMock() s3_client.provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) s3_client.buckets = {} s3_client.regions_with_buckets = [] - macie_client = mock.MagicMock + macie_client = mock.MagicMock() macie_client.provider = set_mocked_aws_provider( [AWS_REGION_EU_WEST_1], create_default_organization=False ) @@ -189,7 +189,7 @@ class Test_macie_is_enabled: @mock_aws def test_macie_suspended_ignored_with_buckets(self): - s3_client = mock.MagicMock + s3_client = mock.MagicMock() s3_client.regions_with_buckets = [AWS_REGION_EU_WEST_1] s3_client.provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) s3_client.buckets = [ @@ -200,7 +200,7 @@ class Test_macie_is_enabled: ) ] - macie_client = mock.MagicMock + macie_client = mock.MagicMock() macie_client.provider = set_mocked_aws_provider( [AWS_REGION_EU_WEST_1], create_default_organization=False ) @@ -258,10 +258,10 @@ class Test_macie_is_enabled: @mock_aws def test_macie_suspended(self): - s3_client = mock.MagicMock + s3_client = mock.MagicMock() s3_client.provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) - macie_client = mock.MagicMock + macie_client = mock.MagicMock() macie_client.provider = set_mocked_aws_provider( [AWS_REGION_EU_WEST_1], create_default_organization=False ) diff --git a/tests/providers/aws/services/networkfirewall/networkfirewall_deletion_protection/networkfirewall_deletion_protection_test.py b/tests/providers/aws/services/networkfirewall/networkfirewall_deletion_protection/networkfirewall_deletion_protection_test.py index 1ca85fe987..4ff0835ed0 100644 --- a/tests/providers/aws/services/networkfirewall/networkfirewall_deletion_protection/networkfirewall_deletion_protection_test.py +++ b/tests/providers/aws/services/networkfirewall/networkfirewall_deletion_protection/networkfirewall_deletion_protection_test.py @@ -14,7 +14,7 @@ POLICY_ARN = "arn:aws:network-firewall:us-east-1:123456789012:firewall-policy/my class Test_networkfirewall_deletion_protection: def test_no_networkfirewall(self): - networkfirewall_client = mock.MagicMock + networkfirewall_client = mock.MagicMock() networkfirewall_client.provider = set_mocked_aws_provider( [AWS_REGION_US_EAST_1] ) @@ -42,7 +42,7 @@ class Test_networkfirewall_deletion_protection: assert len(result) == 0 def test_networkfirewall_deletion_protection_disabled(self): - networkfirewall_client = mock.MagicMock + networkfirewall_client = mock.MagicMock() networkfirewall_client.provider = set_mocked_aws_provider( [AWS_REGION_US_EAST_1] ) @@ -89,7 +89,7 @@ class Test_networkfirewall_deletion_protection: assert result[0].resource_arn == FIREWALL_ARN def test_networkfirewall_deletion_protection_enabled(self): - networkfirewall_client = mock.MagicMock + networkfirewall_client = mock.MagicMock() networkfirewall_client.provider = set_mocked_aws_provider( [AWS_REGION_US_EAST_1] ) diff --git a/tests/providers/aws/services/networkfirewall/networkfirewall_in_all_vpc/networkfirewall_in_all_vpc_test.py b/tests/providers/aws/services/networkfirewall/networkfirewall_in_all_vpc/networkfirewall_in_all_vpc_test.py index 18193b4f17..dfcd664e3d 100644 --- a/tests/providers/aws/services/networkfirewall/networkfirewall_in_all_vpc/networkfirewall_in_all_vpc_test.py +++ b/tests/providers/aws/services/networkfirewall/networkfirewall_in_all_vpc/networkfirewall_in_all_vpc_test.py @@ -15,13 +15,13 @@ POLICY_ARN = "arn:aws:network-firewall:us-east-1:123456789012:firewall-policy/my class Test_networkfirewall_in_all_vpc: def test_no_vpcs(self): - networkfirewall_client = mock.MagicMock + networkfirewall_client = mock.MagicMock() networkfirewall_client.provider = set_mocked_aws_provider( [AWS_REGION_US_EAST_1] ) networkfirewall_client.region = AWS_REGION_US_EAST_1 networkfirewall_client.network_firewalls = {} - vpc_client = mock.MagicMock + vpc_client = mock.MagicMock() vpc_client.provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) vpc_client.region = AWS_REGION_US_EAST_1 vpc_client.vpcs = {} @@ -51,7 +51,7 @@ class Test_networkfirewall_in_all_vpc: assert len(result) == 0 def test_vpcs_with_firewall_all(self): - networkfirewall_client = mock.MagicMock + networkfirewall_client = mock.MagicMock() networkfirewall_client.provider = set_mocked_aws_provider( [AWS_REGION_US_EAST_1] ) @@ -68,7 +68,7 @@ class Test_networkfirewall_in_all_vpc: deletion_protection=True, ) } - vpc_client = mock.MagicMock + vpc_client = mock.MagicMock() vpc_client.provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) vpc_client.region = AWS_REGION_US_EAST_1 vpc_client.vpcs = { @@ -134,13 +134,13 @@ class Test_networkfirewall_in_all_vpc: assert result[0].resource_arn == "arn_test" def test_vpcs_without_firewall(self): - networkfirewall_client = mock.MagicMock + networkfirewall_client = mock.MagicMock() networkfirewall_client.provider = set_mocked_aws_provider( [AWS_REGION_US_EAST_1] ) networkfirewall_client.region = AWS_REGION_US_EAST_1 networkfirewall_client.network_firewalls = {} - vpc_client = mock.MagicMock + vpc_client = mock.MagicMock() vpc_client.provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) vpc_client.region = AWS_REGION_US_EAST_1 vpc_client.vpcs = { @@ -206,14 +206,14 @@ class Test_networkfirewall_in_all_vpc: assert result[0].resource_arn == "arn_test" def test_vpcs_with_name_without_firewall(self): - networkfirewall_client = mock.MagicMock + networkfirewall_client = mock.MagicMock() networkfirewall_client.provider = set_mocked_aws_provider( [AWS_REGION_US_EAST_1] ) networkfirewall_client.region = AWS_REGION_US_EAST_1 networkfirewall_client.network_firewalls = {} - vpc_client = mock.MagicMock + vpc_client = mock.MagicMock() vpc_client.provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) vpc_client.region = AWS_REGION_US_EAST_1 vpc_client.vpcs = { @@ -279,7 +279,7 @@ class Test_networkfirewall_in_all_vpc: assert result[0].resource_arn == "arn_test" def test_vpcs_with_and_without_firewall(self): - networkfirewall_client = mock.MagicMock + networkfirewall_client = mock.MagicMock() networkfirewall_client.provider = set_mocked_aws_provider( [AWS_REGION_US_EAST_1] ) @@ -296,7 +296,7 @@ class Test_networkfirewall_in_all_vpc: deletion_protection=True, ) } - vpc_client = mock.MagicMock + vpc_client = mock.MagicMock() vpc_client.provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) vpc_client.region = AWS_REGION_US_EAST_1 vpc_client.vpcs = { @@ -400,13 +400,13 @@ class Test_networkfirewall_in_all_vpc: assert r.resource_arn == "arn_test" def test_vpcs_without_firewall_ignoring(self): - networkfirewall_client = mock.MagicMock + networkfirewall_client = mock.MagicMock() networkfirewall_client.provider = set_mocked_aws_provider( [AWS_REGION_US_EAST_1] ) networkfirewall_client.region = AWS_REGION_US_EAST_1 networkfirewall_client.network_firewalls = {} - vpc_client = mock.MagicMock + vpc_client = mock.MagicMock() vpc_client.provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) vpc_client.region = AWS_REGION_US_EAST_1 vpc_client.vpcs = { @@ -464,13 +464,13 @@ class Test_networkfirewall_in_all_vpc: assert len(result) == 0 def test_vpcs_without_firewall_ignoring_vpc_in_use(self): - networkfirewall_client = mock.MagicMock + networkfirewall_client = mock.MagicMock() networkfirewall_client.provider = set_mocked_aws_provider( [AWS_REGION_US_EAST_1] ) networkfirewall_client.region = AWS_REGION_US_EAST_1 networkfirewall_client.network_firewalls = {} - vpc_client = mock.MagicMock + vpc_client = mock.MagicMock() vpc_client.provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) vpc_client.region = AWS_REGION_US_EAST_1 vpc_client.vpcs = { diff --git a/tests/providers/aws/services/networkfirewall/networkfirewall_logging_enabled/networkfirewall_logging_enabled_test.py b/tests/providers/aws/services/networkfirewall/networkfirewall_logging_enabled/networkfirewall_logging_enabled_test.py index 79add91c63..8bc3c6cf6f 100644 --- a/tests/providers/aws/services/networkfirewall/networkfirewall_logging_enabled/networkfirewall_logging_enabled_test.py +++ b/tests/providers/aws/services/networkfirewall/networkfirewall_logging_enabled/networkfirewall_logging_enabled_test.py @@ -17,7 +17,7 @@ POLICY_ARN = "arn:aws:network-firewall:us-east-1:123456789012:firewall-policy/my class Test_networkfirewall_logging_enabled: def test_no_networkfirewall(self): - networkfirewall_client = mock.MagicMock + networkfirewall_client = mock.MagicMock() networkfirewall_client.provider = set_mocked_aws_provider( [AWS_REGION_US_EAST_1] ) @@ -45,7 +45,7 @@ class Test_networkfirewall_logging_enabled: assert len(result) == 0 def test_networkfirewall_logging_disabled(self): - networkfirewall_client = mock.MagicMock + networkfirewall_client = mock.MagicMock() networkfirewall_client.provider = set_mocked_aws_provider( [AWS_REGION_US_EAST_1] ) @@ -93,7 +93,7 @@ class Test_networkfirewall_logging_enabled: assert result[0].resource_arn == FIREWALL_ARN def test_networkfirewall_logging_enabled(self): - networkfirewall_client = mock.MagicMock + networkfirewall_client = mock.MagicMock() networkfirewall_client.provider = set_mocked_aws_provider( [AWS_REGION_US_EAST_1] ) diff --git a/tests/providers/aws/services/networkfirewall/networkfirewall_multi_az/networkfirewall_multi_az_test.py b/tests/providers/aws/services/networkfirewall/networkfirewall_multi_az/networkfirewall_multi_az_test.py index 6e0d5f6750..49239d1282 100644 --- a/tests/providers/aws/services/networkfirewall/networkfirewall_multi_az/networkfirewall_multi_az_test.py +++ b/tests/providers/aws/services/networkfirewall/networkfirewall_multi_az/networkfirewall_multi_az_test.py @@ -16,7 +16,7 @@ POLICY_ARN = "arn:aws:network-firewall:us-east-1:123456789012:firewall-policy/my class Test_networkfirewall_multi_az: def test_no_networkfirewall(self): - networkfirewall_client = mock.MagicMock + networkfirewall_client = mock.MagicMock() networkfirewall_client.provider = set_mocked_aws_provider( [AWS_REGION_US_EAST_1] ) @@ -44,7 +44,7 @@ class Test_networkfirewall_multi_az: assert len(result) == 0 def test_networkfirewall_multi_az_disabled(self): - networkfirewall_client = mock.MagicMock + networkfirewall_client = mock.MagicMock() networkfirewall_client.provider = set_mocked_aws_provider( [AWS_REGION_US_EAST_1] ) @@ -97,7 +97,7 @@ class Test_networkfirewall_multi_az: assert result[0].resource_arn == FIREWALL_ARN def test_networkfirewall_multi_az_enabled(self): - networkfirewall_client = mock.MagicMock + networkfirewall_client = mock.MagicMock() networkfirewall_client.provider = set_mocked_aws_provider( [AWS_REGION_US_EAST_1] ) diff --git a/tests/providers/aws/services/networkfirewall/networkfirewall_policy_default_action_fragmented_packets/networkfirewall_policy_default_action_fragmented_packets_test.py b/tests/providers/aws/services/networkfirewall/networkfirewall_policy_default_action_fragmented_packets/networkfirewall_policy_default_action_fragmented_packets_test.py index cc7c8a17b1..beda353374 100644 --- a/tests/providers/aws/services/networkfirewall/networkfirewall_policy_default_action_fragmented_packets/networkfirewall_policy_default_action_fragmented_packets_test.py +++ b/tests/providers/aws/services/networkfirewall/networkfirewall_policy_default_action_fragmented_packets/networkfirewall_policy_default_action_fragmented_packets_test.py @@ -14,7 +14,7 @@ POLICY_ARN = "arn:aws:network-firewall:us-east-1:123456789012:firewall-policy/my class Test_networkfirewall_policy_default_action_fragmented_packets: def test_no_networkfirewall(self): - networkfirewall_client = mock.MagicMock + networkfirewall_client = mock.MagicMock() networkfirewall_client.provider = set_mocked_aws_provider( [AWS_REGION_US_EAST_1] ) @@ -42,7 +42,7 @@ class Test_networkfirewall_policy_default_action_fragmented_packets: assert len(result) == 0 def test_networkfirewall_default_stateless_action_drop(self): - networkfirewall_client = mock.MagicMock + networkfirewall_client = mock.MagicMock() networkfirewall_client.provider = set_mocked_aws_provider( [AWS_REGION_US_EAST_1] ) @@ -90,7 +90,7 @@ class Test_networkfirewall_policy_default_action_fragmented_packets: assert result[0].resource_arn == FIREWALL_ARN def test_networkfirewall_default_stateless_action_forward(self): - networkfirewall_client = mock.MagicMock + networkfirewall_client = mock.MagicMock() networkfirewall_client.provider = set_mocked_aws_provider( [AWS_REGION_US_EAST_1] ) @@ -139,7 +139,7 @@ class Test_networkfirewall_policy_default_action_fragmented_packets: assert result[0].resource_arn == FIREWALL_ARN def test_networkfirewall_default_stateless_action_pass(self): - networkfirewall_client = mock.MagicMock + networkfirewall_client = mock.MagicMock() networkfirewall_client.provider = set_mocked_aws_provider( [AWS_REGION_US_EAST_1] ) diff --git a/tests/providers/aws/services/networkfirewall/networkfirewall_policy_default_action_full_packets/networkfirewall_policy_default_action_full_packets_test.py b/tests/providers/aws/services/networkfirewall/networkfirewall_policy_default_action_full_packets/networkfirewall_policy_default_action_full_packets_test.py index 9388458eb6..d2a6cb5301 100644 --- a/tests/providers/aws/services/networkfirewall/networkfirewall_policy_default_action_full_packets/networkfirewall_policy_default_action_full_packets_test.py +++ b/tests/providers/aws/services/networkfirewall/networkfirewall_policy_default_action_full_packets/networkfirewall_policy_default_action_full_packets_test.py @@ -14,7 +14,7 @@ POLICY_ARN = "arn:aws:network-firewall:us-east-1:123456789012:firewall-policy/my class Test_networkfirewall_policy_default_action_full_packets: def test_no_networkfirewall(self): - networkfirewall_client = mock.MagicMock + networkfirewall_client = mock.MagicMock() networkfirewall_client.provider = set_mocked_aws_provider( [AWS_REGION_US_EAST_1] ) @@ -42,7 +42,7 @@ class Test_networkfirewall_policy_default_action_full_packets: assert len(result) == 0 def test_networkfirewall_policy_default_action_drop(self): - networkfirewall_client = mock.MagicMock + networkfirewall_client = mock.MagicMock() networkfirewall_client.provider = set_mocked_aws_provider( [AWS_REGION_US_EAST_1] ) @@ -90,7 +90,7 @@ class Test_networkfirewall_policy_default_action_full_packets: assert result[0].resource_arn == FIREWALL_ARN def test_networkfirewall_policy_default_action_forward(self): - networkfirewall_client = mock.MagicMock + networkfirewall_client = mock.MagicMock() networkfirewall_client.provider = set_mocked_aws_provider( [AWS_REGION_US_EAST_1] ) @@ -139,7 +139,7 @@ class Test_networkfirewall_policy_default_action_full_packets: assert result[0].resource_arn == FIREWALL_ARN def test_networkfirewall_policy_default_action_pass(self): - networkfirewall_client = mock.MagicMock + networkfirewall_client = mock.MagicMock() networkfirewall_client.provider = set_mocked_aws_provider( [AWS_REGION_US_EAST_1] ) diff --git a/tests/providers/aws/services/networkfirewall/networkfirewall_policy_rule_group_associated/networkfirewall_policy_rule_group_associated_test.py b/tests/providers/aws/services/networkfirewall/networkfirewall_policy_rule_group_associated/networkfirewall_policy_rule_group_associated_test.py index 1340e4b938..7ea7d46ef0 100644 --- a/tests/providers/aws/services/networkfirewall/networkfirewall_policy_rule_group_associated/networkfirewall_policy_rule_group_associated_test.py +++ b/tests/providers/aws/services/networkfirewall/networkfirewall_policy_rule_group_associated/networkfirewall_policy_rule_group_associated_test.py @@ -14,7 +14,7 @@ POLICY_ARN = "arn:aws:network-firewall:us-east-1:123456789012:firewall-policy/my class Test_networkfirewall_policy_rule_group_associated: def test_no_networkfirewall(self): - networkfirewall_client = mock.MagicMock + networkfirewall_client = mock.MagicMock() networkfirewall_client.provider = set_mocked_aws_provider( [AWS_REGION_US_EAST_1] ) @@ -42,7 +42,7 @@ class Test_networkfirewall_policy_rule_group_associated: assert len(result) == 0 def test_networkfirewall_policy_stateless_rule_group_associated(self): - networkfirewall_client = mock.MagicMock + networkfirewall_client = mock.MagicMock() networkfirewall_client.provider = set_mocked_aws_provider( [AWS_REGION_US_EAST_1] ) @@ -92,7 +92,7 @@ class Test_networkfirewall_policy_rule_group_associated: assert result[0].resource_arn == FIREWALL_ARN def test_networkfirewall_policy_stateful_rule_group_associated(self): - networkfirewall_client = mock.MagicMock + networkfirewall_client = mock.MagicMock() networkfirewall_client.provider = set_mocked_aws_provider( [AWS_REGION_US_EAST_1] ) @@ -142,7 +142,7 @@ class Test_networkfirewall_policy_rule_group_associated: assert result[0].resource_arn == FIREWALL_ARN def test_networkfirewall_policy_both_rule_groups_associated(self): - networkfirewall_client = mock.MagicMock + networkfirewall_client = mock.MagicMock() networkfirewall_client.provider = set_mocked_aws_provider( [AWS_REGION_US_EAST_1] ) @@ -196,7 +196,7 @@ class Test_networkfirewall_policy_rule_group_associated: assert result[0].resource_arn == FIREWALL_ARN def test_networkfirewall_policy_no_rule_groups_associated(self): - networkfirewall_client = mock.MagicMock + networkfirewall_client = mock.MagicMock() networkfirewall_client.provider = set_mocked_aws_provider( [AWS_REGION_US_EAST_1] ) diff --git a/tests/providers/aws/services/rolesanywhere/rolesanywhere_profile_restricts_session_permissions/rolesanywhere_profile_restricts_session_permissions_test.py b/tests/providers/aws/services/rolesanywhere/rolesanywhere_profile_restricts_session_permissions/rolesanywhere_profile_restricts_session_permissions_test.py index 09cdb3ae8d..230523239d 100644 --- a/tests/providers/aws/services/rolesanywhere/rolesanywhere_profile_restricts_session_permissions/rolesanywhere_profile_restricts_session_permissions_test.py +++ b/tests/providers/aws/services/rolesanywhere/rolesanywhere_profile_restricts_session_permissions/rolesanywhere_profile_restricts_session_permissions_test.py @@ -472,6 +472,19 @@ class Test_rolesanywhere_profile_restricts_session_permissions: assert result[0].status == "MANUAL" assert "could not be evaluated" in result[0].status_extended + def test_unscoped_profile_with_unlisted_roles_is_manual(self): + # iam:ListRoles denied leaves iam_client.roles as None. + patches = _patched( + _build_client({PROFILE_ARN: _profile(role_arns=[ADMIN_ROLE_ARN])}) + ) + patches[-1].new.roles = None + with _enter(patches): + result = _run() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ADMIN_ROLE_ARN in result[0].status_extended + assert "could not be evaluated" in result[0].status_extended + def test_unscoped_profile_without_roles_passes(self): with _enter(_patched(_build_client({PROFILE_ARN: _profile(role_arns=[])}))): result = _run() diff --git a/tests/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/rolesanywhere_trust_anchor_pqc_pki_test.py b/tests/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/rolesanywhere_trust_anchor_pqc_pki_test.py index 2038fdde57..a9a825d042 100644 --- a/tests/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/rolesanywhere_trust_anchor_pqc_pki_test.py +++ b/tests/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/rolesanywhere_trust_anchor_pqc_pki_test.py @@ -185,7 +185,7 @@ class Test_rolesanywhere_trust_anchor_pqc_pki: result = rolesanywhere_trust_anchor_pqc_pki().execute() assert len(result) == 1 - assert result[0].status == "FAIL" + assert result[0].status == "MANUAL" assert "could not be inspected" in result[0].status_extended def test_certificate_bundle_source(self): diff --git a/tests/providers/aws/services/s3/s3_bucket_cross_region_replication/s3_bucket_cross_region_replication_test.py b/tests/providers/aws/services/s3/s3_bucket_cross_region_replication/s3_bucket_cross_region_replication_test.py index 2cc4ffd5e5..4eb53dc387 100644 --- a/tests/providers/aws/services/s3/s3_bucket_cross_region_replication/s3_bucket_cross_region_replication_test.py +++ b/tests/providers/aws/services/s3/s3_bucket_cross_region_replication/s3_bucket_cross_region_replication_test.py @@ -1,6 +1,8 @@ from unittest import mock from boto3 import client +from botocore.client import BaseClient +from botocore.exceptions import ClientError from moto import mock_aws from tests.providers.aws.utils import ( @@ -9,6 +11,20 @@ from tests.providers.aws.utils import ( set_mocked_aws_provider, ) +_orig_make_api_call = BaseClient._make_api_call + + +def _deny(operation): + def mock_make_api_call(self, operation_name, kwarg): + if operation_name == operation: + raise ClientError( + {"Error": {"Code": "AccessDenied", "Message": "Access Denied"}}, + operation_name, + ) + return _orig_make_api_call(self, operation_name, kwarg) + + return mock_make_api_call + class Test_s3_bucket_cross_region_replication: # No Buckets @@ -598,10 +614,10 @@ class Test_s3_bucket_cross_region_replication: assert len(result) == 1 # US-EAST-1 Source Bucket - assert result[0].status == "FAIL" + assert result[0].status == "MANUAL" assert ( result[0].status_extended - == f"S3 Bucket {bucket_name_us} has cross region replication rule {repl_rule_id} in bucket {arn_bucket_eu.split(':')[-1]} which is out of Prowler's scope." + == f"S3 Bucket {bucket_name_us} has cross region replication rule {repl_rule_id} in bucket {arn_bucket_eu.split(':')[-1]} which is out of Prowler's scope; verify manually that the destination bucket is in a different region." ) assert result[0].resource_id == bucket_name_us assert ( @@ -609,3 +625,77 @@ class Test_s3_bucket_cross_region_replication: == f"arn:{aws_provider.identity.partition}:s3:::{bucket_name_us}" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_bucket_replication_access_denied_is_manual(self): + """s3:GetReplicationConfiguration denied -> MANUAL, not FAIL.""" + from prowler.providers.aws.services.s3.s3_service import S3 + + s3_client_us_east_1 = client("s3", region_name=AWS_REGION_US_EAST_1) + bucket_name = "bucket_test_us" + s3_client_us_east_1.create_bucket(Bucket=bucket_name) + s3_client_us_east_1.put_bucket_versioning( + Bucket=bucket_name, VersioningConfiguration={"Status": "Enabled"} + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "botocore.client.BaseClient._make_api_call", + new=_deny("GetBucketReplication"), + ), + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.s3.s3_bucket_cross_region_replication.s3_bucket_cross_region_replication.s3_client", + new=S3(aws_provider), + ), + ): + from prowler.providers.aws.services.s3.s3_bucket_cross_region_replication.s3_bucket_cross_region_replication import ( + s3_bucket_cross_region_replication, + ) + + result = s3_bucket_cross_region_replication().execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "s3:GetReplicationConfiguration" in result[0].status_extended + assert result[0].resource_id == bucket_name + + @mock_aws + def test_bucket_versioning_access_denied_is_manual(self): + """s3:GetBucketVersioning denied -> MANUAL, not FAIL.""" + from prowler.providers.aws.services.s3.s3_service import S3 + + s3_client_us_east_1 = client("s3", region_name=AWS_REGION_US_EAST_1) + bucket_name = "bucket_test_us" + s3_client_us_east_1.create_bucket(Bucket=bucket_name) + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "botocore.client.BaseClient._make_api_call", + new=_deny("GetBucketVersioning"), + ), + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.s3.s3_bucket_cross_region_replication.s3_bucket_cross_region_replication.s3_client", + new=S3(aws_provider), + ), + ): + from prowler.providers.aws.services.s3.s3_bucket_cross_region_replication.s3_bucket_cross_region_replication import ( + s3_bucket_cross_region_replication, + ) + + result = s3_bucket_cross_region_replication().execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "s3:GetBucketVersioning" in result[0].status_extended diff --git a/tests/providers/aws/services/sagemaker/sagemaker_notebook_instance_without_direct_internet_access_configured/sagemaker_notebook_instance_without_direct_internet_access_configured_test.py b/tests/providers/aws/services/sagemaker/sagemaker_notebook_instance_without_direct_internet_access_configured/sagemaker_notebook_instance_without_direct_internet_access_configured_test.py index da0f9548bd..1be22a9c3a 100644 --- a/tests/providers/aws/services/sagemaker/sagemaker_notebook_instance_without_direct_internet_access_configured/sagemaker_notebook_instance_without_direct_internet_access_configured_test.py +++ b/tests/providers/aws/services/sagemaker/sagemaker_notebook_instance_without_direct_internet_access_configured/sagemaker_notebook_instance_without_direct_internet_access_configured_test.py @@ -38,6 +38,48 @@ class Test_sagemaker_notebook_instance_without_direct_internet_access_configured result = check.execute() assert len(result) == 0 + def test_instance_direct_internet_unreported_is_manual(self): + """An unreported DirectInternetAccess must not read as disabled. + + The collector only ever assigned True, so "Disabled" and "the field was never + read" were both None and the check defaulted to PASS -- reporting an instance it + had not read as compliant. Now False means disabled and None means unknown. + """ + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_notebook_instances = [] + sagemaker_client.sagemaker_notebook_instances.append( + NotebookInstance( + name=test_notebook_instance, + arn=notebook_instance_arn, + region=AWS_REGION_EU_WEST_1, + direct_internet_access=None, + ) + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_notebook_instance_without_direct_internet_access_configured.sagemaker_notebook_instance_without_direct_internet_access_configured.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_notebook_instance_without_direct_internet_access_configured.sagemaker_notebook_instance_without_direct_internet_access_configured import ( + sagemaker_notebook_instance_without_direct_internet_access_configured, + ) + + check = ( + sagemaker_notebook_instance_without_direct_internet_access_configured() + ) + result = check.execute() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "did not report DirectInternetAccess" in result[0].status_extended + def test_instance_direct_internet_disabled(self): sagemaker_client = mock.MagicMock sagemaker_client.sagemaker_notebook_instances = [] diff --git a/tests/providers/aws/services/sagemaker/sagemaker_service_test.py b/tests/providers/aws/services/sagemaker/sagemaker_service_test.py index bfadd59efe..c931350639 100644 --- a/tests/providers/aws/services/sagemaker/sagemaker_service_test.py +++ b/tests/providers/aws/services/sagemaker/sagemaker_service_test.py @@ -262,6 +262,50 @@ class Test_SageMaker_Service: == lifecycle_config_name ) + def test_describe_notebook_instance_direct_internet_independent_of_root_access( + self, + ): + """DirectInternetAccess and RootAccess are separate settings and must be read separately. + + The shared fixture sets both to "Enabled", so a collector that reads RootAccess while + testing for the DirectInternetAccess key produces the right answer by coincidence. These + two cases separate the fields, which is the only way the confusion is visible. + """ + + def only_direct_internet(self, operation_name, kwarg): + """Serve a notebook instance with internet access on and root access off. + + The combination a collector reading RootAccess records as having NO direct internet + access, which is the false PASS. + """ + if operation_name == "DescribeNotebookInstance": + return {"DirectInternetAccess": "Enabled", "RootAccess": "Disabled"} + return mock_make_api_call(self, operation_name, kwarg) + + def only_root_access(self, operation_name, kwarg): + """Serve a notebook instance with internet access off and root access on. + + The mirror case: a collector reading RootAccess records direct internet access on an + instance that has none, which is the false FAIL. + """ + if operation_name == "DescribeNotebookInstance": + return {"DirectInternetAccess": "Disabled", "RootAccess": "Enabled"} + return mock_make_api_call(self, operation_name, kwarg) + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with patch( + "botocore.client.BaseClient._make_api_call", new=only_direct_internet + ): + notebook = SageMaker(aws_provider).sagemaker_notebook_instances[0] + assert notebook.direct_internet_access + assert not notebook.root_access + + with patch("botocore.client.BaseClient._make_api_call", new=only_root_access): + notebook = SageMaker(aws_provider).sagemaker_notebook_instances[0] + assert not notebook.direct_internet_access + assert notebook.root_access + # Test SageMaker describe notebook instance lifecycle config def test_describe_notebook_instance_lifecycle_config(self): aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) diff --git a/tests/providers/aws/services/secretsmanager/secretsmanager_has_restrictive_resource_policy/secretsmanager_has_restrictive_resource_policy_test.py b/tests/providers/aws/services/secretsmanager/secretsmanager_has_restrictive_resource_policy/secretsmanager_has_restrictive_resource_policy_test.py index 5482fcbbcc..cdf45e446e 100644 --- a/tests/providers/aws/services/secretsmanager/secretsmanager_has_restrictive_resource_policy/secretsmanager_has_restrictive_resource_policy_test.py +++ b/tests/providers/aws/services/secretsmanager/secretsmanager_has_restrictive_resource_policy/secretsmanager_has_restrictive_resource_policy_test.py @@ -103,12 +103,6 @@ class TestSecretsManagerHasRestrictiveResourcePolicy: with mock_aws(): aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) - from prowler.providers.aws.services.secretsmanager.secretsmanager_has_restrictive_resource_policy.secretsmanager_has_restrictive_resource_policy import ( - secretsmanager_client, - ) - - secretsmanager_client.secrets.clear() - with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", diff --git a/tests/providers/aws/services/transfer/transfer_server_fips_security_policy_enabled/transfer_server_fips_security_policy_enabled_test.py b/tests/providers/aws/services/transfer/transfer_server_fips_security_policy_enabled/transfer_server_fips_security_policy_enabled_test.py new file mode 100644 index 0000000000..c60add1918 --- /dev/null +++ b/tests/providers/aws/services/transfer/transfer_server_fips_security_policy_enabled/transfer_server_fips_security_policy_enabled_test.py @@ -0,0 +1,111 @@ +from unittest import mock +from unittest.mock import patch + +import botocore +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +SERVER_ID = "s-01234567890abcdef" +SERVER_ARN = ( + f"arn:aws:transfer:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:server/{SERVER_ID}" +) +CHECK_MODULE = "prowler.providers.aws.services.transfer.transfer_server_fips_security_policy_enabled.transfer_server_fips_security_policy_enabled" + +make_api_call = botocore.client.BaseClient._make_api_call + + +def mock_server_with_policy(security_policy_name): + def _mock(self, operation_name, kwarg): + if operation_name == "ListServers": + return {"Servers": [{"Arn": SERVER_ARN, "ServerId": SERVER_ID}]} + if operation_name == "DescribeServer": + return { + "Server": { + "Arn": SERVER_ARN, + "ServerId": SERVER_ID, + "Protocols": ["SFTP"], + "SecurityPolicyName": security_policy_name, + } + } + return make_api_call(self, operation_name, kwarg) + + return _mock + + +def execute_check(): + from prowler.providers.aws.services.transfer.transfer_service import Transfer + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{CHECK_MODULE}.transfer_client", new=Transfer(aws_provider)), + ): + from prowler.providers.aws.services.transfer.transfer_server_fips_security_policy_enabled.transfer_server_fips_security_policy_enabled import ( + transfer_server_fips_security_policy_enabled, + ) + + return transfer_server_fips_security_policy_enabled().execute() + + +class Test_transfer_server_fips_security_policy_enabled: + @mock_aws + def test_no_servers(self): + assert execute_check() == [] + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_server_with_policy("TransferSecurityPolicy-FIPS-2025-03"), + ) + @mock_aws + def test_fips_policy(self): + result = execute_check() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Transfer Server {SERVER_ID} uses FIPS security policy TransferSecurityPolicy-FIPS-2025-03." + ) + assert result[0].resource_id == SERVER_ID + assert result[0].resource_arn == SERVER_ARN + assert result[0].region == AWS_REGION_US_EAST_1 + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_server_with_policy("TransferSecurityPolicy-2024-01"), + ) + @mock_aws + def test_non_fips_policy(self): + result = execute_check() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Transfer Server {SERVER_ID} uses security policy TransferSecurityPolicy-2024-01, which is not a FIPS security policy." + ) + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_server_with_policy(""), + ) + @mock_aws + def test_policy_not_retrieved(self): + result = execute_check() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == f"Transfer Server security policies could not be retrieved for {SERVER_ID}; verify the transfer:DescribeServer permission." + ) + assert result[0].resource_id == AWS_ACCOUNT_NUMBER + assert result[0].region == AWS_REGION_US_EAST_1 diff --git a/tests/providers/aws/utils.py b/tests/providers/aws/utils.py index 90e2a98e85..c0cbff1c10 100644 --- a/tests/providers/aws/utils.py +++ b/tests/providers/aws/utils.py @@ -21,6 +21,7 @@ AWS_GOV_CLOUD_PARTITION = "aws-us-gov" AWS_CHINA_PARTITION = "aws-cn" AWS_EUSC_PARTITION = "aws-eusc" AWS_ISO_PARTITION = "aws-iso" +AWS_ISO_B_PARTITION = "aws-iso-b" # Root AWS Account AWS_ACCOUNT_NUMBER = "123456789012" @@ -51,9 +52,12 @@ AWS_REGION_CN_NORTH_1 = "cn-north-1" # Gov Cloud Regions AWS_REGION_GOV_CLOUD_US_EAST_1 = "us-gov-east-1" +AWS_REGION_GOV_CLOUD_US_WEST_1 = "us-gov-west-1" # Iso Regions -AWS_REGION_ISO_GLOBAL = "aws-iso-global" +AWS_REGION_ISO_EAST_1 = "us-iso-east-1" +AWS_REGION_ISO_WEST_1 = "us-iso-west-1" +AWS_REGION_ISO_B_EAST_1 = "us-isob-east-1" # European Sovereign Cloud Regions AWS_REGION_EUSC_DE_EAST_1 = "eusc-de-east-1" diff --git a/tests/providers/azure/lib/service/azure_service_test.py b/tests/providers/azure/lib/service/azure_service_test.py index 9360be85ea..49042dc476 100644 --- a/tests/providers/azure/lib/service/azure_service_test.py +++ b/tests/providers/azure/lib/service/azure_service_test.py @@ -106,3 +106,19 @@ class TestAzureServiceSovereignClouds: service.__set_clients__(identity, session, logs_service, region_config) logs_service.assert_called_once_with(credential=session, endpoint=logs_endpoint) + + +class TestAzureServiceRegionConfig: + def test_init_keeps_provider_region_config(self): + region_config = AzureRegionConfig( + name="AzureUSGovernment", + base_url="https://management.usgovcloudapi.net", + credential_scopes=["https://management.usgovcloudapi.net/.default"], + ) + provider = MagicMock() + provider.region_config = region_config + + with patch.object(AzureService, "__set_clients__", return_value={}): + service = AzureService(MagicMock(), provider) + + assert service.region_config is region_config diff --git a/tests/providers/azure/services/defender/defender_service_test.py b/tests/providers/azure/services/defender/defender_service_test.py index b50ddd26c9..d9cdb149ab 100644 --- a/tests/providers/azure/services/defender/defender_service_test.py +++ b/tests/providers/azure/services/defender/defender_service_test.py @@ -1,6 +1,7 @@ from datetime import timedelta from unittest.mock import MagicMock, patch +from prowler.providers.azure.models import AzureRegionConfig from prowler.providers.azure.services.defender.defender_service import ( Assesment, AutoProvisioningSetting, @@ -618,3 +619,53 @@ class Test_Defender_get_jit_policies: mock_client.jit_network_access_policies.list_by_resource_group.assert_called_once_with( resource_group_name="RG" ) + + +US_GOV_REGION_CONFIG = AzureRegionConfig( + name="AzureUSGovernment", + base_url="https://management.usgovcloudapi.net", + credential_scopes=["https://management.usgovcloudapi.net/.default"], +) + + +class Test_Defender_get_security_contacts_sovereign_cloud: + def _defender(self, provider): + with ( + patch(DEFENDER_INIT_PATCHES[0], return_value={}), + patch(DEFENDER_INIT_PATCHES[1], return_value={}), + patch(DEFENDER_INIT_PATCHES[2], return_value={}), + patch(DEFENDER_INIT_PATCHES[3], return_value={}), + patch(DEFENDER_INIT_PATCHES[4], return_value={}), + patch(DEFENDER_INIT_PATCHES[5], return_value={}), + patch(DEFENDER_INIT_PATCHES[6], return_value={}), + ): + return Defender(provider) + + def test_init_requests_token_for_cloud_scope(self): + provider = set_mocked_azure_provider(azure_region_config=US_GOV_REGION_CONFIG) + + self._defender(provider) + + provider.session.get_token.assert_called_once_with( + "https://management.usgovcloudapi.net/.default" + ) + + def test_get_security_contacts_uses_cloud_management_host(self): + provider = set_mocked_azure_provider(azure_region_config=US_GOV_REGION_CONFIG) + defender = self._defender(provider) + + response = MagicMock() + response.json.return_value = {"value": []} + with patch( + "prowler.providers.azure.services.defender.defender_service.requests.get", + return_value=response, + ) as mock_get: + result = defender._get_security_contacts(token="token") + + assert result == {AZURE_SUBSCRIPTION_ID: {}} + mock_get.assert_called_once() + url = mock_get.call_args.args[0] + assert url.startswith( + f"https://management.usgovcloudapi.net/subscriptions/{AZURE_SUBSCRIPTION_ID}/" + ) + assert "management.azure.com" not in url diff --git a/tests/providers/azure/services/entra/entra_global_admin_in_less_than_five_users/entra_global_admin_in_less_than_five_users_test.py b/tests/providers/azure/services/entra/entra_global_admin_in_less_than_five_users/entra_global_admin_in_less_than_five_users_test.py index 4270f485f3..a7dd874d5d 100644 --- a/tests/providers/azure/services/entra/entra_global_admin_in_less_than_five_users/entra_global_admin_in_less_than_five_users_test.py +++ b/tests/providers/azure/services/entra/entra_global_admin_in_less_than_five_users/entra_global_admin_in_less_than_five_users_test.py @@ -1,13 +1,18 @@ from unittest import mock from uuid import uuid4 -from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provider +from tests.providers.azure.azure_fixtures import ( + DOMAIN, + TENANT_IDS, + set_mocked_azure_provider, +) class Test_entra_global_admin_in_less_than_five_users: def test_entra_no_tenants(self): entra_client = mock.MagicMock entra_client.resource_groups = {} + entra_client.users_retrieval_errors = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -33,6 +38,7 @@ class Test_entra_global_admin_in_less_than_five_users: def test_entra_tenant_empty(self): entra_client = mock.MagicMock entra_client.resource_groups = {} + entra_client.users_retrieval_errors = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -58,6 +64,7 @@ class Test_entra_global_admin_in_less_than_five_users: def test_entra_less_than_five_global_admins(self): entra_client = mock.MagicMock entra_client.resource_groups = {} + entra_client.users_retrieval_errors = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -111,6 +118,7 @@ class Test_entra_global_admin_in_less_than_five_users: def test_entra_more_than_five_global_admins(self): entra_client = mock.MagicMock entra_client.resource_groups = {} + entra_client.users_retrieval_errors = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -179,6 +187,7 @@ class Test_entra_global_admin_in_less_than_five_users: def test_entra_exactly_five_global_admins(self): entra_client = mock.MagicMock entra_client.resource_groups = {} + entra_client.users_retrieval_errors = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -240,3 +249,46 @@ class Test_entra_global_admin_in_less_than_five_users: assert result[0].subscription == f"Tenant: {DOMAIN}" assert result[0].resource_name == "Global Administrator" assert result[0].resource_id == id + + def test_entra_users_retrieval_error_reports_single_manual(self): + """Graph could not return the tenant's users -> one tenant-level MANUAL.""" + entra_client = mock.MagicMock + entra_client.resource_groups = {} + entra_client.tenant_ids = [TENANT_IDS[0]] + entra_client.users_retrieval_errors = {DOMAIN: "ODataError HTTP 503"} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_global_admin_in_less_than_five_users.entra_global_admin_in_less_than_five_users.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_global_admin_in_less_than_five_users.entra_global_admin_in_less_than_five_users import ( + entra_global_admin_in_less_than_five_users, + ) + from prowler.providers.azure.services.entra.entra_service import ( + DirectoryRole, + ) + + # Directory roles were retrieved, but every member was filtered + # out because the users could not be fetched: without the error + # tracking this would be a false PASS with 0 administrators. + entra_client.directory_roles = { + DOMAIN: { + "Global Administrator": DirectoryRole(id=str(uuid4()), members=[]) + } + } + entra_client.users = {DOMAIN: {}} + + result = entra_global_admin_in_less_than_five_users().execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "did not return the tenant's users" in result[0].status_extended + assert "503" in result[0].status_extended + assert result[0].subscription == f"Tenant: {DOMAIN}" + assert result[0].resource_id == TENANT_IDS[0] diff --git a/tests/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa_test.py b/tests/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa_test.py index 04d838a2c0..91f7f02e05 100644 --- a/tests/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa_test.py +++ b/tests/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa_test.py @@ -1,13 +1,18 @@ from unittest import mock from uuid import uuid4 -from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provider +from tests.providers.azure.azure_fixtures import ( + DOMAIN, + TENANT_IDS, + set_mocked_azure_provider, +) class Test_entra_non_privileged_user_has_mfa: def test_entra_no_tenants(self): entra_client = mock.MagicMock entra_client.resource_groups = {} + entra_client.users_retrieval_errors = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -31,6 +36,7 @@ class Test_entra_non_privileged_user_has_mfa: def test_entra_tenant_no_users(self): entra_client = mock.MagicMock entra_client.resource_groups = {} + entra_client.users_retrieval_errors = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -54,6 +60,7 @@ class Test_entra_non_privileged_user_has_mfa: def test_entra_user_no_privileged_no_mfa(self): entra_client = mock.MagicMock entra_client.resource_groups = {} + entra_client.users_retrieval_errors = {} user_id = str(uuid4()) with ( @@ -102,6 +109,7 @@ class Test_entra_non_privileged_user_has_mfa: def test_entra_user_no_privileged_mfa(self): entra_client = mock.MagicMock entra_client.resource_groups = {} + entra_client.users_retrieval_errors = {} user_id = str(uuid4()) with ( @@ -147,6 +155,7 @@ class Test_entra_non_privileged_user_has_mfa: def test_entra_disabled_user_no_privileged_no_mfa(self): entra_client = mock.MagicMock entra_client.resource_groups = {} + entra_client.users_retrieval_errors = {} user_id = str(uuid4()) with ( @@ -188,6 +197,7 @@ class Test_entra_non_privileged_user_has_mfa: def test_entra_disabled_user_no_privileged_mfa(self): entra_client = mock.MagicMock entra_client.resource_groups = {} + entra_client.users_retrieval_errors = {} user_id = str(uuid4()) with ( @@ -229,6 +239,7 @@ class Test_entra_non_privileged_user_has_mfa: def test_entra_user_privileged_no_mfa(self): entra_client = mock.MagicMock entra_client.resource_groups = {} + entra_client.users_retrieval_errors = {} user_id = str(uuid4()) with ( @@ -271,6 +282,7 @@ class Test_entra_non_privileged_user_has_mfa: def test_entra_user_privileged_mfa(self): entra_client = mock.MagicMock entra_client.resource_groups = {} + entra_client.users_retrieval_errors = {} user_id = str(uuid4()) with ( @@ -309,3 +321,36 @@ class Test_entra_non_privileged_user_has_mfa: check = entra_non_privileged_user_has_mfa() result = check.execute() assert len(result) == 0 + + def test_entra_users_retrieval_error_reports_single_manual(self): + """Graph could not return the tenant's users -> one tenant-level MANUAL.""" + entra_client = mock.MagicMock + entra_client.resource_groups = {} + entra_client.tenant_ids = [TENANT_IDS[0]] + entra_client.users_retrieval_errors = {DOMAIN: "ODataError HTTP 503"} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_non_privileged_user_has_mfa.entra_non_privileged_user_has_mfa.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_non_privileged_user_has_mfa.entra_non_privileged_user_has_mfa import ( + entra_non_privileged_user_has_mfa, + ) + + entra_client.users = {DOMAIN: {}} + entra_client.directory_roles = {DOMAIN: {}} + + result = entra_non_privileged_user_has_mfa().execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "did not return the tenant's users" in result[0].status_extended + assert "503" in result[0].status_extended + assert result[0].subscription == f"Tenant: {DOMAIN}" + assert result[0].resource_id == TENANT_IDS[0] diff --git a/tests/providers/azure/services/entra/entra_privileged_user_has_mfa/entra_privileged_user_has_mfa_test.py b/tests/providers/azure/services/entra/entra_privileged_user_has_mfa/entra_privileged_user_has_mfa_test.py index 3475baf592..8a9e9c6e11 100644 --- a/tests/providers/azure/services/entra/entra_privileged_user_has_mfa/entra_privileged_user_has_mfa_test.py +++ b/tests/providers/azure/services/entra/entra_privileged_user_has_mfa/entra_privileged_user_has_mfa_test.py @@ -1,13 +1,18 @@ from unittest import mock from uuid import uuid4 -from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provider +from tests.providers.azure.azure_fixtures import ( + DOMAIN, + TENANT_IDS, + set_mocked_azure_provider, +) class Test_entra_privileged_user_has_mfa: def test_entra_no_tenants(self): entra_client = mock.MagicMock entra_client.resource_groups = {} + entra_client.users_retrieval_errors = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -31,6 +36,7 @@ class Test_entra_privileged_user_has_mfa: def test_entra_tenant_no_users(self): entra_client = mock.MagicMock entra_client.resource_groups = {} + entra_client.users_retrieval_errors = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -54,6 +60,7 @@ class Test_entra_privileged_user_has_mfa: def test_entra_user_no_privileged_no_mfa(self): entra_client = mock.MagicMock entra_client.resource_groups = {} + entra_client.users_retrieval_errors = {} user_id = str(uuid4()) with ( @@ -94,6 +101,7 @@ class Test_entra_privileged_user_has_mfa: def test_entra_user_no_privileged_mfa(self): entra_client = mock.MagicMock entra_client.resource_groups = {} + entra_client.users_retrieval_errors = {} user_id = str(uuid4()) with ( @@ -134,6 +142,7 @@ class Test_entra_privileged_user_has_mfa: def test_entra_user_privileged_no_mfa(self): entra_client = mock.MagicMock entra_client.resource_groups = {} + entra_client.users_retrieval_errors = {} user_id = str(uuid4()) with ( @@ -181,6 +190,7 @@ class Test_entra_privileged_user_has_mfa: def test_entra_user_privileged_mfa(self): entra_client = mock.MagicMock entra_client.resource_groups = {} + entra_client.users_retrieval_errors = {} user_id = str(uuid4()) with ( @@ -224,3 +234,36 @@ class Test_entra_privileged_user_has_mfa: assert result[0].resource_name == "foo" assert result[0].resource_id == user_id assert result[0].subscription == f"Tenant: {DOMAIN}" + + def test_entra_users_retrieval_error_reports_single_manual(self): + """Graph could not return the tenant's users -> one tenant-level MANUAL.""" + entra_client = mock.MagicMock + entra_client.resource_groups = {} + entra_client.tenant_ids = [TENANT_IDS[0]] + entra_client.users_retrieval_errors = {DOMAIN: "ODataError HTTP 503"} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_privileged_user_has_mfa.entra_privileged_user_has_mfa.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_privileged_user_has_mfa.entra_privileged_user_has_mfa import ( + entra_privileged_user_has_mfa, + ) + + entra_client.users = {DOMAIN: {}} + entra_client.directory_roles = {DOMAIN: {}} + + result = entra_privileged_user_has_mfa().execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "did not return the tenant's users" in result[0].status_extended + assert "503" in result[0].status_extended + assert result[0].subscription == f"Tenant: {DOMAIN}" + assert result[0].resource_id == TENANT_IDS[0] diff --git a/tests/providers/azure/services/entra/entra_service_test.py b/tests/providers/azure/services/entra/entra_service_test.py index ebd2b790ab..7f5a188391 100644 --- a/tests/providers/azure/services/entra/entra_service_test.py +++ b/tests/providers/azure/services/entra/entra_service_test.py @@ -305,3 +305,113 @@ def test_azure_entra__get_users_handles_pagination(): assert users["tenant-1"]["user-2"].account_enabled is True assert users["tenant-1"]["user-3"].is_mfa_capable is False assert users["tenant-1"]["user-3"].account_enabled is True + + +class TestGetUsersSignInActivity: + """Service-level coverage for the signInActivity 403 fallback.""" + + @staticmethod + def _graph_error(status): + error = Exception("graph error") + error.response_status_code = status + return error + + @staticmethod + def _users_response(value=None, next_link=None): + from types import SimpleNamespace + + return SimpleNamespace(value=value or [], odata_next_link=next_link) + + def _service(self, side_effect): + # SimpleNamespace instead of MagicMock: several check tests assign + # attributes on the MagicMock *class*, which would shadow instance + # child mocks here. + from types import SimpleNamespace + from unittest.mock import AsyncMock + + from prowler.providers.azure.services.entra.entra_service import Entra + + service = Entra.__new__(Entra) + client = SimpleNamespace( + users=SimpleNamespace(get=AsyncMock(side_effect=side_effect)) + ) + service.clients = {"tenant.onmicrosoft.com": client} + service.sign_in_activity_errors = {} + service.users_retrieval_errors = {} + service._get_user_registration_details = AsyncMock(return_value={}) + return service + + def test_403_records_tenant_and_retries_without_sign_in_activity(self): + import asyncio + + service = self._service( + side_effect=[self._graph_error(403), self._users_response()] + ) + users = asyncio.run(service._get_users()) + + assert "tenant.onmicrosoft.com" in service.sign_in_activity_errors + assert "403" in service.sign_in_activity_errors["tenant.onmicrosoft.com"] + assert service.users_retrieval_errors == {} + assert users == {"tenant.onmicrosoft.com": {}} + assert service.clients["tenant.onmicrosoft.com"].users.get.await_count == 2 + + def test_transient_error_does_not_blame_licensing(self): + import asyncio + + service = self._service(side_effect=[self._graph_error(503)]) + users = asyncio.run(service._get_users()) + + # The failure is not attributed to licensing/permissions, but the + # empty inventory is not trusted either: the tenant is recorded so + # the user-based checks report MANUAL. + assert service.sign_in_activity_errors == {} + assert "tenant.onmicrosoft.com" in service.users_retrieval_errors + assert "503" in service.users_retrieval_errors["tenant.onmicrosoft.com"] + assert users == {"tenant.onmicrosoft.com": {}} + assert service.clients["tenant.onmicrosoft.com"].users.get.await_count == 1 + + def test_failing_second_page_records_users_retrieval_error(self): + import asyncio + from types import SimpleNamespace + from unittest.mock import AsyncMock + + service = self._service( + side_effect=[ + self._users_response( + value=[ + SimpleNamespace( + id="user-1", + display_name="user-1", + account_enabled=True, + sign_in_activity=None, + ) + ], + next_link="https://graph.microsoft.com/v1.0/users?$skiptoken=page2", + ) + ] + ) + service.clients["tenant.onmicrosoft.com"].users.with_url = lambda _: ( + SimpleNamespace(get=AsyncMock(side_effect=self._graph_error(503))) + ) + users = asyncio.run(service._get_users()) + + # The first page made it into the inventory, but the tenant is marked + # unavailable: a partial inventory must not be evaluated as complete. + assert "user-1" in users["tenant.onmicrosoft.com"] + assert "tenant.onmicrosoft.com" in service.users_retrieval_errors + assert "503" in service.users_retrieval_errors["tenant.onmicrosoft.com"] + assert service.sign_in_activity_errors == {} + + def test_403_with_failing_retry_records_users_retrieval_error(self): + import asyncio + + service = self._service( + side_effect=[self._graph_error(403), self._graph_error(503)] + ) + users = asyncio.run(service._get_users()) + + assert "tenant.onmicrosoft.com" in service.sign_in_activity_errors + assert "tenant.onmicrosoft.com" in service.users_retrieval_errors + assert "503" in service.users_retrieval_errors["tenant.onmicrosoft.com"] + assert users == {"tenant.onmicrosoft.com": {}} + assert service.clients["tenant.onmicrosoft.com"].users.get.await_count == 2 diff --git a/tests/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in_test.py b/tests/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in_test.py index f940cf232c..2ff8884da2 100644 --- a/tests/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in_test.py +++ b/tests/providers/azure/services/entra/entra_user_with_recent_sign_in/entra_user_with_recent_sign_in_test.py @@ -2,12 +2,21 @@ from datetime import datetime, timedelta, timezone from unittest import mock from uuid import uuid4 -from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provider +from tests.providers.azure.azure_fixtures import ( + DOMAIN, + TENANT_IDS, + set_mocked_azure_provider, +) + +TENANT_ID = TENANT_IDS[0] class Test_entra_user_with_recent_sign_in: def test_entra_no_tenants(self): entra_client = mock.MagicMock + entra_client.sign_in_activity_errors = {} + entra_client.users_retrieval_errors = {} + entra_client.tenant_ids = [TENANT_ID] with ( mock.patch( @@ -31,6 +40,9 @@ class Test_entra_user_with_recent_sign_in: def test_entra_user_disabled(self): entra_client = mock.MagicMock + entra_client.sign_in_activity_errors = {} + entra_client.users_retrieval_errors = {} + entra_client.tenant_ids = [TENANT_ID] user_id = str(uuid4()) with ( @@ -63,6 +75,9 @@ class Test_entra_user_with_recent_sign_in: def test_entra_user_never_signed_in(self): entra_client = mock.MagicMock + entra_client.sign_in_activity_errors = {} + entra_client.users_retrieval_errors = {} + entra_client.tenant_ids = [TENANT_ID] user_id = str(uuid4()) with ( @@ -93,10 +108,13 @@ class Test_entra_user_with_recent_sign_in: result = check.execute() assert len(result) == 1 assert result[0].status == "FAIL" - assert "No sign-in activity data available" in result[0].status_extended + assert "no recorded sign-in activity" in result[0].status_extended - def test_entra_single_user_no_sign_in_data_reports_telemetry_gap(self): + def test_entra_single_user_no_sign_in_data_fails(self): entra_client = mock.MagicMock + entra_client.sign_in_activity_errors = {} + entra_client.users_retrieval_errors = {} + entra_client.tenant_ids = [TENANT_ID] user_id = str(uuid4()) with ( @@ -127,11 +145,13 @@ class Test_entra_user_with_recent_sign_in: result = check.execute() assert len(result) == 1 assert result[0].status == "FAIL" - assert "No sign-in activity data available" in result[0].status_extended - assert "1 enabled user" in result[0].status_extended + assert "no recorded sign-in activity" in result[0].status_extended def test_entra_user_stale_sign_in(self): entra_client = mock.MagicMock + entra_client.sign_in_activity_errors = {} + entra_client.users_retrieval_errors = {} + entra_client.tenant_ids = [TENANT_ID] user_id = str(uuid4()) with ( @@ -166,6 +186,9 @@ class Test_entra_user_with_recent_sign_in: def test_entra_user_recent_sign_in(self): entra_client = mock.MagicMock + entra_client.sign_in_activity_errors = {} + entra_client.users_retrieval_errors = {} + entra_client.tenant_ids = [TENANT_ID] user_id = str(uuid4()) with ( @@ -198,8 +221,11 @@ class Test_entra_user_with_recent_sign_in: assert result[0].status == "PASS" assert "10 days ago" in result[0].status_extended - def test_entra_all_users_no_sign_in_data_license_issue(self): + def test_entra_all_users_no_sign_in_data_fail(self): entra_client = mock.MagicMock + entra_client.sign_in_activity_errors = {} + entra_client.users_retrieval_errors = {} + entra_client.tenant_ids = [TENANT_ID] with ( mock.patch( @@ -231,14 +257,63 @@ class Test_entra_user_with_recent_sign_in: check = entra_user_with_recent_sign_in() result = check.execute() - # Should produce 1 finding (license warning), not 5 individual FAILs + # Graph returned the users without any sign-in: every one is stale + assert len(result) == 5 + assert all(r.status == "FAIL" for r in result) + + def test_entra_sign_in_activity_errors_reports_single_manual(self): + """Graph refused signInActivity (no P1/P2 or AuditLog.Read.All) -> one tenant MANUAL.""" + entra_client = mock.MagicMock + entra_client.tenant_ids = [TENANT_ID] + entra_client.sign_in_activity_errors = { + DOMAIN: "ODataError HTTP 403 Authentication_RequestFromNonPremiumTenantOrB2CTenant" + } + entra_client.users_retrieval_errors = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_service import User + from prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in import ( + entra_user_with_recent_sign_in, + ) + + # Users were re-fetched without signInActivity, so they exist but + # must not be evaluated individually. + entra_client.users = { + DOMAIN: { + str(uuid4()): User( + id=str(uuid4()), name="user", account_enabled=True + ) + } + } + + result = entra_user_with_recent_sign_in().execute() + assert len(result) == 1 - assert result[0].status == "FAIL" - assert "Entra ID P1/P2 licensing" in result[0].status_extended - assert "5 enabled users" in result[0].status_extended + assert result[0].status == "MANUAL" + assert "Entra ID P1/P2" in result[0].status_extended + assert "AuditLog.Read.All" in result[0].status_extended + assert ( + "Authentication_RequestFromNonPremiumTenantOrB2CTenant" + in result[0].status_extended + ) + assert result[0].resource_id == TENANT_ID + assert result[0].resource_name == DOMAIN + assert result[0].subscription == f"Tenant: {DOMAIN}" def test_entra_user_never_signed_in_when_telemetry_exists_for_tenant(self): entra_client = mock.MagicMock + entra_client.sign_in_activity_errors = {} + entra_client.users_retrieval_errors = {} + entra_client.tenant_ids = [TENANT_ID] with ( mock.patch( @@ -282,12 +357,16 @@ class Test_entra_user_with_recent_sign_in: r.status == "PASS" and "5 days ago" in r.status_extended for r in result ) assert any( - r.status == "FAIL" and "never signed in" in r.status_extended + r.status == "FAIL" + and "no recorded sign-in activity" in r.status_extended for r in result ) def test_entra_user_boundary_90_days(self): entra_client = mock.MagicMock + entra_client.sign_in_activity_errors = {} + entra_client.users_retrieval_errors = {} + entra_client.tenant_ids = [TENANT_ID] user_id = str(uuid4()) with ( @@ -319,3 +398,34 @@ class Test_entra_user_with_recent_sign_in: assert len(result) == 1 assert result[0].status == "PASS" assert "90 days ago" in result[0].status_extended + + def test_entra_users_retrieval_error_reports_single_manual(self): + """Graph could not return the tenant's users at all -> one tenant MANUAL.""" + entra_client = mock.MagicMock + entra_client.tenant_ids = [TENANT_ID] + entra_client.sign_in_activity_errors = {} + entra_client.users_retrieval_errors = {DOMAIN: "ODataError HTTP 503"} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in import ( + entra_user_with_recent_sign_in, + ) + + entra_client.users = {DOMAIN: {}} + + result = entra_user_with_recent_sign_in().execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "did not return the tenant's users" in result[0].status_extended + assert "503" in result[0].status_extended + assert result[0].resource_id == TENANT_ID diff --git a/tests/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa_test.py b/tests/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa_test.py index 83c06ea5b6..d62b16c520 100644 --- a/tests/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa_test.py +++ b/tests/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa_test.py @@ -7,6 +7,7 @@ from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, AZURE_SUBSCRIPTION_NAME, DOMAIN, + TENANT_IDS, set_mocked_azure_provider, ) @@ -18,12 +19,17 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa: iam_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} entra_client = mock.MagicMock entra_client.resource_groups = {} + entra_client.users_retrieval_errors = {} entra_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", return_value=set_mocked_azure_provider(), ), + mock.patch( + "prowler.providers.azure.services.entra.entra_user_with_vm_access_has_mfa.entra_user_with_vm_access_has_mfa.entra_client", + new=entra_client, + ), mock.patch( "prowler.providers.azure.services.entra.entra_user_with_vm_access_has_mfa.entra_user_with_vm_access_has_mfa.iam_client", new=iam_client, @@ -47,6 +53,7 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa: role_assigment_id = str(uuid4()) entra_client = mock.MagicMock entra_client.resource_groups = {} + entra_client.users_retrieval_errors = {} entra_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} user_id = str(uuid4()) @@ -120,6 +127,7 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa: role_assigment_id = str(uuid4()) entra_client = mock.MagicMock entra_client.resource_groups = {} + entra_client.users_retrieval_errors = {} entra_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} user_id = str(uuid4()) @@ -193,6 +201,7 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa: role_assigment_id = str(uuid4()) entra_client = mock.MagicMock entra_client.resource_groups = {} + entra_client.users_retrieval_errors = {} entra_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} user_id = str(uuid4()) @@ -249,6 +258,7 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa: role_assigment_id = str(uuid4()) entra_client = mock.MagicMock entra_client.resource_groups = {} + entra_client.users_retrieval_errors = {} entra_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} user_id = str(uuid4()) @@ -306,3 +316,44 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa: check = entra_user_with_vm_access_has_mfa() result = check.execute() assert len(result) == 0 + + def test_entra_users_retrieval_error_reports_single_manual(self): + """Graph could not return the tenant's users -> one tenant-level MANUAL.""" + iam_client = mock.MagicMock + iam_client.resource_groups = {} + iam_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + entra_client = mock.MagicMock + entra_client.resource_groups = {} + entra_client.users_retrieval_errors = {DOMAIN: "ODataError HTTP 503"} + entra_client.tenant_ids = [TENANT_IDS[0]] + entra_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_user_with_vm_access_has_mfa.entra_user_with_vm_access_has_mfa.entra_client", + new=entra_client, + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_user_with_vm_access_has_mfa.entra_user_with_vm_access_has_mfa.iam_client", + new=iam_client, + ), + ): + from prowler.providers.azure.services.entra.entra_user_with_vm_access_has_mfa.entra_user_with_vm_access_has_mfa import ( + entra_user_with_vm_access_has_mfa, + ) + + iam_client.role_assignments = {} + entra_client.users = {DOMAIN: {}} + + result = entra_user_with_vm_access_has_mfa().execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "did not return the tenant's users" in result[0].status_extended + assert "503" in result[0].status_extended + assert result[0].subscription == f"Tenant: {DOMAIN}" + assert result[0].resource_id == TENANT_IDS[0] diff --git a/tests/providers/azure/services/keyvault/keyvault_service_test.py b/tests/providers/azure/services/keyvault/keyvault_service_test.py index e43b7a9fff..d52d292af3 100644 --- a/tests/providers/azure/services/keyvault/keyvault_service_test.py +++ b/tests/providers/azure/services/keyvault/keyvault_service_test.py @@ -470,3 +470,87 @@ class Test_KeyVault_get_key_vaults: mock_client.vaults.list_by_resource_group.assert_called_once_with( resource_group_name="MyRG" ) + + +class Test_KeyVault_get_keys: + def test_get_keys_builds_key_client_from_vault_uri(self): + mock_client = MagicMock() + mock_client.keys.list.return_value = [] + + mock_provider = MagicMock() + mock_provider.identity = MagicMock() + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.azure.services.monitor.monitor_service.Monitor", + new=MagicMock(), + ), + patch( + "prowler.providers.azure.services.keyvault.keyvault_service.KeyVault._get_key_vaults", + return_value={}, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_service import ( + KeyVault, + ) + + keyvault = KeyVault(set_mocked_azure_provider()) + + keyvault.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + provider = set_mocked_azure_provider() + vault_uri = "https://my-vault.vault.usgovcloudapi.net/" + + with patch( + "prowler.providers.azure.services.keyvault.keyvault_service.KeyClient" + ) as mock_key_client_cls: + mock_key_client_cls.return_value.list_properties_of_keys.return_value = [] + keys = keyvault._get_keys( + AZURE_SUBSCRIPTION_ID, RESOURCE_GROUP, "my-vault", vault_uri, provider + ) + + assert keys == [] + mock_key_client_cls.assert_called_once_with( + vault_url=vault_uri, credential=provider.session + ) + + def test_get_keys_without_vault_uri_skips_rotation_policies(self): + mock_client = MagicMock() + mock_client.keys.list.return_value = [] + + mock_provider = MagicMock() + mock_provider.identity = MagicMock() + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.azure.services.monitor.monitor_service.Monitor", + new=MagicMock(), + ), + patch( + "prowler.providers.azure.services.keyvault.keyvault_service.KeyVault._get_key_vaults", + return_value={}, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_service import ( + KeyVault, + ) + + keyvault = KeyVault(set_mocked_azure_provider()) + + keyvault.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + provider = set_mocked_azure_provider() + + with patch( + "prowler.providers.azure.services.keyvault.keyvault_service.KeyClient" + ) as mock_key_client_cls: + keys = keyvault._get_keys( + AZURE_SUBSCRIPTION_ID, RESOURCE_GROUP, "my-vault", "", provider + ) + + assert keys == [] + mock_key_client_cls.assert_not_called() diff --git a/tests/providers/gcp/services/iam/iam_account_access_approval_enabled/iam_account_access_approval_enabled_test.py b/tests/providers/gcp/services/iam/iam_account_access_approval_enabled/iam_account_access_approval_enabled_test.py index fab9264535..c37ac8c139 100644 --- a/tests/providers/gcp/services/iam/iam_account_access_approval_enabled/iam_account_access_approval_enabled_test.py +++ b/tests/providers/gcp/services/iam/iam_account_access_approval_enabled/iam_account_access_approval_enabled_test.py @@ -8,6 +8,9 @@ from tests.providers.gcp.gcp_fixtures import GCP_PROJECT_ID, set_mocked_gcp_prov class Test_iam_account_access_approval_enabled: def test_iam_no_settings(self): accessapproval_client = mock.MagicMock() + accessapproval_client.api_disabled_project_ids = set() + accessapproval_client.api_state_unknown_project_ids = set() + accessapproval_client.settings_lookup_failed = set() accessapproval_client.settings = {} accessapproval_client.project_ids = [GCP_PROJECT_ID] accessapproval_client.region = "global" @@ -51,6 +54,9 @@ class Test_iam_account_access_approval_enabled: def test_iam_project_with_settings(self): cloudresourcemanager_client = mock.MagicMock() accessapproval_client = mock.MagicMock() + accessapproval_client.api_disabled_project_ids = set() + accessapproval_client.api_state_unknown_project_ids = set() + accessapproval_client.settings_lookup_failed = set() accessapproval_client.project_ids = [GCP_PROJECT_ID] accessapproval_client.region = "global" accessapproval_client.projects = { @@ -103,6 +109,9 @@ class Test_iam_account_access_approval_enabled: def test_iam_project_with_settings_empty_project_name(self): cloudresourcemanager_client = mock.MagicMock() accessapproval_client = mock.MagicMock() + accessapproval_client.api_disabled_project_ids = set() + accessapproval_client.api_state_unknown_project_ids = set() + accessapproval_client.settings_lookup_failed = set() accessapproval_client.project_ids = [GCP_PROJECT_ID] accessapproval_client.region = "global" accessapproval_client.projects = { @@ -151,3 +160,126 @@ class Test_iam_account_access_approval_enabled: assert result[0].resource_name == "GCP Project" assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == "global" + + def test_iam_settings_lookup_failed(self): + """Permission/API error reading the settings -> MANUAL, not FAIL.""" + accessapproval_client = mock.MagicMock() + accessapproval_client.api_disabled_project_ids = set() + accessapproval_client.api_state_unknown_project_ids = set() + accessapproval_client.settings = {} + accessapproval_client.settings_lookup_failed = {GCP_PROJECT_ID} + accessapproval_client.project_ids = [GCP_PROJECT_ID] + accessapproval_client.region = "global" + accessapproval_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="test", + labels={}, + lifecycle_state="ACTIVE", + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.iam.iam_account_access_approval_enabled.iam_account_access_approval_enabled.accessapproval_client", + new=accessapproval_client, + ), + ): + from prowler.providers.gcp.services.iam.iam_account_access_approval_enabled.iam_account_access_approval_enabled import ( + iam_account_access_approval_enabled, + ) + + check = iam_account_access_approval_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert search( + "Access Approval settings could not be read", + result[0].status_extended, + ) + assert result[0].resource_id == GCP_PROJECT_ID + assert result[0].project_id == GCP_PROJECT_ID + + def test_iam_api_disabled_project_is_fail(self): + """API definitively disabled -> Access Approval cannot be enabled -> FAIL.""" + accessapproval_client = mock.MagicMock() + accessapproval_client.settings = {} + accessapproval_client.settings_lookup_failed = set() + accessapproval_client.api_disabled_project_ids = {GCP_PROJECT_ID} + accessapproval_client.api_state_unknown_project_ids = set() + accessapproval_client.project_ids = [] + accessapproval_client.region = "global" + accessapproval_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="test", + labels={}, + lifecycle_state="ACTIVE", + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.iam.iam_account_access_approval_enabled.iam_account_access_approval_enabled.accessapproval_client", + new=accessapproval_client, + ), + ): + from prowler.providers.gcp.services.iam.iam_account_access_approval_enabled.iam_account_access_approval_enabled import ( + iam_account_access_approval_enabled, + ) + + result = iam_account_access_approval_enabled().execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "API is disabled" in result[0].status_extended + assert result[0].project_id == GCP_PROJECT_ID + + def test_iam_api_state_unknown_project_is_manual(self): + """API activation state undetermined -> evidence gap -> MANUAL.""" + accessapproval_client = mock.MagicMock() + accessapproval_client.settings = {} + accessapproval_client.settings_lookup_failed = set() + accessapproval_client.api_disabled_project_ids = set() + accessapproval_client.api_state_unknown_project_ids = {GCP_PROJECT_ID} + accessapproval_client.project_ids = [] + accessapproval_client.region = "global" + accessapproval_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="test", + labels={}, + lifecycle_state="ACTIVE", + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.iam.iam_account_access_approval_enabled.iam_account_access_approval_enabled.accessapproval_client", + new=accessapproval_client, + ), + ): + from prowler.providers.gcp.services.iam.iam_account_access_approval_enabled.iam_account_access_approval_enabled import ( + iam_account_access_approval_enabled, + ) + + result = iam_account_access_approval_enabled().execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "could not be determined" in result[0].status_extended diff --git a/tests/providers/gcp/services/iam/iamgcp_service_test.py b/tests/providers/gcp/services/iam/iamgcp_service_test.py index c2b5e655f2..1109356ca7 100644 --- a/tests/providers/gcp/services/iam/iamgcp_service_test.py +++ b/tests/providers/gcp/services/iam/iamgcp_service_test.py @@ -1,5 +1,5 @@ from datetime import datetime -from unittest.mock import patch +from unittest.mock import MagicMock, patch from prowler.providers.gcp.services.cloudresourcemanager.cloudresourcemanager_service import ( CloudResourceManager, @@ -113,6 +113,118 @@ class TestAccessApproval: access_approval_client.settings[GCP_PROJECT_ID].project_id == GCP_PROJECT_ID ) + assert access_approval_client.settings_lookup_failed == set() + + def _build_with_http_error(self, status): + from googleapiclient.errors import HttpError + + http_error = HttpError( + resp=MagicMock(status=status, reason="error"), + content=b'{"error": {"code": %d, "message": "error"}}' % status, + uri="https://accessapproval.googleapis.com/v1/projects/123/accessApprovalSettings", + ) + client = MagicMock() + client.projects().getAccessApprovalSettings().execute.side_effect = http_error + + 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__", + return_value=client, + ), + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + ): + from prowler.providers.gcp.services.iam.iam_service import AccessApproval + + return AccessApproval(set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID])) + + def test_settings_not_found_means_not_enabled(self): + """A 404 means Access Approval is not enabled: no settings, no error.""" + access_approval_client = self._build_with_http_error(404) + + assert access_approval_client.settings == {} + assert access_approval_client.settings_lookup_failed == set() + + def test_settings_permission_denied_is_tracked(self): + """A 403 (or API disabled) is a lookup failure, not 'not enabled'.""" + access_approval_client = self._build_with_http_error(403) + + assert access_approval_client.settings == {} + assert access_approval_client.settings_lookup_failed == {GCP_PROJECT_ID} + + def test_access_approval_api_disabled_is_tracked(self): + """A DISABLED serviceusage state must land in api_disabled_project_ids.""" + serviceusage_client = MagicMock() + serviceusage_client.services().get().execute.return_value = { + "state": "DISABLED" + } + + provider = set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + # The fixture is a MagicMock: make the API-activation precheck run. + provider.skip_api_check = False + + with ( + patch( + "prowler.providers.gcp.lib.service.service.discovery.build", + return_value=serviceusage_client, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + return_value=MagicMock(), + ), + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=provider, + ), + ): + from prowler.providers.gcp.services.iam.iam_service import AccessApproval + + access_approval_client = AccessApproval(provider) + + assert access_approval_client.project_ids == [] + assert access_approval_client.api_disabled_project_ids == {GCP_PROJECT_ID} + assert access_approval_client.api_state_unknown_project_ids == set() + assert access_approval_client.settings == {} + + def test_access_approval_api_state_unknown_is_tracked(self): + """A failing serviceusage call must land in api_state_unknown_project_ids.""" + serviceusage_client = MagicMock() + serviceusage_client.services().get().execute.side_effect = Exception( + "PERMISSION_DENIED: serviceusage.services.get" + ) + + provider = set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + provider.skip_api_check = False + + with ( + patch( + "prowler.providers.gcp.lib.service.service.discovery.build", + return_value=serviceusage_client, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + return_value=MagicMock(), + ), + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=provider, + ), + ): + from prowler.providers.gcp.services.iam.iam_service import AccessApproval + + access_approval_client = AccessApproval(provider) + + assert access_approval_client.project_ids == [] + assert access_approval_client.api_disabled_project_ids == set() + assert access_approval_client.api_state_unknown_project_ids == { + GCP_PROJECT_ID + } class TestEssentialContacts: diff --git a/tests/providers/github/services/repository/repository_service_test.py b/tests/providers/github/services/repository/repository_service_test.py index 4b77567e32..af174a6e54 100644 --- a/tests/providers/github/services/repository/repository_service_test.py +++ b/tests/providers/github/services/repository/repository_service_test.py @@ -1165,3 +1165,194 @@ class Test_Repository_List_Rate_Limit_Propagation: ): with raises(RateLimitExceededException): repository_service._list_repositories() + + +class Test_Repository_GraphQL_Pagination: + """Accessible repository discovery must follow GraphQL pagination.""" + + def _repository_service(self): + provider = set_mocked_github_provider() + provider.repositories = [] + provider.organizations = [] + with patch.object(Repository, "__init__", lambda *_: None): + repository_service = Repository(provider) + repository_service.clients = [MagicMock()] + repository_service.provider = provider + return repository_service + + @staticmethod + def _graphql_response(nodes, has_next_page=False, end_cursor=None, errors=None): + response = MagicMock() + payload = { + "data": { + "viewer": { + "repositories": { + "nodes": nodes, + "pageInfo": { + "hasNextPage": has_next_page, + "endCursor": end_cursor, + }, + } + } + } + } + if errors is not None: + payload["errors"] = errors + response.json.return_value = payload + return response + + def test_graphql_paginates_accessible_repositories(self): + repository_service = self._repository_service() + first_page_names = [f"owner/repo-{index}" for index in range(100)] + second_page_names = ["owner/repo-100"] + pages = [ + self._graphql_response( + [{"nameWithOwner": name} for name in first_page_names], + has_next_page=True, + end_cursor="page-2", + ), + self._graphql_response( + [{"nameWithOwner": name} for name in second_page_names] + ), + ] + + with patch("requests.post", side_effect=pages) as mock_post: + repositories = repository_service._get_accessible_repos_graphql() + + assert repositories == first_page_names + second_page_names + assert mock_post.call_count == 2 + first_request, second_request = mock_post.call_args_list + assert "after: $cursor" in first_request.kwargs["json"]["query"] + assert first_request.kwargs["json"]["variables"] == {"cursor": None} + assert second_request.kwargs["json"]["variables"] == {"cursor": "page-2"} + assert first_request.kwargs["timeout"] == (10, 60) + + def test_graphql_keeps_accessible_repositories_on_partial_errors(self): + """Per-node errors (e.g. SAML-protected repositories) must not drop the page.""" + repository_service = self._repository_service() + response = self._graphql_response( + [{"nameWithOwner": "owner/visible"}, None], + errors=[ + { + "type": "FORBIDDEN", + "path": ["viewer", "repositories", "nodes", 1], + "message": "Resource protected by organization SAML enforcement.", + } + ], + ) + + with ( + patch("requests.post", return_value=response), + patch( + "prowler.providers.github.services.repository.repository_service.logger" + ) as mock_logger, + ): + repositories = repository_service._get_accessible_repos_graphql() + + assert repositories == ["owner/visible"] + assert mock_logger.warning.call_count == 2 + assert "SAML" in str(mock_logger.warning.call_args_list[0]) + + def test_graphql_later_page_failure_keeps_collected_repositories(self): + repository_service = self._repository_service() + first_page = self._graphql_response( + [{"nameWithOwner": "owner/first"}], has_next_page=True, end_cursor="page-2" + ) + + with ( + patch( + "requests.post", + side_effect=[ + first_page, + requests.exceptions.Timeout("second page timed out"), + ], + ) as mock_post, + patch( + "prowler.providers.github.services.repository.repository_service.logger" + ) as mock_logger, + ): + repositories = repository_service._get_accessible_repos_graphql() + + assert repositories == ["owner/first"] + assert mock_post.call_count == 2 + mock_logger.error.assert_called_once() + assert "Timeout" in str(mock_logger.error.call_args) + + def test_graphql_errors_without_data_return_empty_list(self): + repository_service = self._repository_service() + response = MagicMock() + response.json.return_value = { + "data": None, + "errors": [{"type": "RATE_LIMITED", "message": "API rate limit exceeded"}], + } + + with ( + patch("requests.post", return_value=response), + patch( + "prowler.providers.github.services.repository.repository_service.logger" + ) as mock_logger, + ): + repositories = repository_service._get_accessible_repos_graphql() + + assert repositories == [] + assert "RATE_LIMITED" in str(mock_logger.error.call_args) + + def test_graphql_stops_on_repeated_cursor(self): + repository_service = self._repository_service() + pages = [ + self._graphql_response( + [{"nameWithOwner": "owner/a"}], has_next_page=True, end_cursor="same" + ), + self._graphql_response( + [{"nameWithOwner": "owner/b"}], has_next_page=True, end_cursor="same" + ), + ] + + with ( + patch("requests.post", side_effect=pages) as mock_post, + patch( + "prowler.providers.github.services.repository.repository_service.logger" + ) as mock_logger, + ): + repositories = repository_service._get_accessible_repos_graphql() + + assert repositories == ["owner/a", "owner/b"] + assert mock_post.call_count == 2 + mock_logger.error.assert_called_once() + + @pytest.mark.parametrize( + "nodes, page_info", + [ + ("not-a-list", {"hasNextPage": False}), + ({}, {"hasNextPage": False}), + (None, {"hasNextPage": False}), + ([{"nameWithOwner": "owner/b"}], ["invalid"]), + ([{"nameWithOwner": "owner/b"}], "invalid"), + ([{"nameWithOwner": "owner/b"}], []), + ([{"nameWithOwner": "owner/b"}], None), + ([{"nameWithOwner": "owner/b"}], {}), + ([{"nameWithOwner": "owner/b"}], {"hasNextPage": "false"}), + ], + ) + def test_graphql_invalid_page_keeps_collected_repositories(self, nodes, page_info): + repository_service = self._repository_service() + first_page = self._graphql_response( + [{"nameWithOwner": "owner/a"}], has_next_page=True, end_cursor="page-2" + ) + invalid_page = MagicMock() + invalid_page.json.return_value = { + "data": { + "viewer": {"repositories": {"nodes": nodes, "pageInfo": page_info}} + } + } + + with ( + patch("requests.post", side_effect=[first_page, invalid_page]), + patch( + "prowler.providers.github.services.repository.repository_service.logger" + ) as mock_logger, + ): + repositories = repository_service._get_accessible_repos_graphql() + + assert repositories == ["owner/a"] + mock_logger.error.assert_called_once() diff --git a/tests/providers/googleworkspace/googleworkspace_fixtures.py b/tests/providers/googleworkspace/googleworkspace_fixtures.py index 72744b6244..96e50ce2e0 100644 --- a/tests/providers/googleworkspace/googleworkspace_fixtures.py +++ b/tests/providers/googleworkspace/googleworkspace_fixtures.py @@ -1,5 +1,6 @@ """Test fixtures for Google Workspace provider tests""" +from typing import Optional from unittest.mock import MagicMock from prowler.providers.googleworkspace.models import ( @@ -81,17 +82,19 @@ ROLE_GROUPS_ADMIN = { def set_mocked_googleworkspace_provider( - identity: GoogleWorkspaceIdentityInfo = GoogleWorkspaceIdentityInfo( + identity: Optional[GoogleWorkspaceIdentityInfo] = None, +): + provider = MagicMock() + provider.type = "googleworkspace" + # Built per call: as a default argument every test would share one instance, + # and a test mutating it would leak into the rest of the session. + provider.identity = identity or GoogleWorkspaceIdentityInfo( domain=DOMAIN, customer_id=CUSTOMER_ID, delegated_user=DELEGATED_USER, root_org_unit_id=ROOT_ORG_UNIT_ID, profile="default", - ), -): - provider = MagicMock() - provider.type = "googleworkspace" - provider.identity = identity + ) provider.domain_resource = build_googleworkspace_domain_resource() return provider diff --git a/tests/providers/googleworkspace/lib/service/googleworkspace_service_test.py b/tests/providers/googleworkspace/lib/service/googleworkspace_service_test.py index 4581ffa527..5e72d285d8 100644 --- a/tests/providers/googleworkspace/lib/service/googleworkspace_service_test.py +++ b/tests/providers/googleworkspace/lib/service/googleworkspace_service_test.py @@ -1,6 +1,13 @@ from unittest.mock import MagicMock -from prowler.providers.googleworkspace.lib.service.service import GoogleWorkspaceService +import pytest + +from prowler.providers.googleworkspace.lib.service.service import ( + CUSTOMER_SCOPE, + OVERRIDE_SCOPE, + UNKNOWN_SCOPE, + GoogleWorkspaceService, +) ROOT_OU_ID = "03ph8a2z1234" @@ -80,3 +87,41 @@ class TestIsCustomerLevelPolicy: ) is False ) + + +class TestPolicyScope: + @pytest.mark.parametrize( + "policy, expected", + [ + ({}, CUSTOMER_SCOPE), + ({"policyQuery": {}}, CUSTOMER_SCOPE), + ({"policyQuery": None}, CUSTOMER_SCOPE), + ({"policyQuery": {"orgUnit": ""}}, CUSTOMER_SCOPE), + ({"policyQuery": {"orgUnit": f"orgUnits/{ROOT_OU_ID}"}}, CUSTOMER_SCOPE), + ({"policyQuery": {"orgUnit": "orgUnits/sub_ou"}}, OVERRIDE_SCOPE), + ({"policyQuery": {"group": "groups/xyz"}}, OVERRIDE_SCOPE), + ( + {"policyQuery": {"group": "groups/xyz", "orgUnit": "orgUnits/sub_ou"}}, + OVERRIDE_SCOPE, + ), + ], + ) + def test_scope_with_a_known_root_org_unit(self, policy, expected): + assert _make_service()._policy_scope(policy) == expected + + @pytest.mark.parametrize("org_unit", [f"orgUnits/{ROOT_OU_ID}", "orgUnits/sub_ou"]) + def test_without_the_root_id_an_org_unit_scope_is_unknown(self, org_unit): + """The root OU and a sub-OU are indistinguishable, so neither may be assumed""" + svc = _make_service(root_org_unit_id=None) + + assert ( + svc._policy_scope({"policyQuery": {"orgUnit": org_unit}}) == UNKNOWN_SCOPE + ) + + def test_a_group_is_an_override_even_without_the_root_id(self): + svc = _make_service(root_org_unit_id=None) + + assert ( + svc._policy_scope({"policyQuery": {"group": "groups/xyz"}}) + == OVERRIDE_SCOPE + ) diff --git a/tests/providers/googleworkspace/services/gmail/gmail_domain_spoofing_protection_enabled/gmail_domain_spoofing_protection_enabled_test.py b/tests/providers/googleworkspace/services/gmail/gmail_domain_spoofing_protection_enabled/gmail_domain_spoofing_protection_enabled_test.py index de18c6d9c6..a87979ee5c 100644 --- a/tests/providers/googleworkspace/services/gmail/gmail_domain_spoofing_protection_enabled/gmail_domain_spoofing_protection_enabled_test.py +++ b/tests/providers/googleworkspace/services/gmail/gmail_domain_spoofing_protection_enabled/gmail_domain_spoofing_protection_enabled_test.py @@ -40,6 +40,38 @@ class TestGmailDomainSpoofingProtectionEnabled: assert findings[0].resource_name == "Gmail Policies" assert findings[0].customer_id == CUSTOMER_ID + def test_pass_enable_flag_not_returned(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_domain_spoofing_protection_enabled.gmail_domain_spoofing_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_domain_spoofing_protection_enabled.gmail_domain_spoofing_protection_enabled import ( + gmail_domain_spoofing_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + domain_spoofing_consequence="SPAM_FOLDER", + ) + + check = gmail_domain_spoofing_protection_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "uses Google's default (enabled)" in findings[0].status_extended + assert "is enabled with action" not in findings[0].status_extended + assert findings[0].resource_name == "Gmail Policies" + assert findings[0].customer_id == CUSTOMER_ID + def test_fail_no_action(self): mock_provider = set_mocked_googleworkspace_provider() @@ -70,6 +102,36 @@ class TestGmailDomainSpoofingProtectionEnabled: assert findings[0].status == "FAIL" assert "no action" in findings[0].status_extended + def test_fail_warning_action(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_domain_spoofing_protection_enabled.gmail_domain_spoofing_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_domain_spoofing_protection_enabled.gmail_domain_spoofing_protection_enabled import ( + gmail_domain_spoofing_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + detect_domain_name_spoofing=True, + domain_spoofing_consequence="WARNING", + ) + + check = gmail_domain_spoofing_protection_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "show a warning" in findings[0].status_extended + def test_fail_protection_disabled(self): mock_provider = set_mocked_googleworkspace_provider() @@ -100,7 +162,7 @@ class TestGmailDomainSpoofingProtectionEnabled: assert findings[0].status == "FAIL" assert "disabled" in findings[0].status_extended - def test_pass_using_default(self): + def test_fail_using_default(self): mock_provider = set_mocked_googleworkspace_provider() with ( @@ -124,8 +186,8 @@ class TestGmailDomainSpoofingProtectionEnabled: findings = check.execute() assert len(findings) == 1 - assert findings[0].status == "PASS" - assert "secure default" in findings[0].status_extended + assert findings[0].status == "FAIL" + assert "default action" in findings[0].status_extended def test_no_findings_when_fetch_failed(self): mock_provider = set_mocked_googleworkspace_provider() diff --git a/tests/providers/googleworkspace/services/gmail/gmail_employee_name_spoofing_protection_enabled/gmail_employee_name_spoofing_protection_enabled_test.py b/tests/providers/googleworkspace/services/gmail/gmail_employee_name_spoofing_protection_enabled/gmail_employee_name_spoofing_protection_enabled_test.py index d30284b852..c4ba415ba7 100644 --- a/tests/providers/googleworkspace/services/gmail/gmail_employee_name_spoofing_protection_enabled/gmail_employee_name_spoofing_protection_enabled_test.py +++ b/tests/providers/googleworkspace/services/gmail/gmail_employee_name_spoofing_protection_enabled/gmail_employee_name_spoofing_protection_enabled_test.py @@ -70,6 +70,36 @@ class TestGmailEmployeeNameSpoofingProtectionEnabled: assert findings[0].status == "FAIL" assert "no action" in findings[0].status_extended + def test_fail_warning_action(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_employee_name_spoofing_protection_enabled.gmail_employee_name_spoofing_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_employee_name_spoofing_protection_enabled.gmail_employee_name_spoofing_protection_enabled import ( + gmail_employee_name_spoofing_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + detect_employee_name_spoofing=True, + employee_name_spoofing_consequence="WARNING", + ) + + check = gmail_employee_name_spoofing_protection_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "show a warning" in findings[0].status_extended + def test_fail_protection_disabled(self): mock_provider = set_mocked_googleworkspace_provider() @@ -100,7 +130,7 @@ class TestGmailEmployeeNameSpoofingProtectionEnabled: assert findings[0].status == "FAIL" assert "disabled" in findings[0].status_extended - def test_pass_using_default(self): + def test_fail_using_default(self): mock_provider = set_mocked_googleworkspace_provider() with ( @@ -124,8 +154,8 @@ class TestGmailEmployeeNameSpoofingProtectionEnabled: findings = check.execute() assert len(findings) == 1 - assert findings[0].status == "PASS" - assert "secure default" in findings[0].status_extended + assert findings[0].status == "FAIL" + assert "default action" in findings[0].status_extended def test_no_findings_when_fetch_failed(self): mock_provider = set_mocked_googleworkspace_provider() diff --git a/tests/providers/googleworkspace/services/gmail/gmail_groups_spoofing_protection_enabled/gmail_groups_spoofing_protection_enabled_test.py b/tests/providers/googleworkspace/services/gmail/gmail_groups_spoofing_protection_enabled/gmail_groups_spoofing_protection_enabled_test.py index b06092ebe5..1decd300f2 100644 --- a/tests/providers/googleworkspace/services/gmail/gmail_groups_spoofing_protection_enabled/gmail_groups_spoofing_protection_enabled_test.py +++ b/tests/providers/googleworkspace/services/gmail/gmail_groups_spoofing_protection_enabled/gmail_groups_spoofing_protection_enabled_test.py @@ -103,6 +103,36 @@ class TestGmailGroupsSpoofingProtectionEnabled: assert findings[0].status == "FAIL" assert "no action" in findings[0].status_extended + def test_fail_warning_action(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_groups_spoofing_protection_enabled.gmail_groups_spoofing_protection_enabled import ( + gmail_groups_spoofing_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + detect_groups_spoofing=True, + groups_spoofing_consequence="WARNING", + ) + + check = gmail_groups_spoofing_protection_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "show a warning" in findings[0].status_extended + def test_fail_protection_disabled(self): mock_provider = set_mocked_googleworkspace_provider() diff --git a/tests/providers/googleworkspace/services/gmail/gmail_inbound_domain_spoofing_protection_enabled/gmail_inbound_domain_spoofing_protection_enabled_test.py b/tests/providers/googleworkspace/services/gmail/gmail_inbound_domain_spoofing_protection_enabled/gmail_inbound_domain_spoofing_protection_enabled_test.py index b319b8fdb1..1677df655c 100644 --- a/tests/providers/googleworkspace/services/gmail/gmail_inbound_domain_spoofing_protection_enabled/gmail_inbound_domain_spoofing_protection_enabled_test.py +++ b/tests/providers/googleworkspace/services/gmail/gmail_inbound_domain_spoofing_protection_enabled/gmail_inbound_domain_spoofing_protection_enabled_test.py @@ -70,6 +70,36 @@ class TestGmailInboundDomainSpoofingProtectionEnabled: assert findings[0].status == "FAIL" assert "no action" in findings[0].status_extended + def test_fail_warning_action(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.gmail.gmail_inbound_domain_spoofing_protection_enabled.gmail_inbound_domain_spoofing_protection_enabled.gmail_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.gmail.gmail_inbound_domain_spoofing_protection_enabled.gmail_inbound_domain_spoofing_protection_enabled import ( + gmail_inbound_domain_spoofing_protection_enabled, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = GmailPolicies( + detect_inbound_domain_spoofing=True, + inbound_domain_spoofing_consequence="WARNING", + ) + + check = gmail_inbound_domain_spoofing_protection_enabled() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "show a warning" in findings[0].status_extended + def test_fail_protection_disabled(self): mock_provider = set_mocked_googleworkspace_provider() @@ -100,7 +130,7 @@ class TestGmailInboundDomainSpoofingProtectionEnabled: assert findings[0].status == "FAIL" assert "disabled" in findings[0].status_extended - def test_pass_using_default(self): + def test_fail_using_default(self): mock_provider = set_mocked_googleworkspace_provider() with ( @@ -124,8 +154,8 @@ class TestGmailInboundDomainSpoofingProtectionEnabled: findings = check.execute() assert len(findings) == 1 - assert findings[0].status == "PASS" - assert "secure default" in findings[0].status_extended + assert findings[0].status == "FAIL" + assert "default action" in findings[0].status_extended def test_no_findings_when_fetch_failed(self): mock_provider = set_mocked_googleworkspace_provider() diff --git a/tests/providers/googleworkspace/services/gmail/lib/spoofing_test.py b/tests/providers/googleworkspace/services/gmail/lib/spoofing_test.py new file mode 100644 index 0000000000..34d14c8de5 --- /dev/null +++ b/tests/providers/googleworkspace/services/gmail/lib/spoofing_test.py @@ -0,0 +1,34 @@ +import pytest + +from prowler.providers.googleworkspace.services.gmail.lib.spoofing import ( + PROTECTIVE_CONSEQUENCES, + describe_consequence, + is_protective, +) + + +class TestIsProtective: + @pytest.mark.parametrize("consequence", sorted(PROTECTIVE_CONSEQUENCES)) + def test_actions_that_move_the_message_out_of_the_inbox(self, consequence): + assert is_protective(consequence) is True + + @pytest.mark.parametrize( + "consequence", ["WARNING", "NO_ACTION", None, "", "spam_folder", "UNKNOWN"] + ) + def test_everything_else_is_not_protective(self, consequence): + """Anything the benchmark does not accept, including unknown values""" + assert is_protective(consequence) is False + + +class TestDescribeConsequence: + @pytest.mark.parametrize( + "consequence, expected", + [ + (None, "uses Google's default action"), + ("NO_ACTION", "is set to take no action"), + ("WARNING", "show a warning"), + ("SOMETHING_NEW", "is set to 'SOMETHING_NEW'"), + ], + ) + def test_renders_for_finding_messages(self, consequence, expected): + assert expected in describe_consequence(consequence) diff --git a/tests/providers/googleworkspace/services/rules/lib/alerts_test.py b/tests/providers/googleworkspace/services/rules/lib/alerts_test.py new file mode 100644 index 0000000000..886de269c2 --- /dev/null +++ b/tests/providers/googleworkspace/services/rules/lib/alerts_test.py @@ -0,0 +1,154 @@ +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from prowler.lib.check.models import CheckMetadata +from prowler.providers.googleworkspace.services.rules import rules_service +from prowler.providers.googleworkspace.services.rules.lib.alerts import ( + evaluate_system_defined_alert, +) +from prowler.providers.googleworkspace.services.rules.rules_service import ( + SystemDefinedAlert, +) +from tests.providers.googleworkspace.googleworkspace_fixtures import ( + set_mocked_googleworkspace_provider, +) + +RULE_NAME = "Leaked password" +OTHER_RULE = "Suspicious login" + + +def make_client(alerts, policies_fetched=True): + client = MagicMock() + client.provider = set_mocked_googleworkspace_provider() + client.policies_fetched = policies_fetched + client.system_defined_alerts = alerts + return client + + +def configured(**overrides): + values = dict( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=True, + all_super_admins=True, + ) + values.update(overrides) + return SystemDefinedAlert(**values) + + +# Real metadata: CheckReportGoogleWorkspace validates it, a mock will not do. +# Loaded from the file rather than from the check class, whose module import +# builds the service client and needs a live provider. +METADATA_FILE = ( + Path(rules_service.__file__).parent + / "rules_leaked_password_alert_configured" + / "rules_leaked_password_alert_configured.metadata.json" +) +METADATA = CheckMetadata.parse_file(METADATA_FILE).json() + + +def run(alerts, minimum_severity="MEDIUM", policies_fetched=True): + return evaluate_system_defined_alert( + make_client(alerts, policies_fetched), METADATA, RULE_NAME, minimum_severity + ) + + +class TestEvaluateSystemDefinedAlert: + def test_evaluates_only_the_requested_rule(self): + findings = run( + [ + configured(display_name=OTHER_RULE, state="INACTIVE", severity=None), + configured(), + configured(display_name="Government-backed attacks", severity="HIGH"), + ] + ) + + assert len(findings) == 1 + assert findings[0].resource_name == RULE_NAME + assert findings[0].status == "PASS" + + def test_no_finding_when_the_rule_is_absent(self): + assert run([configured(display_name=OTHER_RULE)]) == [] + + def test_no_finding_when_fetch_failed(self): + assert run([configured()], policies_fetched=False) == [] + + @pytest.mark.parametrize("severity", ["MEDIUM", "HIGH"]) + def test_a_severity_above_the_minimum_is_stricter_not_weaker(self, severity): + findings = run([configured(severity=severity)], "MEDIUM") + + assert findings[0].status == "PASS" + + @pytest.mark.parametrize("severity", ["CRITICAL", "high", "SEVERITY_UNSPECIFIED"]) + def test_an_unrankable_severity_is_not_claimed_to_be_below_the_minimum( + self, severity + ): + """Saying CRITICAL falls short of MEDIUM would be a lie, not a finding""" + findings = run([configured(severity=severity)], "MEDIUM") + + assert findings[0].status == "FAIL" + assert f"severity is {severity}, which is not one of" in ( + findings[0].status_extended + ) + assert f"should be at least {severity}" not in findings[0].status_extended + + def test_reports_the_minimum_severity_on_failure(self): + findings = run([configured(severity="LOW")], "MEDIUM") + + assert findings[0].status == "FAIL" + assert "severity is LOW (should be at least MEDIUM)" in ( + findings[0].status_extended + ) + + def test_an_unobserved_rule_left_on_an_active_default_is_manual(self): + """Google documents no default severity, so it cannot be verified""" + findings = run([configured(severity=None, from_default=True)]) + + assert findings[0].status == "MANUAL" + assert "was not returned by the API" in findings[0].status_extended + + def test_an_unobserved_rule_that_defaults_to_off_still_fails(self): + """The OFF default is documented, so it fails whatever the severity is""" + findings = run([configured(state="INACTIVE", severity=None, from_default=True)]) + + assert findings[0].status == "FAIL" + assert "Google's default for it is OFF" in findings[0].status_extended + + def test_fail_when_the_alert_is_not_sent_to_the_alert_center(self): + """An active rule whose alert center delivery is DISABLED is not compliant""" + findings = run([configured(alert_center_status="DISABLED")], "MEDIUM") + + assert findings[0].status == "FAIL" + assert "not sent to the alert center" in findings[0].status_extended + + def test_pass_when_the_alert_center_status_is_not_reported(self): + """The API never returns this field, so its absence cannot fail a rule""" + findings = run([configured(alert_center_status=None)], "MEDIUM") + + assert findings[0].status == "PASS" + + def test_a_failing_condition_wins_over_an_unreported_delivery(self): + findings = run([configured(severity="LOW", alert_center_status=None)], "MEDIUM") + + assert findings[0].status == "FAIL" + assert "severity is LOW" in findings[0].status_extended + + def test_reports_every_failing_condition(self): + findings = run( + [ + configured( + state="INACTIVE", + severity="LOW", + email_notifications_enabled=False, + ) + ] + ) + + extended = findings[0].status_extended + assert findings[0].status == "FAIL" + assert "alert is OFF" in extended + assert "email notifications are disabled" in extended + assert "severity is LOW" in extended diff --git a/tests/providers/googleworkspace/services/rules/rules_admin_privilege_granted_alert_configured/rules_admin_privilege_granted_alert_configured_test.py b/tests/providers/googleworkspace/services/rules/rules_admin_privilege_granted_alert_configured/rules_admin_privilege_granted_alert_configured_test.py index cf1a6128c4..c3c83fd019 100644 --- a/tests/providers/googleworkspace/services/rules/rules_admin_privilege_granted_alert_configured/rules_admin_privilege_granted_alert_configured_test.py +++ b/tests/providers/googleworkspace/services/rules/rules_admin_privilege_granted_alert_configured/rules_admin_privilege_granted_alert_configured_test.py @@ -46,7 +46,79 @@ class TestRulesAdminPrivilegeGrantedAlertConfigured: assert len(findings) == 1 assert findings[0].status == "PASS" - assert "properly configured" in findings[0].status_extended + assert "is properly configured" in findings[0].status_extended + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_severity_below_the_minimum(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_admin_privilege_granted_alert_configured.rules_admin_privilege_granted_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_admin_privilege_granted_alert_configured.rules_admin_privilege_granted_alert_configured import ( + rules_admin_privilege_granted_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="LOW", + email_notifications_enabled=True, + all_super_admins=True, + ) + ] + + check = rules_admin_privilege_granted_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "severity is LOW" in findings[0].status_extended + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_severity_not_configured(self): + """Test FAIL when the alert is on but no severity is configured.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_admin_privilege_granted_alert_configured.rules_admin_privilege_granted_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_admin_privilege_granted_alert_configured.rules_admin_privilege_granted_alert_configured import ( + rules_admin_privilege_granted_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + email_notifications_enabled=True, + all_super_admins=True, + ) + ] + + check = rules_admin_privilege_granted_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "severity is not configured" in findings[0].status_extended assert findings[0].customer_id == CUSTOMER_ID def test_fail_alert_off(self): diff --git a/tests/providers/googleworkspace/services/rules/rules_gmail_employee_spoofing_alert_configured/rules_gmail_employee_spoofing_alert_configured_test.py b/tests/providers/googleworkspace/services/rules/rules_gmail_employee_spoofing_alert_configured/rules_gmail_employee_spoofing_alert_configured_test.py index f907b2f89e..82ced6a8d1 100644 --- a/tests/providers/googleworkspace/services/rules/rules_gmail_employee_spoofing_alert_configured/rules_gmail_employee_spoofing_alert_configured_test.py +++ b/tests/providers/googleworkspace/services/rules/rules_gmail_employee_spoofing_alert_configured/rules_gmail_employee_spoofing_alert_configured_test.py @@ -46,7 +46,79 @@ class TestRulesGmailEmployeeSpoofingAlertConfigured: assert len(findings) == 1 assert findings[0].status == "PASS" - assert "properly configured" in findings[0].status_extended + assert "is properly configured" in findings[0].status_extended + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_severity_below_the_minimum(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_gmail_employee_spoofing_alert_configured.rules_gmail_employee_spoofing_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_gmail_employee_spoofing_alert_configured.rules_gmail_employee_spoofing_alert_configured import ( + rules_gmail_employee_spoofing_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="LOW", + email_notifications_enabled=True, + all_super_admins=True, + ) + ] + + check = rules_gmail_employee_spoofing_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "severity is LOW" in findings[0].status_extended + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_severity_not_configured(self): + """Test FAIL when the alert is on but no severity is configured.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_gmail_employee_spoofing_alert_configured.rules_gmail_employee_spoofing_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_gmail_employee_spoofing_alert_configured.rules_gmail_employee_spoofing_alert_configured import ( + rules_gmail_employee_spoofing_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + email_notifications_enabled=True, + all_super_admins=True, + ) + ] + + check = rules_gmail_employee_spoofing_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "severity is not configured" in findings[0].status_extended assert findings[0].customer_id == CUSTOMER_ID def test_fail_alert_off(self): diff --git a/tests/providers/googleworkspace/services/rules/rules_government_backed_attacks_alert_configured/rules_government_backed_attacks_alert_configured_test.py b/tests/providers/googleworkspace/services/rules/rules_government_backed_attacks_alert_configured/rules_government_backed_attacks_alert_configured_test.py index 90ec845258..0bc9fd4158 100644 --- a/tests/providers/googleworkspace/services/rules/rules_government_backed_attacks_alert_configured/rules_government_backed_attacks_alert_configured_test.py +++ b/tests/providers/googleworkspace/services/rules/rules_government_backed_attacks_alert_configured/rules_government_backed_attacks_alert_configured_test.py @@ -35,7 +35,7 @@ class TestRulesGovernmentBackedAttacksAlertConfigured: SystemDefinedAlert( display_name=RULE_NAME, state="ACTIVE", - severity="MEDIUM", + severity="HIGH", email_notifications_enabled=True, all_super_admins=True, ) @@ -46,7 +46,79 @@ class TestRulesGovernmentBackedAttacksAlertConfigured: assert len(findings) == 1 assert findings[0].status == "PASS" - assert "properly configured" in findings[0].status_extended + assert "is properly configured" in findings[0].status_extended + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_severity_below_the_minimum(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_government_backed_attacks_alert_configured.rules_government_backed_attacks_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_government_backed_attacks_alert_configured.rules_government_backed_attacks_alert_configured import ( + rules_government_backed_attacks_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="MEDIUM", + email_notifications_enabled=True, + all_super_admins=True, + ) + ] + + check = rules_government_backed_attacks_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "severity is MEDIUM" in findings[0].status_extended + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_severity_not_configured(self): + """Test FAIL when the alert is on but no severity is configured.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_government_backed_attacks_alert_configured.rules_government_backed_attacks_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_government_backed_attacks_alert_configured.rules_government_backed_attacks_alert_configured import ( + rules_government_backed_attacks_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + email_notifications_enabled=True, + all_super_admins=True, + ) + ] + + check = rules_government_backed_attacks_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "severity is not configured" in findings[0].status_extended assert findings[0].customer_id == CUSTOMER_ID def test_fail_alert_off(self): @@ -105,7 +177,7 @@ class TestRulesGovernmentBackedAttacksAlertConfigured: SystemDefinedAlert( display_name=RULE_NAME, state="ACTIVE", - severity="MEDIUM", + severity="HIGH", email_notifications_enabled=False, all_super_admins=False, ) @@ -141,7 +213,7 @@ class TestRulesGovernmentBackedAttacksAlertConfigured: SystemDefinedAlert( display_name=RULE_NAME, state="ACTIVE", - severity="MEDIUM", + severity="HIGH", email_notifications_enabled=True, all_super_admins=False, ) diff --git a/tests/providers/googleworkspace/services/rules/rules_leaked_password_alert_configured/rules_leaked_password_alert_configured_test.py b/tests/providers/googleworkspace/services/rules/rules_leaked_password_alert_configured/rules_leaked_password_alert_configured_test.py index ace6c29d13..450e825309 100644 --- a/tests/providers/googleworkspace/services/rules/rules_leaked_password_alert_configured/rules_leaked_password_alert_configured_test.py +++ b/tests/providers/googleworkspace/services/rules/rules_leaked_password_alert_configured/rules_leaked_password_alert_configured_test.py @@ -46,7 +46,116 @@ class TestRulesLeakedPasswordAlertConfigured: assert len(findings) == 1 assert findings[0].status == "PASS" - assert "properly configured" in findings[0].status_extended + assert "is properly configured" in findings[0].status_extended + assert findings[0].customer_id == CUSTOMER_ID + + def test_pass_high_severity(self): + """Test PASS with High severity: CIS 6.7 sets High in the remediation and Medium in the audit.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_leaked_password_alert_configured.rules_leaked_password_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_leaked_password_alert_configured.rules_leaked_password_alert_configured import ( + rules_leaked_password_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="HIGH", + email_notifications_enabled=True, + all_super_admins=True, + ) + ] + + check = rules_leaked_password_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "is properly configured" in findings[0].status_extended + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_severity_below_the_minimum(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_leaked_password_alert_configured.rules_leaked_password_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_leaked_password_alert_configured.rules_leaked_password_alert_configured import ( + rules_leaked_password_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="LOW", + email_notifications_enabled=True, + all_super_admins=True, + ) + ] + + check = rules_leaked_password_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "severity is LOW" in findings[0].status_extended + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_severity_not_configured(self): + """Test FAIL when the alert is on but no severity is configured.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_leaked_password_alert_configured.rules_leaked_password_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_leaked_password_alert_configured.rules_leaked_password_alert_configured import ( + rules_leaked_password_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + email_notifications_enabled=True, + all_super_admins=True, + ) + ] + + check = rules_leaked_password_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "severity is not configured" in findings[0].status_extended assert findings[0].customer_id == CUSTOMER_ID def test_fail_alert_off(self): diff --git a/tests/providers/googleworkspace/services/rules/rules_password_changed_alert_configured/rules_password_changed_alert_configured_test.py b/tests/providers/googleworkspace/services/rules/rules_password_changed_alert_configured/rules_password_changed_alert_configured_test.py index db1b8056e7..9e2bc07c20 100644 --- a/tests/providers/googleworkspace/services/rules/rules_password_changed_alert_configured/rules_password_changed_alert_configured_test.py +++ b/tests/providers/googleworkspace/services/rules/rules_password_changed_alert_configured/rules_password_changed_alert_configured_test.py @@ -46,7 +46,79 @@ class TestRulesPasswordChangedAlertConfigured: assert len(findings) == 1 assert findings[0].status == "PASS" - assert "properly configured" in findings[0].status_extended + assert "is properly configured" in findings[0].status_extended + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_severity_below_the_minimum(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_password_changed_alert_configured.rules_password_changed_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_password_changed_alert_configured.rules_password_changed_alert_configured import ( + rules_password_changed_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="LOW", + email_notifications_enabled=True, + all_super_admins=True, + ) + ] + + check = rules_password_changed_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "severity is LOW" in findings[0].status_extended + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_severity_not_configured(self): + """Test FAIL when the alert is on but no severity is configured.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_password_changed_alert_configured.rules_password_changed_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_password_changed_alert_configured.rules_password_changed_alert_configured import ( + rules_password_changed_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + email_notifications_enabled=True, + all_super_admins=True, + ) + ] + + check = rules_password_changed_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "severity is not configured" in findings[0].status_extended assert findings[0].customer_id == CUSTOMER_ID def test_fail_alert_off(self): diff --git a/tests/providers/googleworkspace/services/rules/rules_service_test.py b/tests/providers/googleworkspace/services/rules/rules_service_test.py index 6368df4bcb..0d0fd39ed4 100644 --- a/tests/providers/googleworkspace/services/rules/rules_service_test.py +++ b/tests/providers/googleworkspace/services/rules/rules_service_test.py @@ -1,9 +1,19 @@ +from pathlib import Path from unittest.mock import MagicMock, patch +from prowler.lib.check.models import CheckMetadata +from prowler.providers.googleworkspace.services.rules import rules_service from tests.providers.googleworkspace.googleworkspace_fixtures import ( set_mocked_googleworkspace_provider, ) +METADATA_FILE = ( + Path(rules_service.__file__).parent + / "rules_government_backed_attacks_alert_configured" + / "rules_government_backed_attacks_alert_configured.metadata.json" +) +METADATA = CheckMetadata.parse_file(METADATA_FILE).json() + class TestRulesService: def test_fetch_fully_configured_rule(self): @@ -28,7 +38,10 @@ class TestRulesService: "action": { "alertCenterAction": { "recipients": [{"allSuperAdmins": True}], - "alertCenterConfig": {"severity": "LOW"}, + "alertCenterConfig": { + "severity": "LOW", + "status": "ENABLED", + }, } }, "state": "ACTIVE", @@ -68,6 +81,7 @@ class TestRulesService: assert suspicious_login.email_notifications_enabled is True assert suspicious_login.all_super_admins is True assert suspicious_login.severity == "LOW" + assert suspicious_login.alert_center_status == "ENABLED" def test_fetch_rule_without_email_notifications(self): """Test a rule that is ACTIVE but has no email recipients configured.""" @@ -204,6 +218,114 @@ class TestRulesService: assert gov_attacks.email_notifications_enabled is True assert gov_attacks.all_super_admins is True + def test_ou_and_group_scoped_policies_are_skipped(self): + """Only the customer-level policy describes the whole domain""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + def alert_policy(display_name, state, policy_query=None): + policy = { + "setting": { + "type": "settings/rule.system_defined_alerts", + "value": {"displayName": display_name, "state": state}, + } + } + if policy_query: + policy["policyQuery"] = policy_query + return policy + + mock_service = MagicMock() + mock_policies_list = MagicMock() + mock_policies_list.execute.return_value = { + "policies": [ + alert_policy("Suspicious login", "ACTIVE"), + alert_policy( + "Suspicious login", "INACTIVE", {"orgUnit": "orgUnits/sales_team"} + ), + alert_policy( + "Leaked password", "INACTIVE", {"group": "groups/contractors"} + ), + ] + } + mock_service.policies().list.return_value = mock_policies_list + mock_service.policies().list_next.return_value = None + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.rules.rules_service import ( + Rules, + ) + + rules = Rules(mock_provider) + + by_name = {a.display_name: a for a in rules.system_defined_alerts} + assert by_name["Suspicious login"].state == "ACTIVE" + assert by_name["Suspicious login"].from_default is False + # Only seen in a group-scoped policy, so it falls back to the default. + assert by_name["Leaked password"].from_default is True + + def test_empty_response_marks_alerts_as_inferred(self): + """A rule the API never returned must not be reported as tenant configuration.""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_policies_list = MagicMock() + mock_policies_list.execute.return_value = {"policies": []} + mock_service.policies().list.return_value = mock_policies_list + mock_service.policies().list_next.return_value = None + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.rules.lib.alerts import ( + evaluate_system_defined_alert, + ) + from prowler.providers.googleworkspace.services.rules.rules_service import ( + Rules, + ) + + rules = Rules(mock_provider) + + assert all(alert.from_default for alert in rules.system_defined_alerts) + + # End to end: nothing was observed for a rule that defaults to ON, + # so it has to be reviewed by hand instead of blamed on the tenant. + client = MagicMock() + client.provider = mock_provider + client.policies_fetched = True + client.system_defined_alerts = rules.system_defined_alerts + findings = evaluate_system_defined_alert( + client, METADATA, "Government-backed attacks", "HIGH" + ) + + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "was not returned by the API" in findings[0].status_extended + def test_api_error_sets_policies_fetched_false(self): """Test that API errors result in policies_fetched being False.""" mock_provider = set_mocked_googleworkspace_provider() diff --git a/tests/providers/googleworkspace/services/rules/rules_suspicious_activity_suspension_alert_configured/rules_suspicious_activity_suspension_alert_configured_test.py b/tests/providers/googleworkspace/services/rules/rules_suspicious_activity_suspension_alert_configured/rules_suspicious_activity_suspension_alert_configured_test.py index acd60756f0..58b8a273f1 100644 --- a/tests/providers/googleworkspace/services/rules/rules_suspicious_activity_suspension_alert_configured/rules_suspicious_activity_suspension_alert_configured_test.py +++ b/tests/providers/googleworkspace/services/rules/rules_suspicious_activity_suspension_alert_configured/rules_suspicious_activity_suspension_alert_configured_test.py @@ -35,7 +35,7 @@ class TestRulesSuspiciousActivitySuspensionAlertConfigured: SystemDefinedAlert( display_name=RULE_NAME, state="ACTIVE", - severity="MEDIUM", + severity="HIGH", email_notifications_enabled=True, all_super_admins=True, ) @@ -46,7 +46,79 @@ class TestRulesSuspiciousActivitySuspensionAlertConfigured: assert len(findings) == 1 assert findings[0].status == "PASS" - assert "properly configured" in findings[0].status_extended + assert "is properly configured" in findings[0].status_extended + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_severity_below_the_minimum(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_suspicious_activity_suspension_alert_configured.rules_suspicious_activity_suspension_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_suspicious_activity_suspension_alert_configured.rules_suspicious_activity_suspension_alert_configured import ( + rules_suspicious_activity_suspension_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="LOW", + email_notifications_enabled=True, + all_super_admins=True, + ) + ] + + check = rules_suspicious_activity_suspension_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "severity is LOW" in findings[0].status_extended + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_severity_not_configured(self): + """Test FAIL when the alert is on but no severity is configured.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_suspicious_activity_suspension_alert_configured.rules_suspicious_activity_suspension_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_suspicious_activity_suspension_alert_configured.rules_suspicious_activity_suspension_alert_configured import ( + rules_suspicious_activity_suspension_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + email_notifications_enabled=True, + all_super_admins=True, + ) + ] + + check = rules_suspicious_activity_suspension_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "severity is not configured" in findings[0].status_extended assert findings[0].customer_id == CUSTOMER_ID def test_fail_alert_off(self): @@ -105,7 +177,7 @@ class TestRulesSuspiciousActivitySuspensionAlertConfigured: SystemDefinedAlert( display_name=RULE_NAME, state="ACTIVE", - severity="MEDIUM", + severity="HIGH", email_notifications_enabled=False, all_super_admins=False, ) @@ -141,7 +213,7 @@ class TestRulesSuspiciousActivitySuspensionAlertConfigured: SystemDefinedAlert( display_name=RULE_NAME, state="ACTIVE", - severity="MEDIUM", + severity="HIGH", email_notifications_enabled=True, all_super_admins=False, ) diff --git a/tests/providers/googleworkspace/services/rules/rules_suspicious_login_alert_configured/rules_suspicious_login_alert_configured_test.py b/tests/providers/googleworkspace/services/rules/rules_suspicious_login_alert_configured/rules_suspicious_login_alert_configured_test.py index aab70797dc..1c6f568421 100644 --- a/tests/providers/googleworkspace/services/rules/rules_suspicious_login_alert_configured/rules_suspicious_login_alert_configured_test.py +++ b/tests/providers/googleworkspace/services/rules/rules_suspicious_login_alert_configured/rules_suspicious_login_alert_configured_test.py @@ -16,6 +16,42 @@ class TestRulesSuspiciousLoginAlertConfigured: """Test PASS when alert is ON, email notifications ON, recipients = all super admins.""" mock_provider = set_mocked_googleworkspace_provider() + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_suspicious_login_alert_configured.rules_suspicious_login_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_suspicious_login_alert_configured.rules_suspicious_login_alert_configured import ( + rules_suspicious_login_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="LOW", + email_notifications_enabled=True, + all_super_admins=True, + ) + ] + + check = rules_suspicious_login_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "is properly configured" in findings[0].status_extended + assert findings[0].customer_id == CUSTOMER_ID + + def test_pass_severity_above_the_minimum(self): + mock_provider = set_mocked_googleworkspace_provider() + with ( patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -46,7 +82,43 @@ class TestRulesSuspiciousLoginAlertConfigured: assert len(findings) == 1 assert findings[0].status == "PASS" - assert "properly configured" in findings[0].status_extended + assert "is properly configured" in findings[0].status_extended + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_severity_not_configured(self): + """Test FAIL when the alert is on but no severity is configured.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_suspicious_login_alert_configured.rules_suspicious_login_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_suspicious_login_alert_configured.rules_suspicious_login_alert_configured import ( + rules_suspicious_login_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + email_notifications_enabled=True, + all_super_admins=True, + ) + ] + + check = rules_suspicious_login_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "severity is not configured" in findings[0].status_extended assert findings[0].customer_id == CUSTOMER_ID def test_fail_alert_off(self): @@ -105,7 +177,7 @@ class TestRulesSuspiciousLoginAlertConfigured: SystemDefinedAlert( display_name=RULE_NAME, state="ACTIVE", - severity="MEDIUM", + severity="LOW", email_notifications_enabled=False, all_super_admins=False, ) @@ -141,7 +213,7 @@ class TestRulesSuspiciousLoginAlertConfigured: SystemDefinedAlert( display_name=RULE_NAME, state="ACTIVE", - severity="MEDIUM", + severity="LOW", email_notifications_enabled=True, all_super_admins=False, ) diff --git a/tests/providers/googleworkspace/services/rules/rules_suspicious_programmatic_login_alert_configured/rules_suspicious_programmatic_login_alert_configured_test.py b/tests/providers/googleworkspace/services/rules/rules_suspicious_programmatic_login_alert_configured/rules_suspicious_programmatic_login_alert_configured_test.py index f1596ace67..35c51dbd59 100644 --- a/tests/providers/googleworkspace/services/rules/rules_suspicious_programmatic_login_alert_configured/rules_suspicious_programmatic_login_alert_configured_test.py +++ b/tests/providers/googleworkspace/services/rules/rules_suspicious_programmatic_login_alert_configured/rules_suspicious_programmatic_login_alert_configured_test.py @@ -35,7 +35,7 @@ class TestRulesSuspiciousProgrammaticLoginAlertConfigured: SystemDefinedAlert( display_name=RULE_NAME, state="ACTIVE", - severity="MEDIUM", + severity="LOW", email_notifications_enabled=True, all_super_admins=True, ) @@ -46,7 +46,79 @@ class TestRulesSuspiciousProgrammaticLoginAlertConfigured: assert len(findings) == 1 assert findings[0].status == "PASS" - assert "properly configured" in findings[0].status_extended + assert "is properly configured" in findings[0].status_extended + assert findings[0].customer_id == CUSTOMER_ID + + def test_pass_severity_above_the_minimum(self): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_suspicious_programmatic_login_alert_configured.rules_suspicious_programmatic_login_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_suspicious_programmatic_login_alert_configured.rules_suspicious_programmatic_login_alert_configured import ( + rules_suspicious_programmatic_login_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + severity="HIGH", + email_notifications_enabled=True, + all_super_admins=True, + ) + ] + + check = rules_suspicious_programmatic_login_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "is properly configured" in findings[0].status_extended + assert findings[0].customer_id == CUSTOMER_ID + + def test_fail_severity_not_configured(self): + """Test FAIL when the alert is on but no severity is configured.""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.rules.rules_suspicious_programmatic_login_alert_configured.rules_suspicious_programmatic_login_alert_configured.rules_client" + ) as mock_rules_client, + ): + from prowler.providers.googleworkspace.services.rules.rules_suspicious_programmatic_login_alert_configured.rules_suspicious_programmatic_login_alert_configured import ( + rules_suspicious_programmatic_login_alert_configured, + ) + + mock_rules_client.provider = mock_provider + mock_rules_client.policies_fetched = True + mock_rules_client.system_defined_alerts = [ + SystemDefinedAlert( + display_name=RULE_NAME, + state="ACTIVE", + email_notifications_enabled=True, + all_super_admins=True, + ) + ] + + check = rules_suspicious_programmatic_login_alert_configured() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "severity is not configured" in findings[0].status_extended assert findings[0].customer_id == CUSTOMER_ID def test_fail_alert_off(self): @@ -105,7 +177,7 @@ class TestRulesSuspiciousProgrammaticLoginAlertConfigured: SystemDefinedAlert( display_name=RULE_NAME, state="ACTIVE", - severity="MEDIUM", + severity="LOW", email_notifications_enabled=False, all_super_admins=False, ) @@ -141,7 +213,7 @@ class TestRulesSuspiciousProgrammaticLoginAlertConfigured: SystemDefinedAlert( display_name=RULE_NAME, state="ACTIVE", - severity="MEDIUM", + severity="LOW", email_notifications_enabled=True, all_super_admins=False, ) diff --git a/tests/providers/googleworkspace/services/security/googleworkspace_security_service_test.py b/tests/providers/googleworkspace/services/security/googleworkspace_security_service_test.py index 13335e22cb..e17614de0d 100644 --- a/tests/providers/googleworkspace/services/security/googleworkspace_security_service_test.py +++ b/tests/providers/googleworkspace/services/security/googleworkspace_security_service_test.py @@ -1,6 +1,9 @@ from unittest.mock import MagicMock, patch +import pytest + from tests.providers.googleworkspace.googleworkspace_fixtures import ( + ROOT_ORG_UNIT_ID, set_mocked_googleworkspace_provider, ) @@ -280,6 +283,141 @@ class TestSecurityService: assert security.policies.trust_internal_apps is None assert security.policies.dlp_drive_rules_exist is None + def test_group_and_sub_ou_policies_are_recorded_as_overrides(self): + """The customer-level value is not what the overridden users get""" + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_empty = MagicMock() + mock_empty.execute.return_value = { + "policies": [ + { + "policyQuery": {"group": "groups/abc123"}, + "setting": { + "type": "settings/security.two_step_verification_enforcement", + "value": {"enforcedFrom": "1970-01-01T00:00:00Z"}, + }, + }, + { + "policyQuery": {"orgUnit": "orgUnits/03ph8a2z1xdnme9"}, + "setting": { + "type": "settings/security.two_step_verification_enforcement_factor", + "value": {"allowedSignInFactorSet": "ALL"}, + }, + }, + { + "setting": { + "type": "settings/security.two_step_verification_enforcement", + "value": {"enforcedFrom": "2026-05-25T15:27:52.352Z"}, + } + }, + ] + } + mock_service.policies().list.side_effect = [ + mock_empty, + mock_empty, + mock_empty, + ] + mock_service.policies().list_next.return_value = None + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.security.security_service import ( + Security, + ) + + security = Security(mock_provider) + + assert security.policies.overridden_settings == [ + "security.two_step_verification_enforcement", + "security.two_step_verification_enforcement_factor", + ] + assert security.policies.unresolved_scope is False + # The customer-level policy is still read, it is just not effective + # for everyone. + assert security.policies.two_sv_enforced_from == "2026-05-25T15:27:52.352Z" + + @pytest.mark.parametrize( + "root_org_unit_id, org_unit, expected_enforced_from, expected_unresolved", + [ + # The root OU is the whole domain: read it, do not call it an override. + ( + ROOT_ORG_UNIT_ID, + f"orgUnits/{ROOT_ORG_UNIT_ID}", + "2026-05-25T15:27:52.352Z", + False, + ), + # Without the root id a sub-OU cannot be told apart from the root, so + # the value is dropped and the scope is flagged as unresolved. + (None, "orgUnits/03ph8a2z1xdnme9", None, True), + ], + ) + def test_an_org_unit_is_never_reported_as_an_override( + self, root_org_unit_id, org_unit, expected_enforced_from, expected_unresolved + ): + mock_provider = set_mocked_googleworkspace_provider() + mock_provider.audit_config = {} + mock_provider.fixer_config = {} + mock_provider.identity = mock_provider.identity.copy( + update={"root_org_unit_id": root_org_unit_id} + ) + mock_session = MagicMock() + mock_session.credentials = MagicMock() + mock_provider.session = mock_session + + mock_service = MagicMock() + mock_policies = MagicMock() + mock_policies.execute.return_value = { + "policies": [ + { + "policyQuery": {"orgUnit": org_unit}, + "setting": { + "type": "settings/security.two_step_verification_enforcement", + "value": {"enforcedFrom": "2026-05-25T15:27:52.352Z"}, + }, + } + ] + } + mock_service.policies().list.side_effect = [ + mock_policies, + mock_policies, + mock_policies, + ] + mock_service.policies().list_next.return_value = None + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_service.GoogleWorkspaceService._build_service", + return_value=mock_service, + ), + ): + from prowler.providers.googleworkspace.services.security.security_service import ( + Security, + ) + + security = Security(mock_provider) + + assert security.policies.overridden_settings == [] + assert security.policies.two_sv_enforced_from == expected_enforced_from + assert security.policies.unresolved_scope is expected_unresolved + def test_fetch_policies_api_error(self): """Test handling of API errors during policy fetch""" mock_provider = set_mocked_googleworkspace_provider() diff --git a/tests/providers/googleworkspace/services/security/lib/durations_test.py b/tests/providers/googleworkspace/services/security/lib/durations_test.py new file mode 100644 index 0000000000..7e1a6d3038 --- /dev/null +++ b/tests/providers/googleworkspace/services/security/lib/durations_test.py @@ -0,0 +1,97 @@ +from datetime import datetime, timezone + +import pytest + +from prowler.providers.googleworkspace.services.security.lib.durations import ( + ONE_DAY_SECONDS, + ONE_YEAR_SECONDS, + TWO_WEEKS_SECONDS, + enforcement_issue, + format_duration, + parse_duration_seconds, +) + + +class TestParseDurationSeconds: + @pytest.mark.parametrize( + "value, expected", + [ + ("0s", 0), + ("86400s", ONE_DAY_SECONDS), + ("1209600s", TWO_WEEKS_SECONDS), + ("31536000s", ONE_YEAR_SECONDS), + ("86400.9s", ONE_DAY_SECONDS), + (" 86400s ", ONE_DAY_SECONDS), + ], + ) + def test_parses_protobuf_durations(self, value, expected): + assert parse_duration_seconds(value) == expected + + @pytest.mark.parametrize( + "value", + [None, "", "86400", "28d", "P30D", "-3600s", "1e5s", "abc", 86400], + ) + def test_returns_none_for_anything_it_cannot_read(self, value): + """Callers must be able to tell a real length from an unreadable value""" + assert parse_duration_seconds(value) is None + + +class TestFormatDuration: + @pytest.mark.parametrize( + "value, expected", + [ + ("0s", "none"), + ("86400s", "1 day(s)"), + ("1209600s", "14 day(s)"), + ("31536000s", "365 day(s)"), + ("3600s", "1 hour(s)"), + ("129600s", "36 hour(s)"), + ("5400s", "5400 second(s)"), + (None, "not configured"), + ("28d", "not configured"), + ], + ) + def test_renders_for_finding_messages(self, value, expected): + assert format_duration(value) == expected + + +class TestEnforcementIssue: + NOW = datetime(2026, 8, 25, 12, 0, 0, tzinfo=timezone.utc) + + @pytest.mark.parametrize( + "value", ["2026-05-25T15:27:52.352Z", "2026-08-25T11:59:59Z"] + ) + def test_no_issue_once_enforcement_has_started(self, value): + assert enforcement_issue(value, now=self.NOW) is None + + def test_naive_timestamp_is_read_as_utc(self): + assert enforcement_issue("2026-01-01T00:00:00", now=self.NOW) is None + + @pytest.mark.parametrize("value", [None, ""]) + def test_missing_value_defaults_to_off(self, value): + assert ( + enforcement_issue(value, now=self.NOW) + == "enforcement is not configured and defaults to OFF" + ) + + @pytest.mark.parametrize( + "value", ["1970-01-01T00:00:00Z", "1970-01-01T00:00:00.000000000Z"] + ) + def test_zero_value_timestamp_is_off(self, value): + """The API reports OFF as the protobuf zero-value Timestamp""" + assert enforcement_issue(value, now=self.NOW) == "enforcement is set to OFF" + + @pytest.mark.parametrize( + "value", ["2099-01-01T00:00:00Z", "2026-12-31T23:59:59+00:00"] + ) + def test_future_start_date_means_nobody_is_enforced_yet(self, value): + assert ( + enforcement_issue(value, now=self.NOW) + == f"enforcement does not start until {value}" + ) + + def test_unreadable_timestamp_is_reported_instead_of_assumed_active(self): + assert ( + enforcement_issue("not-a-date", now=self.NOW) + == "the enforcement start date 'not-a-date' could not be read" + ) diff --git a/tests/providers/googleworkspace/services/security/security_2sv_enforced/security_2sv_enforced_test.py b/tests/providers/googleworkspace/services/security/security_2sv_enforced/security_2sv_enforced_test.py index a6d6a549a9..0c317a1be2 100644 --- a/tests/providers/googleworkspace/services/security/security_2sv_enforced/security_2sv_enforced_test.py +++ b/tests/providers/googleworkspace/services/security/security_2sv_enforced/security_2sv_enforced_test.py @@ -1,5 +1,7 @@ from unittest.mock import patch +import pytest + from prowler.providers.googleworkspace.services.security.security_service import ( SecurityPolicies, ) @@ -8,149 +10,262 @@ from tests.providers.googleworkspace.googleworkspace_fixtures import ( set_mocked_googleworkspace_provider, ) +CHECK_CLIENT = ( + "prowler.providers.googleworkspace.services.security." + "security_2sv_enforced.security_2sv_enforced.security_client" +) + +# A domain that satisfies every step of the CIS audit procedure. +COMPLIANT = dict( + two_sv_enforced_from="2026-05-25T15:27:52.352Z", + two_sv_allow_enrollment=True, + two_sv_enrollment_grace_period="1209600s", + two_sv_allow_trusting_device=False, + # Security keys exclude verification codes via text and phone call. + two_sv_allowed_factor_set="PASSKEY_ONLY", +) + + +def run_check(policies_fetched=True, **overrides): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch(CHECK_CLIENT) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_2sv_enforced.security_2sv_enforced import ( + security_2sv_enforced, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = policies_fetched + mock_client.policies = SecurityPolicies(**overrides) + + return security_2sv_enforced().execute() + class TestSecurity2svEnforced: - def test_pass_2sv_enforced(self): - """Test PASS when 2-Step Verification enforcement is active""" - mock_provider = set_mocked_googleworkspace_provider() + def test_pass_full_audit_procedure_met(self): + """PASS when every step of the CIS audit procedure is satisfied""" + findings = run_check(**COMPLIANT) - with ( - patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=mock_provider, + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "is enforced" in findings[0].status_extended + assert findings[0].resource_name == "Security Policies" + assert findings[0].resource_id == "securityPolicies" + assert findings[0].customer_id == CUSTOMER_ID + + @pytest.mark.parametrize( + "factor_set", ["NO_TELEPHONY", "PASSKEY_ONLY", "PASSKEY_PLUS_SECURITY_CODE"] + ) + def test_pass_any_method_that_excludes_telephony(self, factor_set): + """CIS asks for any method except verification codes via text or phone call""" + findings = run_check(**{**COMPLIANT, "two_sv_allowed_factor_set": factor_set}) + + assert len(findings) == 1 + assert findings[0].status == "PASS" + + def test_pass_no_enrollment_period(self): + """PASS when there is no enrollment period, which is stricter than 2 weeks""" + findings = run_check(**{**COMPLIANT, "two_sv_enrollment_grace_period": "0s"}) + + assert len(findings) == 1 + assert findings[0].status == "PASS" + + @pytest.mark.parametrize( + "overrides, expected", + [ + ({"two_sv_enforced_from": None}, "not configured"), + ({"two_sv_enforced_from": ""}, "not configured"), + ({"two_sv_enforced_from": "1970-01-01T00:00:00Z"}, "OFF"), + ({"two_sv_allow_enrollment": False}, "not allowed to turn on"), + ( + {"two_sv_enrollment_grace_period": "2592000s"}, + "enrollment period is 30 day(s)", ), - patch( - "prowler.providers.googleworkspace.services.security.security_2sv_enforced.security_2sv_enforced.security_client" - ) as mock_client, - ): - from prowler.providers.googleworkspace.services.security.security_2sv_enforced.security_2sv_enforced import ( - security_2sv_enforced, - ) - - mock_client.provider = mock_provider - mock_client.policies_fetched = True - mock_client.policies = SecurityPolicies( - two_sv_enforced_from="2026-05-25T15:27:52.352Z" - ) - - check = security_2sv_enforced() - findings = check.execute() - - assert len(findings) == 1 - assert findings[0].status == "PASS" - assert "active" in findings[0].status_extended - assert findings[0].resource_name == "Security Policies" - assert findings[0].resource_id == "securityPolicies" - assert findings[0].customer_id == CUSTOMER_ID - - def test_fail_none_not_configured(self): - """Test FAIL when 2-Step Verification enforcement is not configured (None)""" - mock_provider = set_mocked_googleworkspace_provider() - - with ( - patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=mock_provider, + ({"two_sv_allow_trusting_device": True}, "trust their device"), + ( + {"two_sv_allow_trusting_device": None}, + "device trust is not configured", ), - patch( - "prowler.providers.googleworkspace.services.security.security_2sv_enforced.security_2sv_enforced.security_client" - ) as mock_client, - ): - from prowler.providers.googleworkspace.services.security.security_2sv_enforced.security_2sv_enforced import ( - security_2sv_enforced, - ) - - mock_client.provider = mock_provider - mock_client.policies_fetched = True - mock_client.policies = SecurityPolicies(two_sv_enforced_from=None) - - check = security_2sv_enforced() - findings = check.execute() - - assert len(findings) == 1 - assert findings[0].status == "FAIL" - assert "not configured" in findings[0].status_extended - - def test_fail_empty_off(self): - """Test FAIL when 2-Step Verification enforcement is set to OFF (empty string)""" - mock_provider = set_mocked_googleworkspace_provider() - - with ( - patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=mock_provider, + ( + {"two_sv_enforced_from": "2099-01-01T00:00:00Z"}, + "enforcement does not start until 2099-01-01T00:00:00Z", ), - patch( - "prowler.providers.googleworkspace.services.security.security_2sv_enforced.security_2sv_enforced.security_client" - ) as mock_client, - ): - from prowler.providers.googleworkspace.services.security.security_2sv_enforced.security_2sv_enforced import ( - security_2sv_enforced, - ) - - mock_client.provider = mock_provider - mock_client.policies_fetched = True - mock_client.policies = SecurityPolicies(two_sv_enforced_from="") - - check = security_2sv_enforced() - findings = check.execute() - - assert len(findings) == 1 - assert findings[0].status == "FAIL" - assert "OFF" in findings[0].status_extended - - def test_fail_epoch_enforcement_off(self): - """Test FAIL when API returns epoch zero timestamp (enforcement OFF)""" - mock_provider = set_mocked_googleworkspace_provider() - - with ( - patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=mock_provider, + ( + {"two_sv_enforced_from": "not-a-date"}, + "enforcement start date 'not-a-date' could not be read", ), - patch( - "prowler.providers.googleworkspace.services.security.security_2sv_enforced.security_2sv_enforced.security_client" - ) as mock_client, - ): - from prowler.providers.googleworkspace.services.security.security_2sv_enforced.security_2sv_enforced import ( - security_2sv_enforced, - ) + ( + {"two_sv_enrollment_grace_period": "28d"}, + "enrollment period '28d' could not be read", + ), + ( + {"two_sv_allowed_factor_set": "ALL"}, + "the allowed methods are ALL", + ), + ( + {"two_sv_allowed_factor_set": "SOME_FUTURE_ENUM"}, + "the allowed methods are SOME_FUTURE_ENUM", + ), + ( + {"two_sv_allowed_factor_set": None}, + "allowed methods are not configured", + ), + ], + ) + def test_fail_each_audit_step(self, overrides, expected): + """FAIL when any single step of the CIS audit procedure is not met""" + findings = run_check(**{**COMPLIANT, **overrides}) - mock_client.provider = mock_provider - mock_client.policies_fetched = True - mock_client.policies = SecurityPolicies( - two_sv_enforced_from="1970-01-01T00:00:00Z" - ) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert expected in findings[0].status_extended - check = security_2sv_enforced() - findings = check.execute() + def test_fail_reports_every_issue(self): + """A domain left on Google's defaults reports all the failing steps""" + findings = run_check(two_sv_enforced_from=None) - assert len(findings) == 1 - assert findings[0].status == "FAIL" - assert "OFF" in findings[0].status_extended + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "enforcement is not configured" in findings[0].status_extended + assert "device trust is not configured" in findings[0].status_extended + assert "allowed methods are not configured" in findings[0].status_extended + + def test_manual_when_a_group_or_sub_ou_overrides_a_passing_policy(self): + """A passing domain-wide policy cannot be confirmed for the overridden users""" + findings = run_check( + **{ + **COMPLIANT, + "overridden_settings": {"security.two_step_verification_enforcement"}, + } + ) + + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "security.two_step_verification_enforcement is also overridden" in ( + findings[0].status_extended + ) + + def test_a_domain_wide_failure_is_reported_even_with_an_override(self): + """Whoever no override reaches still gets the failing domain-wide policy""" + findings = run_check( + **{ + **COMPLIANT, + "two_sv_enforced_from": None, + "overridden_settings": { + "security.two_step_verification_enforcement_factor" + }, + } + ) + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "enforcement is not configured" in findings[0].status_extended + + def test_manual_when_several_settings_are_overridden(self): + findings = run_check( + **{ + **COMPLIANT, + "overridden_settings": [ + "security.two_step_verification_device_trust", + "security.two_step_verification_enforcement", + ], + } + ) + + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert ( + "security.two_step_verification_device_trust, " + "security.two_step_verification_enforcement are also overridden" + ) in findings[0].status_extended + + def test_manual_when_a_setting_only_exists_below_the_domain(self): + """No domain-wide value was reported, so the defaults would fabricate issues""" + findings = run_check( + **{ + **COMPLIANT, + "two_sv_enforced_from": None, + "overridden_settings": ["security.two_step_verification_enforcement"], + "unobserved_settings": ["security.two_step_verification_enforcement"], + } + ) + + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "no domain-wide value was reported" in findings[0].status_extended + + def test_manual_when_the_policy_scope_could_not_be_resolved(self): + """Every value was dropped, so none of them can be judged""" + findings = run_check(**{**COMPLIANT, "unresolved_scope": True}) + + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "root organizational unit could not be resolved" in ( + findings[0].status_extended + ) + + def test_a_failure_keeps_the_override_caveat(self): + findings = run_check( + **{ + **COMPLIANT, + "two_sv_allow_trusting_device": True, + "overridden_settings": ["security.two_step_verification_enforcement"], + } + ) + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "trust their device" in findings[0].status_extended + assert "also overridden" in findings[0].status_extended + + def test_fail_when_every_failing_setting_is_overridden(self): + """The all-users requirement still fails for users outside the override""" + findings = run_check( + **{ + **COMPLIANT, + "two_sv_allow_trusting_device": True, + "overridden_settings": ["security.two_step_verification_device_trust"], + } + ) + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "trust their device" in findings[0].status_extended + assert "also overridden" in findings[0].status_extended + + def test_fail_when_only_some_failing_settings_are_overridden(self): + """A failure no override reaches is still proven for the whole domain""" + findings = run_check( + **{ + **COMPLIANT, + "two_sv_allow_trusting_device": True, + "two_sv_allowed_factor_set": "ALL", + "overridden_settings": ["security.two_step_verification_device_trust"], + } + ) + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "the allowed methods are ALL" in findings[0].status_extended + + def test_an_override_on_a_setting_this_check_ignores_does_not_apply(self): + """The sign-in code setting belongs to 4.1.1.2, not to 4.1.1.1 or 4.1.1.3""" + findings = run_check( + **{ + **COMPLIANT, + "overridden_settings": {"security.two_step_verification_sign_in_code"}, + } + ) + + assert len(findings) == 1 + assert findings[0].status == "PASS" def test_no_findings_when_fetch_failed(self): - """Test no findings returned when the API fetch failed""" - mock_provider = set_mocked_googleworkspace_provider() - - with ( - patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=mock_provider, - ), - patch( - "prowler.providers.googleworkspace.services.security.security_2sv_enforced.security_2sv_enforced.security_client" - ) as mock_client, - ): - from prowler.providers.googleworkspace.services.security.security_2sv_enforced.security_2sv_enforced import ( - security_2sv_enforced, - ) - - mock_client.provider = mock_provider - mock_client.policies_fetched = False - mock_client.policies = SecurityPolicies() - - check = security_2sv_enforced() - findings = check.execute() - - assert len(findings) == 0 + """No findings returned when the API fetch failed""" + assert run_check(policies_fetched=False) == [] diff --git a/tests/providers/googleworkspace/services/security/security_2sv_hardware_keys_admins/security_2sv_hardware_keys_admins_test.py b/tests/providers/googleworkspace/services/security/security_2sv_hardware_keys_admins/security_2sv_hardware_keys_admins_test.py index 522164f341..6fa4de8157 100644 --- a/tests/providers/googleworkspace/services/security/security_2sv_hardware_keys_admins/security_2sv_hardware_keys_admins_test.py +++ b/tests/providers/googleworkspace/services/security/security_2sv_hardware_keys_admins/security_2sv_hardware_keys_admins_test.py @@ -1,5 +1,7 @@ from unittest.mock import patch +import pytest + from prowler.providers.googleworkspace.services.security.security_service import ( SecurityPolicies, ) @@ -8,119 +10,259 @@ from tests.providers.googleworkspace.googleworkspace_fixtures import ( set_mocked_googleworkspace_provider, ) +CHECK_CLIENT = ( + "prowler.providers.googleworkspace.services.security." + "security_2sv_hardware_keys_admins.security_2sv_hardware_keys_admins." + "security_client" +) + +# A domain that satisfies every step of the CIS audit procedure. +COMPLIANT = dict( + two_sv_allowed_factor_set="PASSKEY_ONLY", + two_sv_enforced_from="2026-05-25T15:27:52.352Z", + two_sv_allow_enrollment=True, + two_sv_backup_code_exception_period="86400s", +) + + +def run_check(policies_fetched=True, **overrides): + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch(CHECK_CLIENT) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_2sv_hardware_keys_admins.security_2sv_hardware_keys_admins import ( + security_2sv_hardware_keys_admins, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = policies_fetched + mock_client.policies = SecurityPolicies(**overrides) + + return security_2sv_hardware_keys_admins().execute() + class TestSecurity2svHardwareKeysAdmins: - def test_pass_passkey_only(self): - """Test PASS when 2SV enforcement requires security keys only""" - mock_provider = set_mocked_googleworkspace_provider() + def test_pass_full_audit_procedure_met(self): + """PASS when every step of the CIS audit procedure is satisfied""" + findings = run_check(**COMPLIANT) - with ( - patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=mock_provider, + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "requires security keys only" in findings[0].status_extended + assert findings[0].resource_name == "Security Policies" + assert findings[0].resource_id == "securityPolicies" + assert findings[0].customer_id == CUSTOMER_ID + + def test_pass_enforcement_scheduled_for_a_future_date(self): + """4.1.1.2 accepts 'On from ', unlike 4.1.1.1 and 4.1.1.3""" + findings = run_check( + **{**COMPLIANT, "two_sv_enforced_from": "2099-01-01T00:00:00Z"} + ) + + assert len(findings) == 1 + assert findings[0].status == "PASS" + + def test_pass_no_suspension_grace_period(self): + """No grace period is stricter than the 1 day the benchmark asks for""" + findings = run_check( + **{**COMPLIANT, "two_sv_backup_code_exception_period": "0s"} + ) + + assert len(findings) == 1 + assert findings[0].status == "PASS" + + def test_pass_ignores_new_user_enrollment_period(self): + """The new user enrollment period belongs to 4.1.1.1 and 4.1.1.3, not here""" + findings = run_check( + **{**COMPLIANT, "two_sv_enrollment_grace_period": "2592000s"} + ) + + assert len(findings) == 1 + assert findings[0].status == "PASS" + + def test_pass_ignores_advanced_protection_security_codes(self): + """The Advanced Protection Program page is covered by CIS 4.1.3.1, not here""" + findings = run_check( + **{ + **COMPLIANT, + "advanced_protection_security_code_option": "ALLOWED_WITHOUT_REMOTE_ACCESS", + } + ) + + assert len(findings) == 1 + assert findings[0].status == "PASS" + + @pytest.mark.parametrize( + "overrides, expected", + [ + ({"two_sv_allowed_factor_set": "ALL"}, "accepted method is ALL"), + ( + {"two_sv_allowed_factor_set": None}, + "accepted method is not configured", ), - patch( - "prowler.providers.googleworkspace.services.security.security_2sv_hardware_keys_admins.security_2sv_hardware_keys_admins.security_client" - ) as mock_client, - ): - from prowler.providers.googleworkspace.services.security.security_2sv_hardware_keys_admins.security_2sv_hardware_keys_admins import ( - security_2sv_hardware_keys_admins, - ) - - mock_client.provider = mock_provider - mock_client.policies_fetched = True - mock_client.policies = SecurityPolicies( - two_sv_allowed_factor_set="PASSKEY_ONLY" - ) - - check = security_2sv_hardware_keys_admins() - findings = check.execute() - - assert len(findings) == 1 - assert findings[0].status == "PASS" - assert "security keys only" in findings[0].status_extended - assert findings[0].resource_name == "Security Policies" - assert findings[0].resource_id == "securityPolicies" - assert findings[0].customer_id == CUSTOMER_ID - - def test_fail_all_methods_allowed(self): - """Test FAIL when 2SV enforcement allows ALL methods""" - mock_provider = set_mocked_googleworkspace_provider() - - with ( - patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=mock_provider, + ({"two_sv_enforced_from": None}, "enforcement is not configured"), + ( + {"two_sv_enforced_from": "1970-01-01T00:00:00Z"}, + "enforcement is set to OFF", ), - patch( - "prowler.providers.googleworkspace.services.security.security_2sv_hardware_keys_admins.security_2sv_hardware_keys_admins.security_client" - ) as mock_client, - ): - from prowler.providers.googleworkspace.services.security.security_2sv_hardware_keys_admins.security_2sv_hardware_keys_admins import ( - security_2sv_hardware_keys_admins, - ) - - mock_client.provider = mock_provider - mock_client.policies_fetched = True - mock_client.policies = SecurityPolicies(two_sv_allowed_factor_set="ALL") - - check = security_2sv_hardware_keys_admins() - findings = check.execute() - - assert len(findings) == 1 - assert findings[0].status == "FAIL" - assert "ALL" in findings[0].status_extended - - def test_fail_none_not_configured(self): - """Test FAIL when 2SV enforcement factor is not configured (None)""" - mock_provider = set_mocked_googleworkspace_provider() - - with ( - patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=mock_provider, + ({"two_sv_allow_enrollment": False}, "not allowed to turn on"), + ( + {"two_sv_enforced_from": "not-a-date"}, + "enforcement start date 'not-a-date' could not be read", ), - patch( - "prowler.providers.googleworkspace.services.security.security_2sv_hardware_keys_admins.security_2sv_hardware_keys_admins.security_client" - ) as mock_client, - ): - from prowler.providers.googleworkspace.services.security.security_2sv_hardware_keys_admins.security_2sv_hardware_keys_admins import ( - security_2sv_hardware_keys_admins, - ) + ( + {"two_sv_backup_code_exception_period": "1209600s"}, + "suspension grace period is 14 day(s)", + ), + ( + {"two_sv_backup_code_exception_period": "7d"}, + "suspension grace period '7d' could not be read", + ), + ], + ) + def test_fail_each_audit_step(self, overrides, expected): + """FAIL when any single step of the CIS audit procedure is not met""" + findings = run_check(**{**COMPLIANT, **overrides}) - mock_client.provider = mock_provider - mock_client.policies_fetched = True - mock_client.policies = SecurityPolicies(two_sv_allowed_factor_set=None) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert expected in findings[0].status_extended - check = security_2sv_hardware_keys_admins() - findings = check.execute() + def test_fail_keeps_domain_wide_scope_note(self): + """The domain-wide scope caveat is reported on failure too""" + findings = run_check() - assert len(findings) == 1 - assert findings[0].status == "FAIL" - assert "not configured" in findings[0].status_extended + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "role-specific 2SV enforcement" in findings[0].status_extended + + def test_manual_when_a_group_or_sub_ou_overrides_a_passing_policy(self): + """A passing domain-wide policy cannot be confirmed for the overridden users""" + findings = run_check( + **{ + **COMPLIANT, + "overridden_settings": {"security.two_step_verification_enforcement"}, + } + ) + + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "security.two_step_verification_enforcement is also overridden" in ( + findings[0].status_extended + ) + + def test_a_domain_wide_failure_is_reported_even_with_an_override(self): + """Whoever no override reaches still gets the failing domain-wide policy""" + findings = run_check( + **{ + **COMPLIANT, + "two_sv_enforced_from": None, + "overridden_settings": { + "security.two_step_verification_enforcement_factor" + }, + } + ) + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "enforcement is not configured" in findings[0].status_extended + + def test_manual_when_a_setting_only_exists_below_the_domain(self): + """No domain-wide value was reported, so the defaults would fabricate issues""" + findings = run_check( + **{ + **COMPLIANT, + "two_sv_allowed_factor_set": None, + "overridden_settings": [ + "security.two_step_verification_enforcement_factor" + ], + "unobserved_settings": [ + "security.two_step_verification_enforcement_factor" + ], + } + ) + + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "no domain-wide value was reported" in findings[0].status_extended + + def test_manual_when_the_policy_scope_could_not_be_resolved(self): + """Every value was dropped, so none of them can be judged""" + findings = run_check(**{**COMPLIANT, "unresolved_scope": True}) + + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "root organizational unit could not be resolved" in ( + findings[0].status_extended + ) + + def test_a_failure_keeps_the_override_caveat(self): + findings = run_check( + **{ + **COMPLIANT, + "two_sv_allowed_factor_set": "ALL", + "overridden_settings": ["security.two_step_verification_enforcement"], + } + ) + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "accepted method is ALL" in findings[0].status_extended + assert "also overridden" in findings[0].status_extended + + def test_manual_when_every_failing_setting_is_overridden(self): + """The admin group may get the overriding value, which is not exposed""" + findings = run_check( + **{ + **COMPLIANT, + "two_sv_allowed_factor_set": "ALL", + "overridden_settings": [ + "security.two_step_verification_enforcement_factor" + ], + } + ) + + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "accepted method is ALL" in findings[0].status_extended + assert "administrative accounts may be configured correctly" in ( + findings[0].status_extended + ) + + def test_fail_when_only_some_failing_settings_are_overridden(self): + """A failure no override reaches is still proven for the whole domain""" + findings = run_check( + **{ + **COMPLIANT, + "two_sv_allowed_factor_set": "ALL", + "two_sv_allow_enrollment": False, + "overridden_settings": [ + "security.two_step_verification_enforcement_factor" + ], + } + ) + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "not allowed to turn on" in findings[0].status_extended + + def test_an_override_on_a_setting_this_check_ignores_does_not_apply(self): + """Device trust belongs to 4.1.1.1 and 4.1.1.3, not to 4.1.1.2""" + findings = run_check( + **{ + **COMPLIANT, + "overridden_settings": {"security.two_step_verification_device_trust"}, + } + ) + + assert len(findings) == 1 + assert findings[0].status == "PASS" def test_no_findings_when_fetch_failed(self): - """Test no findings returned when the API fetch failed""" - mock_provider = set_mocked_googleworkspace_provider() - - with ( - patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=mock_provider, - ), - patch( - "prowler.providers.googleworkspace.services.security.security_2sv_hardware_keys_admins.security_2sv_hardware_keys_admins.security_client" - ) as mock_client, - ): - from prowler.providers.googleworkspace.services.security.security_2sv_hardware_keys_admins.security_2sv_hardware_keys_admins import ( - security_2sv_hardware_keys_admins, - ) - - mock_client.provider = mock_provider - mock_client.policies_fetched = False - mock_client.policies = SecurityPolicies() - - check = security_2sv_hardware_keys_admins() - findings = check.execute() - - assert len(findings) == 0 + """No findings returned when the API fetch failed""" + assert run_check(policies_fetched=False) == [] diff --git a/tests/providers/googleworkspace/services/security/security_password_policy_strong/security_password_policy_strong_test.py b/tests/providers/googleworkspace/services/security/security_password_policy_strong/security_password_policy_strong_test.py index 850bf243a9..7f504d2293 100644 --- a/tests/providers/googleworkspace/services/security/security_password_policy_strong/security_password_policy_strong_test.py +++ b/tests/providers/googleworkspace/services/security/security_password_policy_strong/security_password_policy_strong_test.py @@ -115,8 +115,8 @@ class TestSecurityPasswordPolicyStrong: assert findings[0].status == "FAIL" assert "does not meet" in findings[0].status_extended - def test_fail_strength_unset_treated_as_missing(self): - """Test FAIL when password_allowed_strength is None even with other fields strong""" + def test_pass_strength_unset_is_googles_secure_default(self): + """CIS documents 'Enforce strong password' as checked out of the box""" mock_provider = set_mocked_googleworkspace_provider() with ( @@ -146,8 +146,7 @@ class TestSecurityPasswordPolicyStrong: findings = check.execute() assert len(findings) == 1 - assert findings[0].status == "FAIL" - assert "password strength is not configured" in findings[0].status_extended + assert findings[0].status == "PASS" def test_fail_min_length_unset_reports_not_configured(self): """Test FAIL message uses 'not configured' when password_minimum_length is None""" @@ -183,6 +182,207 @@ class TestSecurityPasswordPolicyStrong: assert findings[0].status == "FAIL" assert "minimum length is not configured" in findings[0].status_extended + def test_fail_expiration_longer_than_365_days(self): + """Test FAIL when the password reset frequency exceeds 365 days""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_password_policy_strong.security_password_policy_strong.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_password_policy_strong.security_password_policy_strong import ( + security_password_policy_strong, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies( + password_minimum_length=14, + password_allowed_strength="STRONG", + password_allow_reuse=False, + password_enforce_at_login=True, + password_expiration_duration="63072000s", + ) + + check = security_password_policy_strong() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "expiration is 730 day(s)" in findings[0].status_extended + assert "requires 365 days or less" in findings[0].status_extended + + def test_pass_expiration_shorter_than_365_days(self): + """Test PASS when the reset frequency is shorter, which is more restrictive""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_password_policy_strong.security_password_policy_strong.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_password_policy_strong.security_password_policy_strong import ( + security_password_policy_strong, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies( + password_minimum_length=14, + password_allowed_strength="STRONG", + password_allow_reuse=False, + password_enforce_at_login=True, + password_expiration_duration="7776000s", + ) + + check = security_password_policy_strong() + findings = check.execute() + + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "expiration 90 day(s)" in findings[0].status_extended + + def test_fail_reuse_allowed(self): + """Test FAIL when password reuse is allowed (CIS 4.1.5.1 step 7)""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_password_policy_strong.security_password_policy_strong.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_password_policy_strong.security_password_policy_strong import ( + security_password_policy_strong, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies( + password_minimum_length=14, + password_allowed_strength="STRONG", + password_allow_reuse=True, + password_enforce_at_login=True, + password_expiration_duration="31536000s", + ) + + findings = security_password_policy_strong().execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "password reuse is allowed" in findings[0].status_extended + + def test_fail_not_enforced_at_next_sign_in(self): + """Test FAIL when the policy is not enforced at next sign-in (CIS 4.1.5.1 step 6)""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_password_policy_strong.security_password_policy_strong.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_password_policy_strong.security_password_policy_strong import ( + security_password_policy_strong, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies( + password_minimum_length=14, + password_allowed_strength="STRONG", + password_allow_reuse=False, + password_enforce_at_login=False, + password_expiration_duration="31536000s", + ) + + findings = security_password_policy_strong().execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "not enforced at next sign-in" in findings[0].status_extended + + def test_fail_passwords_never_expire(self): + """Test FAIL naming Google's 'Never expires' rather than 'not configured'""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_password_policy_strong.security_password_policy_strong.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_password_policy_strong.security_password_policy_strong import ( + security_password_policy_strong, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies( + password_minimum_length=14, + password_allowed_strength="STRONG", + password_allow_reuse=False, + password_enforce_at_login=True, + password_expiration_duration="0s", + ) + + findings = security_password_policy_strong().execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "never expire" in findings[0].status_extended + + def test_fail_unreadable_expiration(self): + """Test FAIL when the expiration value cannot be parsed, instead of skipping it""" + mock_provider = set_mocked_googleworkspace_provider() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=mock_provider, + ), + patch( + "prowler.providers.googleworkspace.services.security.security_password_policy_strong.security_password_policy_strong.security_client" + ) as mock_client, + ): + from prowler.providers.googleworkspace.services.security.security_password_policy_strong.security_password_policy_strong import ( + security_password_policy_strong, + ) + + mock_client.provider = mock_provider + mock_client.policies_fetched = True + mock_client.policies = SecurityPolicies( + password_minimum_length=14, + password_allowed_strength="STRONG", + password_allow_reuse=False, + password_enforce_at_login=True, + password_expiration_duration="365d", + ) + + findings = security_password_policy_strong().execute() + + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "could not be read" in findings[0].status_extended + def test_no_findings_when_fetch_failed(self): """Test no findings returned when the API fetch failed""" mock_provider = set_mocked_googleworkspace_provider() diff --git a/tests/providers/huaweicloud/huaweicloud_provider_test.py b/tests/providers/huaweicloud/huaweicloud_provider_test.py index 189a64a44f..8b84c3f2f5 100644 --- a/tests/providers/huaweicloud/huaweicloud_provider_test.py +++ b/tests/providers/huaweicloud/huaweicloud_provider_test.py @@ -292,6 +292,32 @@ class TestHuaweiCloudEndpointAlignment: endpoint = "https://ecs.af-north-1.myhuaweicloud.com" assert _align_endpoint_tld("af-north-1", endpoint) == endpoint + def test_smn_client_uses_europe_endpoint(self): + session = HuaweiCloudSession( + HuaweiCloudCredentials(ak=ACCESS_KEY, sk=SECRET_KEY), + region="eu-west-101", + ) + builder = mock.MagicMock() + builder.with_credentials.return_value = builder + builder.with_http_config.return_value = builder + builder.with_region.return_value = builder + expected_client = mock.MagicMock() + builder.build.return_value = expected_client + + with ( + mock.patch( + "huaweicloudsdksmn.v2.SmnClient.new_builder", return_value=builder + ), + mock.patch.object(session, "_http_config"), + mock.patch.object(session, "_get_basic_credentials"), + ): + client = session.client("smn", "eu-west-101") + + assert client is expected_client + region = builder.with_region.call_args.args[0] + assert region.id == "eu-west-101" + assert region.endpoints == ["https://smn.eu-west-101.myhuaweicloud.eu"] + class TestHuaweiCloudProviderValidationRegion: def test_no_regions_uses_default(self): diff --git a/tests/providers/huaweicloud/services/smn/huaweicloud_smn_service_test.py b/tests/providers/huaweicloud/services/smn/huaweicloud_smn_service_test.py new file mode 100644 index 0000000000..a91d71cde3 --- /dev/null +++ b/tests/providers/huaweicloud/services/smn/huaweicloud_smn_service_test.py @@ -0,0 +1,124 @@ +from unittest import mock + +from huaweicloudsdksmn.v2 import ( + ListSubscriptionsByTopicResponse, + ListSubscriptionsItem, + ListTopicsItem, + ListTopicsResponse, +) + +from prowler.providers.huaweicloud.services.smn.smn_service import SMN + + +def _topic(number: int) -> ListTopicsItem: + return ListTopicsItem( + topic_urn=f"urn:smn:eu-west-101:account:topic-{number}", + topic_id=f"topic-{number}", + name=f"topic-{number}", + display_name=f"Topic {number}", + push_policy=0, + ) + + +def _topics_page(topic_count: int, topics: list) -> ListTopicsResponse: + return ListTopicsResponse(topic_count=topic_count, topics=topics) + + +def _subscriptions_page( + subscription_count: int, statuses: list +) -> ListSubscriptionsByTopicResponse: + return ListSubscriptionsByTopicResponse( + subscription_count=subscription_count, + subscriptions=[ListSubscriptionsItem(status=status) for status in statuses], + ) + + +def _service(client): + service = SMN.__new__(SMN) + service.regional_clients = {"eu-west-101": client} + service.topics = [] + return service + + +class TestHuaweiCloudSMNService: + def test_unconfirmed_and_canceled_subscriptions_do_not_count(self): + client = mock.MagicMock() + client.list_topics.return_value = _topics_page(1, [_topic(1)]) + client.list_subscriptions_by_topic.return_value = _subscriptions_page(2, [0, 3]) + service = _service(client) + + service._list_topics() + + assert len(service.topics) == 1 + assert service.topics[0].confirmed_subscription_count == 0 + + def test_paginates_topics_and_confirmed_subscriptions(self): + client = mock.MagicMock() + client.list_topics.side_effect = [ + _topics_page(101, [_topic(1)]), + _topics_page(101, [_topic(2)]), + ] + client.list_subscriptions_by_topic.side_effect = [ + _subscriptions_page(101, [0] * 100), + _subscriptions_page(101, [1]), + _subscriptions_page(2, [1, 3]), + ] + service = _service(client) + + service._list_topics() + + assert [topic.confirmed_subscription_count for topic in service.topics] == [ + 1, + 1, + ] + topic_requests = [call.args[0] for call in client.list_topics.call_args_list] + assert [(request.offset, request.limit) for request in topic_requests] == [ + (0, 100), + (100, 100), + ] + subscription_requests = [ + call.args[0] for call in client.list_subscriptions_by_topic.call_args_list + ] + assert [ + (request.topic_urn, request.offset, request.limit) + for request in subscription_requests + ] == [ + ("urn:smn:eu-west-101:account:topic-1", 0, 100), + ("urn:smn:eu-west-101:account:topic-1", 100, 100), + ("urn:smn:eu-west-101:account:topic-2", 0, 100), + ] + + def test_skips_topic_when_subscription_discovery_fails(self): + client = mock.MagicMock() + client.list_topics.return_value = _topics_page(1, [_topic(1)]) + client.list_subscriptions_by_topic.side_effect = Exception("denied") + service = _service(client) + + service._list_topics() + + assert service.topics == [] + + def test_topic_discovery_failure_returns_empty_inventory(self): + client = mock.MagicMock() + client.list_topics.side_effect = Exception("denied") + service = _service(client) + + service._list_topics() + + assert service.topics == [] + + def test_init_always_lists_topics_from_the_api(self): + def initialize_service(service, *_args, **_kwargs): + service.session = mock.MagicMock() + + with ( + mock.patch.object(SMN, "_list_topics") as list_topics, + mock.patch( + "prowler.providers.huaweicloud.services.smn.smn_service.HuaweiCloudService.__init__", + new=initialize_service, + ), + ): + service = SMN(mock.MagicMock()) + + list_topics.assert_called_once_with() + assert service.topics == [] diff --git a/tests/providers/huaweicloud/services/smn/smn_topic_subscriptions/smn_topic_subscriptions_test.py b/tests/providers/huaweicloud/services/smn/smn_topic_subscriptions/smn_topic_subscriptions_test.py new file mode 100644 index 0000000000..3e76dddc80 --- /dev/null +++ b/tests/providers/huaweicloud/services/smn/smn_topic_subscriptions/smn_topic_subscriptions_test.py @@ -0,0 +1,164 @@ +from unittest import mock + +from prowler.providers.huaweicloud.services.smn.smn_service import SMNTopic + +from tests.providers.huaweicloud.huaweicloud_fixtures import ( + set_mocked_huaweicloud_provider, +) + + +class Test_smn_topic_subscriptions: + def test_no_topics(self): + smn_client = mock.MagicMock() + smn_client.topics = [] + smn_client.region = "la-south-2" + smn_client.audited_account = "123456789012" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_huaweicloud_provider(), + ), + mock.patch( + "prowler.providers.huaweicloud.services.smn.smn_topic_subscriptions.smn_topic_subscriptions.smn_client", + new=smn_client, + ), + ): + from prowler.providers.huaweicloud.services.smn.smn_topic_subscriptions.smn_topic_subscriptions import ( + smn_topic_subscriptions, + ) + + check = smn_topic_subscriptions() + result = check.execute() + assert len(result) == 0 + + def test_topic_with_subscriptions(self): + smn_client = mock.MagicMock() + smn_client.topics = [ + SMNTopic( + topic_urn="urn:smn:la-south-2:123456789012:alert-topic", + topic_id="topic-001", + name="alert-topic", + display_name="Alert Topic", + push_policy=0, + confirmed_subscription_count=2, + region="la-south-2", + ) + ] + smn_client.region = "la-south-2" + smn_client.audited_account = "123456789012" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_huaweicloud_provider(), + ), + mock.patch( + "prowler.providers.huaweicloud.services.smn.smn_topic_subscriptions.smn_topic_subscriptions.smn_client", + new=smn_client, + ), + ): + from prowler.providers.huaweicloud.services.smn.smn_topic_subscriptions.smn_topic_subscriptions import ( + smn_topic_subscriptions, + ) + + check = smn_topic_subscriptions() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + "SMN topic 'alert-topic' (topic-001) has 2 confirmed " + "subscription(s)." + ) + assert result[0].resource_id == "topic-001" + assert result[0].resource_name == "alert-topic" + assert result[0].resource_arn == ( + "urn:smn:la-south-2:123456789012:alert-topic" + ) + + def test_topic_without_subscriptions(self): + smn_client = mock.MagicMock() + smn_client.topics = [ + SMNTopic( + topic_urn="urn:smn:la-south-2:123456789012:empty-topic", + topic_id="topic-002", + name="empty-topic", + display_name="Empty Topic", + push_policy=0, + confirmed_subscription_count=0, + region="la-south-2", + ) + ] + smn_client.region = "la-south-2" + smn_client.audited_account = "123456789012" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_huaweicloud_provider(), + ), + mock.patch( + "prowler.providers.huaweicloud.services.smn.smn_topic_subscriptions.smn_topic_subscriptions.smn_client", + new=smn_client, + ), + ): + from prowler.providers.huaweicloud.services.smn.smn_topic_subscriptions.smn_topic_subscriptions import ( + smn_topic_subscriptions, + ) + + check = smn_topic_subscriptions() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "SMN topic 'empty-topic' (topic-002) has no confirmed " + "subscriptions. Notifications will not be delivered." + ) + assert result[0].resource_arn == ( + "urn:smn:la-south-2:123456789012:empty-topic" + ) + + def test_mixed_topics(self): + smn_client = mock.MagicMock() + smn_client.topics = [ + SMNTopic( + topic_urn="urn:smn:la-south-2:123456789012:alert-topic", + topic_id="topic-001", + name="alert-topic", + display_name="Alert Topic", + push_policy=0, + confirmed_subscription_count=3, + region="la-south-2", + ), + SMNTopic( + topic_urn="urn:smn:la-south-2:123456789012:empty-topic", + topic_id="topic-002", + name="empty-topic", + display_name="Empty Topic", + push_policy=0, + confirmed_subscription_count=0, + region="la-south-2", + ), + ] + smn_client.region = "la-south-2" + smn_client.audited_account = "123456789012" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_huaweicloud_provider(), + ), + mock.patch( + "prowler.providers.huaweicloud.services.smn.smn_topic_subscriptions.smn_topic_subscriptions.smn_client", + new=smn_client, + ), + ): + from prowler.providers.huaweicloud.services.smn.smn_topic_subscriptions.smn_topic_subscriptions import ( + smn_topic_subscriptions, + ) + + check = smn_topic_subscriptions() + result = check.execute() + assert len(result) == 2 + assert result[0].status == "PASS" + assert result[1].status == "FAIL" diff --git a/tests/providers/image/image_provider_test.py b/tests/providers/image/image_provider_test.py index 92c4236dd8..94c6a7181b 100644 --- a/tests/providers/image/image_provider_test.py +++ b/tests/providers/image/image_provider_test.py @@ -50,6 +50,11 @@ def _make_provider(**kwargs): return ImageProvider(**defaults) +@pytest.fixture(autouse=True) +def _no_configured_cache_dir(monkeypatch): + monkeypatch.delenv("TRIVY_CACHE_DIR", raising=False) + + class TestImageProvider: def test_image_provider(self): """Test default initialization.""" @@ -678,6 +683,18 @@ class TestImageProviderRegistryAuth: assert env["TRIVY_REGISTRY_TOKEN"] == "my-token" + def test_build_trivy_env_registry_insecure_sets_trivy_insecure(self): + provider = _make_provider(registry_insecure=True) + env = provider._build_trivy_env() + + assert env["TRIVY_INSECURE"] == "true" + + def test_build_trivy_env_secure_registry_leaves_trivy_insecure_unset(self): + provider = _make_provider() + env = provider._build_trivy_env() + + assert "TRIVY_INSECURE" not in env + @patch("subprocess.run") def test_execute_trivy_sets_trivy_env_with_basic_auth(self, mock_subprocess): """Test that _execute_trivy sets TRIVY_USERNAME/PASSWORD for native Trivy auth.""" @@ -987,6 +1004,34 @@ class TestCleanup: provider.cleanup() provider.cleanup() + def test_configured_cache_dir_is_used(self, monkeypatch, tmp_path): + """A deployment that supplies a cache directory gets that one.""" + monkeypatch.setenv("TRIVY_CACHE_DIR", str(tmp_path)) + + provider = _make_provider() + + assert provider._trivy_cache_dir == str(tmp_path) + + def test_configured_cache_dir_survives_cleanup(self, monkeypatch, tmp_path): + """A supplied directory is not the provider's to delete: it holds a + database the deployment may have no way to fetch again.""" + monkeypatch.setenv("TRIVY_CACHE_DIR", str(tmp_path)) + provider = _make_provider() + + provider.cleanup() + + assert os.path.isdir(str(tmp_path)) + + def test_unset_cache_dir_keeps_the_temporary_one(self, monkeypatch): + """Without one configured, nothing changes for existing deployments.""" + monkeypatch.delenv("TRIVY_CACHE_DIR", raising=False) + + provider = _make_provider() + + assert os.path.isdir(provider._trivy_cache_dir) + provider.cleanup() + assert not os.path.isdir(provider._trivy_cache_dir) + def test_cleanup_removes_trivy_cache_dir(self): """Test that cleanup removes the temporary Trivy cache directory.""" provider = _make_provider() @@ -1381,3 +1426,102 @@ class TestRegistryListMode: # This is the line that crashes: global_provider is None so # .print_credentials() raises AttributeError. global_provider.print_credentials() + + +class TestConnectionPrivateNetworkAllowlist: + """PROWLER_IMAGE_PROVIDER_ALLOWED_PRIVATE_NETWORKS applies to test_connection.""" + + ENV = "PROWLER_IMAGE_PROVIDER_ALLOWED_PRIVATE_NETWORKS" + + @staticmethod + def _private_dns(host_to_ip): + def _stub(host, *_args, **_kwargs): + return [(2, 1, 6, "", (host_to_ip[host], 0))] + + return _stub + + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_private_registry_rejected_without_allowlist( + self, mock_request, monkeypatch + ): + monkeypatch.delenv(self.ENV, raising=False) + ping = MagicMock( + status_code=401, + headers={ + "Www-Authenticate": 'Bearer realm="https://harbor.internal/service/token",service="harbor-registry"' + }, + ) + mock_request.return_value = ping + + with patch( + "prowler.providers.image.lib.registry.base.socket.getaddrinfo", + side_effect=self._private_dns({"harbor.internal": "10.20.0.5"}), + ): + result = ImageProvider.test_connection( + image="harbor.internal", raise_on_exception=False + ) + + assert result.is_connected is False + + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_private_registry_permitted_with_allowlist(self, mock_request, monkeypatch): + monkeypatch.setenv(self.ENV, "10.20.0.0/16") + ping = MagicMock( + status_code=401, + headers={ + "Www-Authenticate": 'Bearer realm="https://harbor.internal/service/token",service="harbor-registry"' + }, + ) + token = MagicMock(status_code=200) + token.json.return_value = {"token": "tok"} + catalog = MagicMock(status_code=200, headers={}) + catalog.json.return_value = {"repositories": ["app"]} + mock_request.side_effect = [ping, token, catalog] + + with patch( + "prowler.providers.image.lib.registry.base.socket.getaddrinfo", + side_effect=self._private_dns({"harbor.internal": "10.20.0.5"}), + ): + result = ImageProvider.test_connection(image="harbor.internal") + + assert result.is_connected is True + + +class TestRegistryScanErrorDegradation: + @patch("subprocess.run") + def test_registry_discovered_image_scan_error_is_skipped(self, mock_subprocess): + provider = _make_provider(images=["reg.io/chart:1.0", "alpine:3.18"]) + provider._registry_discovered = {"reg.io/chart:1.0"} + mock_subprocess.side_effect = [ + MagicMock(returncode=1, stdout="", stderr="unsupported media type"), + MagicMock(returncode=0, stdout=get_sample_trivy_json_output(), stderr=""), + ] + + reports = [] + for batch in provider.run_scan(): + reports.extend(batch) + + assert len(reports) == 1 + assert reports[0].check_metadata.CheckID == "CVE-2024-1234" + + @patch("subprocess.run") + def test_explicit_image_scan_error_still_raises(self, mock_subprocess): + provider = _make_provider(images=["alpine:3.18"]) + mock_subprocess.return_value = MagicMock( + returncode=1, stdout="", stderr="unsupported media type" + ) + + with pytest.raises(ImageScanError): + for _ in provider.run_scan(): + pass + + @patch("subprocess.run") + def test_scan_per_image_degrades_registry_discovered_error(self, mock_subprocess): + provider = _make_provider(images=["reg.io/chart:1.0"]) + provider._registry_discovered = {"reg.io/chart:1.0"} + mock_subprocess.return_value = MagicMock( + returncode=1, stdout="", stderr="unsupported media type" + ) + + results = list(provider.scan_per_image()) + assert results == [("reg.io/chart:1.0", [])] diff --git a/tests/providers/image/lib/registry/test_oci_adapter.py b/tests/providers/image/lib/registry/test_oci_adapter.py index b814019504..8713f98f97 100644 --- a/tests/providers/image/lib/registry/test_oci_adapter.py +++ b/tests/providers/image/lib/registry/test_oci_adapter.py @@ -6,6 +6,7 @@ import pytest import requests from prowler.providers.image.exceptions.exceptions import ( + ImageInvalidAllowedNetworksError, ImageRegistryAuthError, ImageRegistryCatalogError, ImageRegistryNetworkError, @@ -96,6 +97,37 @@ class TestOciAdapterAuth: adapter._ensure_auth() assert adapter._bearer_token == "bearer-tok" + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_http_realm_from_https_registry_raises(self, mock_request): + ping_resp = MagicMock( + status_code=401, + headers={ + "Www-Authenticate": 'Bearer realm="http://auth.reg.io/token",service="registry"' + }, + ) + mock_request.return_value = ping_resp + adapter = OciRegistryAdapter("https://reg.io", username="u", password="p") + with pytest.raises(ImageRegistryAuthError, match="cleartext"): + adapter._ensure_auth() + # The token exchange must never happen: only the /v2/ ping went out + assert mock_request.call_count == 1 + + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_http_realm_from_http_registry_keeps_credentials(self, mock_request): + ping_resp = MagicMock( + status_code=401, + headers={ + "Www-Authenticate": 'Bearer realm="http://reg.io/token",service="registry"' + }, + ) + token_resp = MagicMock(status_code=200) + token_resp.json.return_value = {"token": "bearer-tok"} + mock_request.side_effect = [ping_resp, token_resp] + adapter = OciRegistryAdapter("http://reg.io", username="u", password="p") + adapter._ensure_auth() + token_call = mock_request.call_args_list[1] + assert token_call.kwargs.get("auth") == ("u", "p") + @patch("prowler.providers.image.lib.registry.base.requests.request") def test_ensure_auth_403_raises(self, mock_request): resp = MagicMock(status_code=403) @@ -193,8 +225,8 @@ class TestOciAdapterAuth: def test_authed_request_retries_on_401_with_bearer(self, mock_request): adapter = OciRegistryAdapter("reg.io", username="u", password="p") adapter._bearer_token = "expired-token" - # First request: 401 (expired token) - resp_401 = MagicMock(status_code=401) + # First request: 401 without a challenge -> blind re-auth fallback + resp_401 = MagicMock(status_code=401, headers={}) # _ensure_auth ping: 401 with bearer challenge ping_resp = MagicMock( status_code=401, @@ -218,7 +250,7 @@ class TestOciAdapterAuth: adapter = OciRegistryAdapter("reg.io", username="u", password="p") adapter._basic_auth_verified = True # No bearer token — using basic auth - resp_401 = MagicMock(status_code=401) + resp_401 = MagicMock(status_code=401, headers={}) mock_request.return_value = resp_401 result = adapter._authed_request("GET", "https://reg.io/v2/_catalog") assert result.status_code == 401 @@ -709,3 +741,587 @@ class TestCredentialRedaction: adapter = OciRegistryAdapter("reg.io", password="secret", token="tok") assert adapter.password == "secret" assert adapter.token == "tok" + + +class TestAllowedPrivateNetworks: + """PROWLER_IMAGE_PROVIDER_ALLOWED_PRIVATE_NETWORKS: explicit CIDR allowlist + consulted by the SSRF guard; unset preserves the default rejection.""" + + ENV = "PROWLER_IMAGE_PROVIDER_ALLOWED_PRIVATE_NETWORKS" + + def test_unset_env_keeps_private_origin_rejected(self, monkeypatch): + monkeypatch.delenv(self.ENV, raising=False) + adapter = OciRegistryAdapter("https://10.0.0.5:5000") + with pytest.raises(ImageRegistryAuthError, match="non-public"): + adapter._validate_outbound_url("https://10.0.0.5:5000/v2/_catalog?last=x") + + def test_empty_env_keeps_private_origin_rejected(self, monkeypatch): + monkeypatch.setenv(self.ENV, " ") + adapter = OciRegistryAdapter("https://10.0.0.5:5000") + with pytest.raises(ImageRegistryAuthError, match="non-public"): + adapter._validate_outbound_url("https://10.0.0.5:5000/v2/_catalog?last=x") + + def test_allowlisted_literal_ip_permitted(self, monkeypatch): + monkeypatch.setenv(self.ENV, "192.168.65.254/32,10.20.0.0/16") + adapter = OciRegistryAdapter("https://10.20.0.5:5000") + url = adapter._validate_outbound_url( + "https://10.20.0.5:5000/v2/_catalog?last=x" + ) + assert url == "https://10.20.0.5:5000/v2/_catalog?last=x" + + def test_allowlisted_resolved_hostname_permitted(self, monkeypatch): + monkeypatch.setenv(self.ENV, "10.20.0.0/16") + adapter = OciRegistryAdapter("https://harbor.internal") + with patch( + "prowler.providers.image.lib.registry.base.socket.getaddrinfo", + side_effect=_fake_getaddrinfo({"harbor.internal": "10.20.0.5"}), + ): + url = adapter._validate_outbound_url( + "https://harbor.internal/service/token" + ) + assert url == "https://harbor.internal/service/token" + + def test_private_ip_outside_allowlist_rejected(self, monkeypatch): + monkeypatch.setenv(self.ENV, "10.20.0.0/16") + adapter = OciRegistryAdapter("https://harbor.internal") + with patch( + "prowler.providers.image.lib.registry.base.socket.getaddrinfo", + side_effect=_fake_getaddrinfo( + {"harbor.internal": "10.20.0.5", "evil.internal": "192.168.1.99"} + ), + ): + with pytest.raises(ImageRegistryAuthError, match="non-public"): + adapter._validate_outbound_url("https://evil.internal/token") + + def test_metadata_ip_rejected_unless_allowlisted(self, monkeypatch): + monkeypatch.setenv(self.ENV, "10.20.0.0/16") + adapter = OciRegistryAdapter("https://harbor.internal") + with patch( + "prowler.providers.image.lib.registry.base.socket.getaddrinfo", + side_effect=_fake_getaddrinfo({"harbor.internal": "10.20.0.5"}), + ): + with pytest.raises(ImageRegistryAuthError, match="non-public"): + adapter._validate_outbound_url("https://169.254.169.254/latest") + + def test_private_link_from_public_origin_still_rejected(self, monkeypatch): + """Regression: the allowlist does not open ranges it does not name.""" + monkeypatch.setenv(self.ENV, "10.20.0.0/16") + adapter = OciRegistryAdapter("https://reg.example.com") + with patch( + "prowler.providers.image.lib.registry.base.socket.getaddrinfo", + side_effect=_fake_getaddrinfo({"reg.example.com": "8.8.8.8"}), + ): + with pytest.raises(ImageRegistryAuthError, match="non-public"): + adapter._validate_outbound_url("http://192.168.0.99/v2/_catalog") + + def test_local_tld_origin_enforcement_falls_back_to_allowlist(self, monkeypatch): + """Hosts without a registrable domain pass only if they resolve into the allowlist.""" + monkeypatch.setenv(self.ENV, "10.20.0.0/16") + adapter = OciRegistryAdapter("https://registry.corp.local") + with patch( + "prowler.providers.image.lib.registry.base.socket.getaddrinfo", + side_effect=_fake_getaddrinfo( + {"registry.corp.local": "10.20.2.3", "auth.corp.local": "10.20.2.4"} + ), + ): + url = adapter._validate_outbound_url("https://auth.corp.local/token") + assert url == "https://auth.corp.local/token" + + def test_malformed_allowlist_fails_loudly(self, monkeypatch): + monkeypatch.setenv(self.ENV, "10.20.0.0/16,banana") + with pytest.raises(ImageInvalidAllowedNetworksError, match="banana"): + OciRegistryAdapter("https://reg.example.com") + + def test_allowlist_logged_as_relaxed_control(self, monkeypatch, caplog): + monkeypatch.setenv(self.ENV, "10.20.0.0/16") + with caplog.at_level("WARNING"): + OciRegistryAdapter("https://reg.example.com") + assert any( + "10.20.0.0/16" in message and "SSRF" in message + for message in caplog.messages + ) + + +class TestBasicAuthFallback: + """Registries like Harbor guard /_catalog behind Basic even when /v2/ negotiates Bearer.""" + + _BEARER_CHALLENGE = ( + 'Bearer realm="https://reg.io/service/token",service="harbor-registry"' + ) + + def _harbor_responses(self): + ping = MagicMock( + status_code=401, headers={"Www-Authenticate": self._BEARER_CHALLENGE} + ) + token = MagicMock(status_code=200) + token.json.return_value = {"token": "tok"} + catalog_401 = MagicMock( + status_code=401, headers={"Www-Authenticate": 'Basic realm="harbor"'} + ) + return ping, token, catalog_401 + + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_catalog_falls_back_to_basic_when_bearer_rejected(self, mock_request): + ping, token, catalog_401 = self._harbor_responses() + catalog_ok = MagicMock(status_code=200, headers={}) + catalog_ok.json.return_value = {"repositories": ["library/debian"]} + mock_request.side_effect = [ + ping, + token, + catalog_401, # bearer without catalog scope + ping, + token, + catalog_401, # bearer retry, same result + catalog_ok, # basic fallback + ] + + adapter = OciRegistryAdapter("reg.io", username="admin", password="secret") + repos = adapter.list_repositories() + + assert repos == ["library/debian"] + assert mock_request.call_args.kwargs.get("auth") == ("admin", "secret") + + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_basic_challenge_without_credentials_still_fails(self, mock_request): + ping, token, catalog_401 = self._harbor_responses() + mock_request.side_effect = [ping, token, catalog_401, ping, token, catalog_401] + + adapter = OciRegistryAdapter("reg.io") + with pytest.raises(ImageRegistryAuthError, match="catalog listing"): + adapter.list_repositories() + + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_fallback_with_combined_multi_challenge_header(self, mock_request): + # Basic not first in the header must still trigger the fallback + ping, token, _ = self._harbor_responses() + catalog_401 = MagicMock( + status_code=401, + headers={ + "Www-Authenticate": f'{self._BEARER_CHALLENGE}, Basic realm="harbor"' + }, + ) + catalog_ok = MagicMock(status_code=200, headers={}) + catalog_ok.json.return_value = {"repositories": ["library/debian"]} + mock_request.side_effect = [ + ping, + token, + catalog_401, # bearer without catalog scope + token, # re-auth straight from the response's Bearer challenge + catalog_401, # scoped bearer retry, same result + catalog_ok, # basic fallback + ] + + adapter = OciRegistryAdapter("reg.io", username="admin", password="secret") + repos = adapter.list_repositories() + + assert repos == ["library/debian"] + assert mock_request.call_args.kwargs.get("auth") == ("admin", "secret") + + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_multi_page_catalog_falls_back_only_once(self, mock_request): + ping, token, catalog_401 = self._harbor_responses() + page1 = MagicMock( + status_code=200, + ok=True, + headers={"Link": '; rel="next"'}, + ) + page1.json.return_value = {"repositories": ["a"]} + page2 = MagicMock(status_code=200, ok=True, headers={}) + page2.json.return_value = {"repositories": ["b"]} + mock_request.side_effect = [ + ping, + token, + catalog_401, # bearer without catalog scope + ping, + token, + catalog_401, # bearer retry, same result + page1, # basic fallback succeeds -> basic mode persists + page2, # second page goes straight to basic + ] + + adapter = OciRegistryAdapter("reg.io", username="admin", password="secret") + repos = adapter.list_repositories() + + assert repos == ["a", "b"] + assert mock_request.call_count == 8 + assert adapter._basic_auth_verified is True + assert adapter._bearer_token is None + page2_call = mock_request.call_args_list[-1] + assert page2_call.kwargs.get("auth") == ("admin", "secret") + assert "Authorization" not in page2_call.kwargs.get("headers", {}) + + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_basic_fallback_not_sent_cross_origin(self, mock_request): + catalog_401 = MagicMock( + status_code=401, headers={"Www-Authenticate": 'Basic realm="other"'} + ) + mock_request.return_value = catalog_401 + + adapter = OciRegistryAdapter("reg.io", username="admin", password="secret") + adapter._bearer_token = "tok" + resp = adapter._authed_request("GET", "https://other.example.com/v2/_catalog") + + assert resp.status_code == 401 + assert all( + call.kwargs.get("auth") is None for call in mock_request.call_args_list + ) + + +class TestBearerAuthSwitch: + """Registries that negotiate Basic on /v2/ but demand Bearer on other endpoints.""" + + _BEARER_CHALLENGE = 'Bearer realm="https://reg.io/token",service="registry",scope="registry:catalog:*"' + + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_catalog_switches_to_bearer_when_basic_rejected(self, mock_request): + ping = MagicMock( + status_code=401, headers={"Www-Authenticate": 'Basic realm="registry"'} + ) + catalog_401 = MagicMock( + status_code=401, headers={"Www-Authenticate": self._BEARER_CHALLENGE} + ) + token = MagicMock(status_code=200) + token.json.return_value = {"token": "switched-tok"} + catalog_ok = MagicMock(status_code=200, headers={}) + catalog_ok.json.return_value = {"repositories": ["library/debian"]} + mock_request.side_effect = [ping, catalog_401, token, catalog_ok] + + adapter = OciRegistryAdapter("reg.io", username="admin", password="secret") + repos = adapter.list_repositories() + + assert repos == ["library/debian"] + assert adapter._bearer_token == "switched-tok" + # Token exchange carries the credentials + token_call = mock_request.call_args_list[2] + assert token_call.kwargs.get("auth") == ("admin", "secret") + assert token_call.kwargs.get("params", {}).get("scope") == "registry:catalog:*" + # The retry uses the Bearer header, not Basic + retry_call = mock_request.call_args_list[3] + assert retry_call.kwargs.get("auth") is None + assert ( + retry_call.kwargs.get("headers", {}).get("Authorization") + == "Bearer switched-tok" + ) + + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_multi_page_catalog_switches_only_once(self, mock_request): + ping = MagicMock( + status_code=401, headers={"Www-Authenticate": 'Basic realm="registry"'} + ) + catalog_401 = MagicMock( + status_code=401, headers={"Www-Authenticate": self._BEARER_CHALLENGE} + ) + token = MagicMock(status_code=200) + token.json.return_value = {"token": "switched-tok"} + page1 = MagicMock( + status_code=200, + headers={"Link": '; rel="next"'}, + ) + page1.json.return_value = {"repositories": ["a"]} + page2 = MagicMock(status_code=200, headers={}) + page2.json.return_value = {"repositories": ["b"]} + mock_request.side_effect = [ping, catalog_401, token, page1, page2] + + adapter = OciRegistryAdapter("reg.io", username="admin", password="secret") + repos = adapter.list_repositories() + + assert repos == ["a", "b"] + assert mock_request.call_count == 5 + page2_call = mock_request.call_args_list[-1] + assert ( + page2_call.kwargs.get("headers", {}).get("Authorization") + == "Bearer switched-tok" + ) + + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_anonymous_switch_to_bearer(self, mock_request): + ping = MagicMock(status_code=200) + catalog_401 = MagicMock( + status_code=401, headers={"Www-Authenticate": self._BEARER_CHALLENGE} + ) + token = MagicMock(status_code=200) + token.json.return_value = {"token": "anon-tok"} + catalog_ok = MagicMock(status_code=200, headers={}) + catalog_ok.json.return_value = {"repositories": ["public/app"]} + mock_request.side_effect = [ping, catalog_401, token, catalog_ok] + + adapter = OciRegistryAdapter("reg.io") + repos = adapter.list_repositories() + + assert repos == ["public/app"] + token_call = mock_request.call_args_list[2] + assert token_call.kwargs.get("auth") is None + + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_switch_not_attempted_cross_origin(self, mock_request): + catalog_401 = MagicMock( + status_code=401, headers={"Www-Authenticate": self._BEARER_CHALLENGE} + ) + mock_request.return_value = catalog_401 + + adapter = OciRegistryAdapter("reg.io", username="admin", password="secret") + adapter._basic_auth_verified = True + resp = adapter._authed_request("GET", "https://other.example.com/v2/_catalog") + + assert resp.status_code == 401 + assert adapter._bearer_token is None + assert mock_request.call_count == 1 + + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_switch_with_combined_multi_challenge_header(self, mock_request): + # RFC 7235: multiple challenges in one header, Basic first + combined = ( + 'Basic realm="registry", ' + 'Bearer realm="http://reg.io/token",service="registry",scope="registry:catalog:*"' + ) + ping = MagicMock( + status_code=401, headers={"Www-Authenticate": 'Basic realm="registry"'} + ) + catalog_401 = MagicMock(status_code=401, headers={"Www-Authenticate": combined}) + token = MagicMock(status_code=200) + token.json.return_value = {"token": "combined-tok"} + catalog_ok = MagicMock(status_code=200, headers={}) + catalog_ok.json.return_value = {"repositories": ["library/debian"]} + mock_request.side_effect = [ping, catalog_401, token, catalog_ok] + + adapter = OciRegistryAdapter( + "http://reg.io", username="admin", password="secret" + ) + repos = adapter.list_repositories() + + assert repos == ["library/debian"] + # The token exchange must hit the Bearer realm, not Basic's realm="registry" + token_call = mock_request.call_args_list[2] + assert token_call.args[1] == "http://reg.io/token" + assert token_call.kwargs.get("params", {}).get("scope") == "registry:catalog:*" + + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_ping_with_combined_multi_challenge_prefers_bearer(self, mock_request): + combined = 'Basic realm="registry", Bearer realm="https://auth.reg.io/token",service="registry"' + ping = MagicMock(status_code=401, headers={"Www-Authenticate": combined}) + token = MagicMock(status_code=200) + token.json.return_value = {"token": "bearer-tok"} + mock_request.side_effect = [ping, token] + + adapter = OciRegistryAdapter("reg.io", username="u", password="p") + adapter._ensure_auth() + + assert adapter._bearer_token == "bearer-tok" + token_call = mock_request.call_args_list[1] + assert token_call.args[1] == "https://auth.reg.io/token" + + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_failed_token_exchange_raises_auth_error(self, mock_request): + ping = MagicMock( + status_code=401, headers={"Www-Authenticate": 'Basic realm="registry"'} + ) + catalog_401 = MagicMock( + status_code=401, headers={"Www-Authenticate": self._BEARER_CHALLENGE} + ) + token_denied = MagicMock(status_code=401) + mock_request.side_effect = [ping, catalog_401, token_denied] + + adapter = OciRegistryAdapter("reg.io", username="admin", password="wrong") + with pytest.raises(ImageRegistryAuthError, match="bearer token"): + adapter.list_repositories() + + +class TestOciAdapterIsContainerImage: + def _adapter(self): + return OciRegistryAdapter("reg.io", token="t") + + @staticmethod + def _manifest_resp(content_type, body=None): + resp = MagicMock(status_code=200, headers={"Content-Type": content_type}) + resp.json.return_value = body or {} + return resp + + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_docker_v2_manifest_is_image(self, mock_request): + mock_request.return_value = self._manifest_resp( + "application/vnd.docker.distribution.manifest.v2+json" + ) + assert self._adapter().is_container_image("app", "1.0") is True + + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_oci_index_is_image(self, mock_request): + mock_request.return_value = self._manifest_resp( + "application/vnd.oci.image.index.v1+json" + ) + assert self._adapter().is_container_image("app", "1.0") is True + + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_oci_manifest_with_image_config_is_image(self, mock_request): + mock_request.return_value = self._manifest_resp( + "application/vnd.oci.image.manifest.v1+json", + {"config": {"mediaType": "application/vnd.oci.image.config.v1+json"}}, + ) + assert self._adapter().is_container_image("app", "1.0") is True + + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_helm_chart_is_not_image(self, mock_request): + mock_request.return_value = self._manifest_resp( + "application/vnd.oci.image.manifest.v1+json", + {"config": {"mediaType": "application/vnd.cncf.helm.config.v1+json"}}, + ) + assert self._adapter().is_container_image("charts/app", "1.0") is False + + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_artifact_type_wins_over_config(self, mock_request): + mock_request.return_value = self._manifest_resp( + "application/vnd.oci.image.manifest.v1+json", + { + "artifactType": "application/vnd.dev.cosign.artifact.sig.v1+json", + "config": {"mediaType": "application/vnd.oci.image.config.v1+json"}, + }, + ) + assert self._adapter().is_container_image("app", "sig") is False + + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_unknown_media_type_is_not_image(self, mock_request): + mock_request.return_value = self._manifest_resp( + "application/vnd.cncf.helm.chart.content.v1.tar+gzip" + ) + assert self._adapter().is_container_image("charts/app", "1.0") is False + + @patch("prowler.providers.image.lib.registry.base.time.sleep") + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_manifest_error_assumes_image(self, mock_request, _mock_sleep): + mock_request.side_effect = requests.exceptions.ConnectionError("boom") + assert self._adapter().is_container_image("app", "1.0") is True + + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_manifest_404_assumes_image(self, mock_request): + mock_request.return_value = MagicMock(status_code=404, headers={}) + assert self._adapter().is_container_image("app", "1.0") is True + + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_anonymous_auth_pings_only_once(self, mock_request): + mock_request.return_value = MagicMock(status_code=200, headers={}) + adapter = OciRegistryAdapter("reg.io") + adapter._ensure_auth() + adapter._ensure_auth(repository="app") + adapter._ensure_auth(repository="other") + assert mock_request.call_count == 1 + + +class TestOciAdapterScopedReauth: + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_401_with_challenge_reauths_with_response_scope(self, mock_request): + adapter = OciRegistryAdapter("reg.io", username="u", password="p") + adapter._bearer_token = "token-scoped-to-other-repo" + resp_401 = MagicMock( + status_code=401, + headers={ + "Www-Authenticate": 'Bearer realm="https://auth.reg.io/token",service="registry",scope="repository:myapp:pull"' + }, + ) + token_resp = MagicMock(status_code=200) + token_resp.json.return_value = {"token": "myapp-scoped-token"} + resp_200 = MagicMock(status_code=200) + mock_request.side_effect = [resp_401, token_resp, resp_200] + + result = adapter._authed_request( + "GET", "https://reg.io/v2/myapp/manifests/latest" + ) + + assert result.status_code == 200 + assert adapter._bearer_token == "myapp-scoped-token" + # Token exchange used the scope from the response challenge, no blind /v2/ ping + token_call = mock_request.call_args_list[1] + assert token_call.kwargs["params"]["scope"] == "repository:myapp:pull" + assert mock_request.call_count == 3 + + +class TestOciAdapterPickle: + def test_adapter_is_picklable_despite_auth_lock(self): + import pickle + + adapter = OciRegistryAdapter("reg.io", username="u", password="p") + restored = pickle.loads(pickle.dumps(adapter)) + assert restored._base_url == "https://reg.io" + # The lock is recreated, not carried over + assert restored._auth_lock is not adapter._auth_lock + restored._ensure_auth # attribute access must not blow up + + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_retry_uses_own_token_despite_concurrent_overwrite(self, mock_request): + adapter = OciRegistryAdapter("reg.io", username="u", password="p") + adapter._bearer_token = "stale-token" + resp_401 = MagicMock( + status_code=401, + headers={ + "Www-Authenticate": 'Bearer realm="https://auth.reg.io/token",service="registry",scope="repository:myapp:pull"' + }, + ) + token_resp = MagicMock(status_code=200) + token_resp.json.return_value = {"token": "fresh-token"} + resp_200 = MagicMock(status_code=200) + mock_request.side_effect = [resp_401, token_resp, resp_200] + + # Another worker replaces the shared token right after this request's + # token exchange + original = adapter._obtain_bearer_token + + def clobbering(challenge, repository=None): + token = original(challenge, repository) + adapter._bearer_token = "other-repo-token" + return token + + adapter._obtain_bearer_token = clobbering + + result = adapter._authed_request( + "GET", "https://reg.io/v2/myapp/manifests/latest" + ) + + assert result.status_code == 200 + retry_headers = mock_request.call_args_list[2].kwargs["headers"] + assert retry_headers["Authorization"] == "Bearer fresh-token" + + +class TestOciAdapterArtifactIndexAndCaseInsensitivity: + def _adapter(self): + return OciRegistryAdapter("reg.io", token="t") + + @staticmethod + def _manifest_resp(content_type, body=None): + resp = MagicMock(status_code=200, headers={"Content-Type": content_type}) + resp.json.return_value = body or {} + return resp + + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_artifact_index_is_not_image(self, mock_request): + # image-spec v1.1: an index can represent a non-image artifact + mock_request.return_value = self._manifest_resp( + "application/vnd.oci.image.index.v1+json", + { + "artifactType": "application/vnd.cncf.helm.config.v1+json", + "manifests": [], + }, + ) + assert self._adapter().is_container_image("charts/app", "1.0") is False + + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_multiarch_index_without_artifact_type_is_image(self, mock_request): + mock_request.return_value = self._manifest_resp( + "application/vnd.oci.image.index.v1+json", + {"schemaVersion": 2, "manifests": [{"platform": {"os": "linux"}}]}, + ) + assert self._adapter().is_container_image("app", "latest") is True + + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_mixed_case_media_type_is_normalized(self, mock_request): + # RFC 9110: type/subtype are case-insensitive + mock_request.return_value = self._manifest_resp( + "Application/vnd.OCI.Image.Manifest.v1+JSON", + {"config": {"mediaType": "application/vnd.oci.image.config.v1+json"}}, + ) + assert self._adapter().is_container_image("app", "1.0") is True + + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_mixed_case_artifact_type_still_rejected(self, mock_request): + mock_request.return_value = self._manifest_resp( + "application/vnd.oci.image.manifest.v1+json", + {"artifactType": "Application/vnd.CNCF.Helm.Config.v1+json"}, + ) + assert self._adapter().is_container_image("charts/app", "1.0") is False diff --git a/tests/providers/image/lib/registry/test_provider_registry.py b/tests/providers/image/lib/registry/test_provider_registry.py index 97901ea0c4..43c8f2df4d 100644 --- a/tests/providers/image/lib/registry/test_provider_registry.py +++ b/tests/providers/image/lib/registry/test_provider_registry.py @@ -232,3 +232,57 @@ class TestDockerHubEnumeration: for img in provider.images: assert not img.startswith("docker.io/"), f"Unexpected host prefix in {img}" assert len(provider.images) == 3 + + +class TestNonImageArtifactFiltering: + @patch("prowler.providers.image.image_provider.create_registry_adapter") + def test_cosign_tags_skipped_without_manifest_fetch(self, mock_factory): + adapter = MagicMock() + adapter.list_repositories.return_value = ["app"] + adapter.list_tags.return_value = [ + "latest", + "sha256-" + "a" * 64 + ".sig", + "sha256-" + "b" * 64 + ".att", + "sha256-" + "c" * 64 + ".sbom", + ] + adapter.is_container_image.return_value = True + mock_factory.return_value = adapter + + provider = _build_provider() + assert provider.images == ["myregistry.io/app:latest"] + adapter.is_container_image.assert_called_once_with("app", "latest") + + @patch("prowler.providers.image.image_provider.create_registry_adapter") + def test_non_image_artifacts_skipped(self, mock_factory): + adapter = MagicMock() + adapter.list_repositories.return_value = ["app", "charts/app"] + adapter.list_tags.return_value = ["1.0"] + adapter.is_container_image.side_effect = lambda repo, _tag: repo == "app" + mock_factory.return_value = adapter + + provider = _build_provider() + assert provider.images == ["myregistry.io/app:1.0"] + + @patch("prowler.providers.image.image_provider.create_registry_adapter") + def test_discovered_images_tracked_for_error_degradation(self, mock_factory): + adapter = MagicMock() + adapter.list_repositories.return_value = ["app"] + adapter.list_tags.return_value = ["latest"] + adapter.is_container_image.return_value = True + mock_factory.return_value = adapter + + provider = _build_provider(images=["nginx:latest"]) + assert "myregistry.io/app:latest" in provider._registry_discovered + assert "nginx:latest" not in provider._registry_discovered + + @patch("prowler.providers.image.image_provider.create_registry_adapter") + def test_explicit_image_also_discovered_keeps_hard_failure(self, mock_factory): + adapter = MagicMock() + adapter.list_repositories.return_value = ["myapp"] + adapter.list_tags.return_value = ["latest"] + adapter.is_container_image.return_value = True + mock_factory.return_value = adapter + + provider = _build_provider(images=["myregistry.io/myapp:latest"]) + # The user asked for it explicitly: no error degradation + assert "myregistry.io/myapp:latest" not in provider._registry_discovered diff --git a/tests/providers/m365/services/defender/defender_antiphishing_policy_configured/defender_antiphishing_policy_configured_test.py b/tests/providers/m365/services/defender/defender_antiphishing_policy_configured/defender_antiphishing_policy_configured_test.py index 8610e96769..984fa648df 100644 --- a/tests/providers/m365/services/defender/defender_antiphishing_policy_configured/defender_antiphishing_policy_configured_test.py +++ b/tests/providers/m365/services/defender/defender_antiphishing_policy_configured/defender_antiphishing_policy_configured_test.py @@ -521,3 +521,86 @@ class Test_defender_antiphishing_policy_configured: check = defender_antiphishing_policy_configured() result = check.execute() assert len(result) == 0 + + def test_preset_policy_without_rule_is_skipped(self): + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_client.audited_domain = DOMAIN + defender_client.audit_config = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_antiphishing_policy_configured.defender_antiphishing_policy_configured.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_antiphishing_policy_configured.defender_antiphishing_policy_configured import ( + defender_antiphishing_policy_configured, + ) + from prowler.providers.m365.services.defender.defender_service import ( + AntiphishingPolicy, + AntiphishingRule, + ) + + defender_client.antiphishing_policies = { + "Default": AntiphishingPolicy( + name="Default", + spoof_intelligence=True, + spoof_intelligence_action="Quarantine", + dmarc_reject_action="Quarantine", + dmarc_quarantine_action="Quarantine", + safety_tips=True, + unauthenticated_sender_action=True, + show_tag=True, + honor_dmarc_policy=True, + default=True, + ), + "Standard Preset Security Policy1663355404982": AntiphishingPolicy( + name="Standard Preset Security Policy1663355404982", + spoof_intelligence=True, + spoof_intelligence_action="Quarantine", + dmarc_reject_action="Quarantine", + dmarc_quarantine_action="Quarantine", + safety_tips=True, + unauthenticated_sender_action=True, + show_tag=True, + honor_dmarc_policy=True, + default=False, + ), + "Custom1": AntiphishingPolicy( + name="Custom1", + spoof_intelligence=True, + spoof_intelligence_action="Quarantine", + dmarc_reject_action="Quarantine", + dmarc_quarantine_action="Quarantine", + safety_tips=True, + unauthenticated_sender_action=True, + show_tag=True, + honor_dmarc_policy=True, + default=False, + ), + } + defender_client.antiphishing_rules = { + "Custom1": AntiphishingRule( + state="Enabled", + priority=1, + users=["user1@example.com"], + groups=None, + domains=None, + ) + } + + check = defender_antiphishing_policy_configured() + result = check.execute() + + assert len(result) == 2 + assert "Standard Preset Security Policy1663355404982" not in [ + finding.resource_id for finding in result + ] diff --git a/tests/providers/m365/services/defender/defender_antispam_policy_inbound_no_allowed_domains/defender_antispam_policy_inbound_no_allowed_domains_test.py b/tests/providers/m365/services/defender/defender_antispam_policy_inbound_no_allowed_domains/defender_antispam_policy_inbound_no_allowed_domains_test.py index 625fc8ded3..4e0a5482a1 100644 --- a/tests/providers/m365/services/defender/defender_antispam_policy_inbound_no_allowed_domains/defender_antispam_policy_inbound_no_allowed_domains_test.py +++ b/tests/providers/m365/services/defender/defender_antispam_policy_inbound_no_allowed_domains/defender_antispam_policy_inbound_no_allowed_domains_test.py @@ -410,3 +410,65 @@ class Test_defender_antispam_policy_inbound_no_allowed_domains: check = defender_antispam_policy_inbound_no_allowed_domains() result = check.execute() assert len(result) == 0 + + def test_preset_policy_without_rule_is_skipped(self): + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_client.audited_domain = DOMAIN + defender_client.audit_config = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_antispam_policy_inbound_no_allowed_domains.defender_antispam_policy_inbound_no_allowed_domains.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_antispam_policy_inbound_no_allowed_domains.defender_antispam_policy_inbound_no_allowed_domains import ( + defender_antispam_policy_inbound_no_allowed_domains, + ) + from prowler.providers.m365.services.defender.defender_service import ( + DefenderInboundSpamPolicy, + InboundSpamRule, + ) + + defender_client.inbound_spam_policies = [ + DefenderInboundSpamPolicy( + identity="Default", + allowed_sender_domains=[], + default=True, + ), + DefenderInboundSpamPolicy( + identity="Standard Preset Security Policy1663355404982", + allowed_sender_domains=[], + default=False, + ), + DefenderInboundSpamPolicy( + identity="Custom1", + allowed_sender_domains=[], + default=False, + ), + ] + defender_client.inbound_spam_rules = { + "Custom1": InboundSpamRule( + state="Enabled", + priority=1, + users=["user1@example.com"], + groups=None, + domains=None, + ) + } + + check = defender_antispam_policy_inbound_no_allowed_domains() + result = check.execute() + + assert len(result) == 2 + assert "Standard Preset Security Policy1663355404982" not in [ + finding.resource_id for finding in result + ] diff --git a/tests/providers/m365/services/defender/defender_malware_policy_common_attachments_filter_enabled/defender_malware_policy_common_attachments_filter_enabled_test.py b/tests/providers/m365/services/defender/defender_malware_policy_common_attachments_filter_enabled/defender_malware_policy_common_attachments_filter_enabled_test.py index adb62b213d..f2b9fdb402 100644 --- a/tests/providers/m365/services/defender/defender_malware_policy_common_attachments_filter_enabled/defender_malware_policy_common_attachments_filter_enabled_test.py +++ b/tests/providers/m365/services/defender/defender_malware_policy_common_attachments_filter_enabled/defender_malware_policy_common_attachments_filter_enabled_test.py @@ -442,3 +442,77 @@ class Test_defender_malware_policy_common_attachments_filter_enabled: check = defender_malware_policy_common_attachments_filter_enabled() result = check.execute() assert len(result) == 0 + + def test_preset_policy_without_rule_is_skipped(self): + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_client.audited_domain = DOMAIN + defender_client.audit_config = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_malware_policy_common_attachments_filter_enabled.defender_malware_policy_common_attachments_filter_enabled.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_malware_policy_common_attachments_filter_enabled.defender_malware_policy_common_attachments_filter_enabled import ( + defender_malware_policy_common_attachments_filter_enabled, + ) + from prowler.providers.m365.services.defender.defender_service import ( + MalwarePolicy, + MalwareRule, + ) + + defender_client.audit_config = { + "recommended_blocked_file_types": ["exe", "bat"] + } + defender_client.malware_policies = [ + MalwarePolicy( + identity="Default", + enable_file_filter=True, + enable_internal_sender_admin_notifications=True, + internal_sender_admin_address="admin@example.com", + file_types=["exe", "bat"], + is_default=True, + ), + MalwarePolicy( + identity="Standard Preset Security Policy1663355404982", + enable_file_filter=True, + enable_internal_sender_admin_notifications=True, + internal_sender_admin_address="admin@example.com", + file_types=["exe", "bat"], + is_default=False, + ), + MalwarePolicy( + identity="Custom1", + enable_file_filter=True, + enable_internal_sender_admin_notifications=True, + internal_sender_admin_address="admin@example.com", + file_types=["exe", "bat"], + is_default=False, + ), + ] + defender_client.malware_rules = { + "Custom1": MalwareRule( + state="Enabled", + priority=1, + users=["user1@example.com"], + groups=None, + domains=None, + ) + } + + check = defender_malware_policy_common_attachments_filter_enabled() + result = check.execute() + + assert len(result) == 2 + assert "Standard Preset Security Policy1663355404982" not in [ + finding.resource_id for finding in result + ] diff --git a/tests/providers/m365/services/defender/defender_malware_policy_comprehensive_attachments_filter_applied/defender_malware_policy_comprehensive_attachments_filter_applied_test.py b/tests/providers/m365/services/defender/defender_malware_policy_comprehensive_attachments_filter_applied/defender_malware_policy_comprehensive_attachments_filter_applied_test.py index 682f86c768..7a9cfbbd90 100644 --- a/tests/providers/m365/services/defender/defender_malware_policy_comprehensive_attachments_filter_applied/defender_malware_policy_comprehensive_attachments_filter_applied_test.py +++ b/tests/providers/m365/services/defender/defender_malware_policy_comprehensive_attachments_filter_applied/defender_malware_policy_comprehensive_attachments_filter_applied_test.py @@ -452,3 +452,77 @@ class Test_defender_malware_policy_comprehensive_attachments_filter_applied: check = defender_malware_policy_comprehensive_attachments_filter_applied() result = check.execute() assert len(result) == 0 + + def test_preset_policy_without_rule_is_skipped(self): + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_client.audited_domain = DOMAIN + defender_client.audit_config = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_malware_policy_comprehensive_attachments_filter_applied.defender_malware_policy_comprehensive_attachments_filter_applied.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_malware_policy_comprehensive_attachments_filter_applied.defender_malware_policy_comprehensive_attachments_filter_applied import ( + defender_malware_policy_comprehensive_attachments_filter_applied, + ) + from prowler.providers.m365.services.defender.defender_service import ( + MalwarePolicy, + MalwareRule, + ) + + defender_client.audit_config = { + "recommended_blocked_file_types": ["exe", "bat"] + } + defender_client.malware_policies = [ + MalwarePolicy( + identity="Default", + enable_file_filter=True, + enable_internal_sender_admin_notifications=True, + internal_sender_admin_address="admin@example.com", + file_types=["exe", "bat"], + is_default=True, + ), + MalwarePolicy( + identity="Standard Preset Security Policy1663355404982", + enable_file_filter=True, + enable_internal_sender_admin_notifications=True, + internal_sender_admin_address="admin@example.com", + file_types=["exe", "bat"], + is_default=False, + ), + MalwarePolicy( + identity="Custom1", + enable_file_filter=True, + enable_internal_sender_admin_notifications=True, + internal_sender_admin_address="admin@example.com", + file_types=["exe", "bat"], + is_default=False, + ), + ] + defender_client.malware_rules = { + "Custom1": MalwareRule( + state="Enabled", + priority=1, + users=["user1@example.com"], + groups=None, + domains=None, + ) + } + + check = defender_malware_policy_comprehensive_attachments_filter_applied() + result = check.execute() + + assert len(result) == 2 + assert "Standard Preset Security Policy1663355404982" not in [ + finding.resource_id for finding in result + ] diff --git a/tests/providers/m365/services/defender/defender_malware_policy_notifications_internal_users_malware_enabled/defender_malware_policy_notifications_internal_users_malware_enabled_test.py b/tests/providers/m365/services/defender/defender_malware_policy_notifications_internal_users_malware_enabled/defender_malware_policy_notifications_internal_users_malware_enabled_test.py index 16fe656c2b..7764da21e4 100644 --- a/tests/providers/m365/services/defender/defender_malware_policy_notifications_internal_users_malware_enabled/defender_malware_policy_notifications_internal_users_malware_enabled_test.py +++ b/tests/providers/m365/services/defender/defender_malware_policy_notifications_internal_users_malware_enabled/defender_malware_policy_notifications_internal_users_malware_enabled_test.py @@ -456,3 +456,79 @@ class Test_defender_malware_policy_notifications_internal_users_malware_enabled: ) result = check.execute() assert len(result) == 0 + + def test_preset_policy_without_rule_is_skipped(self): + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_client.audited_domain = DOMAIN + defender_client.audit_config = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_malware_policy_notifications_internal_users_malware_enabled.defender_malware_policy_notifications_internal_users_malware_enabled.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_malware_policy_notifications_internal_users_malware_enabled.defender_malware_policy_notifications_internal_users_malware_enabled import ( + defender_malware_policy_notifications_internal_users_malware_enabled, + ) + from prowler.providers.m365.services.defender.defender_service import ( + MalwarePolicy, + MalwareRule, + ) + + defender_client.audit_config = { + "recommended_blocked_file_types": ["exe", "bat"] + } + defender_client.malware_policies = [ + MalwarePolicy( + identity="Default", + enable_file_filter=True, + enable_internal_sender_admin_notifications=True, + internal_sender_admin_address="admin@example.com", + file_types=["exe", "bat"], + is_default=True, + ), + MalwarePolicy( + identity="Standard Preset Security Policy1663355404982", + enable_file_filter=True, + enable_internal_sender_admin_notifications=True, + internal_sender_admin_address="admin@example.com", + file_types=["exe", "bat"], + is_default=False, + ), + MalwarePolicy( + identity="Custom1", + enable_file_filter=True, + enable_internal_sender_admin_notifications=True, + internal_sender_admin_address="admin@example.com", + file_types=["exe", "bat"], + is_default=False, + ), + ] + defender_client.malware_rules = { + "Custom1": MalwareRule( + state="Enabled", + priority=1, + users=["user1@example.com"], + groups=None, + domains=None, + ) + } + + check = ( + defender_malware_policy_notifications_internal_users_malware_enabled() + ) + result = check.execute() + + assert len(result) == 2 + assert "Standard Preset Security Policy1663355404982" not in [ + finding.resource_id for finding in result + ] diff --git a/tests/providers/m365/services/defenderidentity/defenderidentity_health_issues_no_open/defenderidentity_health_issues_no_open_test.py b/tests/providers/m365/services/defenderidentity/defenderidentity_health_issues_no_open/defenderidentity_health_issues_no_open_test.py index 1602e9c821..a58966db9a 100644 --- a/tests/providers/m365/services/defenderidentity/defenderidentity_health_issues_no_open/defenderidentity_health_issues_no_open_test.py +++ b/tests/providers/m365/services/defenderidentity/defenderidentity_health_issues_no_open/defenderidentity_health_issues_no_open_test.py @@ -94,7 +94,7 @@ class Test_defenderidentity_health_issues_no_open: assert result[0].resource_id == "defenderIdentity" def test_both_apis_failed(self): - """Test when both sensors and health_issues APIs fail (None): expected FAIL with permission message.""" + """Test when both sensors and health_issues APIs fail (None): expected MANUAL with permission message.""" defenderidentity_client = mock.MagicMock() defenderidentity_client.audited_tenant = "audited_tenant" defenderidentity_client.audited_domain = DOMAIN @@ -120,7 +120,7 @@ class Test_defenderidentity_health_issues_no_open: result = check.execute() assert len(result) == 1 - assert result[0].status == "FAIL" + assert result[0].status == "MANUAL" assert "APIs are not accessible" in result[0].status_extended assert "SecurityIdentitiesSensors.Read.All" in result[0].status_extended assert "SecurityIdentitiesHealth.Read.All" in result[0].status_extended @@ -155,8 +155,11 @@ class Test_defenderidentity_health_issues_no_open: result = check.execute() assert len(result) == 1 - assert result[0].status == "FAIL" - assert "Cannot read health issues" in result[0].status_extended + assert result[0].status == "MANUAL" + assert ( + "Cannot evaluate Defender for Identity health issues" + in result[0].status_extended + ) assert "1 sensor(s) deployed" in result[0].status_extended assert "SecurityIdentitiesHealth.Read.All" in result[0].status_extended assert result[0].resource == {} @@ -644,3 +647,33 @@ class Test_defenderidentity_health_issues_no_open: ) assert result[0].resource_id == health_issue_id assert result[0].resource_name == health_issue_name + + def test_sensors_api_failed_with_empty_health_issues(self): + """sensors=None (API failed) + health_issues=[]: PASS cannot be trusted -> MANUAL.""" + defenderidentity_client = mock.MagicMock() + defenderidentity_client.audited_tenant = "audited_tenant" + defenderidentity_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.defenderidentity.defenderidentity_health_issues_no_open.defenderidentity_health_issues_no_open.defenderidentity_client", + new=defenderidentity_client, + ), + ): + from prowler.providers.m365.services.defenderidentity.defenderidentity_health_issues_no_open.defenderidentity_health_issues_no_open import ( + defenderidentity_health_issues_no_open, + ) + + defenderidentity_client.sensors = None + defenderidentity_client.health_issues = [] + + result = defenderidentity_health_issues_no_open().execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "sensor deployment" in result[0].status_extended + assert "SecurityIdentitiesSensors.Read.All" in result[0].status_extended diff --git a/tests/providers/m365/services/defenderxdr/defenderxdr_critical_asset_management_pending_approvals/defenderxdr_critical_asset_management_pending_approvals_test.py b/tests/providers/m365/services/defenderxdr/defenderxdr_critical_asset_management_pending_approvals/defenderxdr_critical_asset_management_pending_approvals_test.py index 5163d153dd..74a84f2cc9 100644 --- a/tests/providers/m365/services/defenderxdr/defenderxdr_critical_asset_management_pending_approvals/defenderxdr_critical_asset_management_pending_approvals_test.py +++ b/tests/providers/m365/services/defenderxdr/defenderxdr_critical_asset_management_pending_approvals/defenderxdr_critical_asset_management_pending_approvals_test.py @@ -10,7 +10,7 @@ class Test_defenderxdr_critical_asset_management_pending_approvals: """Tests for the defenderxdr_critical_asset_management_pending_approvals check.""" def test_api_failed_missing_permission(self): - """Test FAIL when API call fails (None): missing ThreatHunting.Read.All permission.""" + """Test MANUAL when API call fails (None): missing ThreatHunting.Read.All permission.""" defenderxdr_client = mock.MagicMock() defenderxdr_client.audited_tenant = "audited_tenant" defenderxdr_client.audited_domain = DOMAIN @@ -34,9 +34,10 @@ class Test_defenderxdr_critical_asset_management_pending_approvals: result = check.execute() assert len(result) == 1 - assert result[0].status == "FAIL" + assert result[0].status == "MANUAL" assert ( - "Unable to query Critical Asset Management" in result[0].status_extended + "Cannot evaluate Critical Asset Management pending approvals" + in result[0].status_extended ) assert "ThreatHunting.Read.All" in result[0].status_extended assert result[0].resource_id == "criticalAssetManagement" diff --git a/tests/providers/m365/services/defenderxdr/defenderxdr_endpoint_privileged_user_exposed_credentials/defenderxdr_endpoint_privileged_user_exposed_credentials_test.py b/tests/providers/m365/services/defenderxdr/defenderxdr_endpoint_privileged_user_exposed_credentials/defenderxdr_endpoint_privileged_user_exposed_credentials_test.py index 87db061f3c..c860eec342 100644 --- a/tests/providers/m365/services/defenderxdr/defenderxdr_endpoint_privileged_user_exposed_credentials/defenderxdr_endpoint_privileged_user_exposed_credentials_test.py +++ b/tests/providers/m365/services/defenderxdr/defenderxdr_endpoint_privileged_user_exposed_credentials/defenderxdr_endpoint_privileged_user_exposed_credentials_test.py @@ -7,7 +7,7 @@ class Test_defenderxdr_endpoint_privileged_user_exposed_credentials: """Tests for the defenderxdr_endpoint_privileged_user_exposed_credentials check.""" def test_mde_status_api_failed(self): - """Test FAIL when MDE status API call fails (None): missing permission.""" + """Test MANUAL when MDE status API call fails (None): missing permission.""" defenderxdr_client = mock.MagicMock() defenderxdr_client.audited_tenant = "audited_tenant" defenderxdr_client.audited_domain = DOMAIN @@ -32,8 +32,11 @@ class Test_defenderxdr_endpoint_privileged_user_exposed_credentials: result = check.execute() assert len(result) == 1 - assert result[0].status == "FAIL" - assert "Unable to query Microsoft Defender XDR" in result[0].status_extended + assert result[0].status == "MANUAL" + assert ( + "unable to query Microsoft Defender XDR Advanced Hunting" + in result[0].status_extended + ) assert "ThreatHunting.Read.All" in result[0].status_extended assert result[0].resource_id == "mdeStatus" @@ -103,7 +106,7 @@ class Test_defenderxdr_endpoint_privileged_user_exposed_credentials: assert result[0].resource_id == "mdeDevices" def test_exposed_credentials_query_failed(self): - """Test FAIL when exposed credentials query fails (None).""" + """Test MANUAL when exposed credentials query fails (None).""" defenderxdr_client = mock.MagicMock() defenderxdr_client.audited_tenant = "audited_tenant" defenderxdr_client.audited_domain = DOMAIN @@ -128,9 +131,9 @@ class Test_defenderxdr_endpoint_privileged_user_exposed_credentials: result = check.execute() assert len(result) == 1 - assert result[0].status == "FAIL" + assert result[0].status == "MANUAL" assert ( - "Unable to query Security Exposure Management" + "unable to query Security Exposure Management" in result[0].status_extended ) assert result[0].resource_id == "exposedCredentials" diff --git a/tests/providers/m365/services/defenderxdr/defenderxdr_service_test.py b/tests/providers/m365/services/defenderxdr/defenderxdr_service_test.py new file mode 100644 index 0000000000..78e468f2b0 --- /dev/null +++ b/tests/providers/m365/services/defenderxdr/defenderxdr_service_test.py @@ -0,0 +1,61 @@ +import asyncio +from unittest import mock + +from prowler.providers.m365.services.defenderxdr.defenderxdr_service import DefenderXDR + + +def _service_with_response(response=None, side_effect=None): + """Build a DefenderXDR instance without running __init__, with a mocked client.""" + service = DefenderXDR.__new__(DefenderXDR) + post = mock.AsyncMock(return_value=response, side_effect=side_effect) + service.client = mock.MagicMock() + service.client.security.microsoft_graph_security_run_hunting_query.post = post + return service + + +class TestRunHuntingQuery: + def test_null_response_is_unavailable_not_empty(self): + """A null response object must not be treated as a successful empty query.""" + service = _service_with_response(response=None) + results, table_not_found = asyncio.run(service._run_hunting_query("query")) + assert results is None + assert table_not_found is False + + def test_empty_results_is_confirmed_empty(self): + response = mock.MagicMock() + response.results = [] + service = _service_with_response(response=response) + results, table_not_found = asyncio.run(service._run_hunting_query("query")) + assert results == [] + assert table_not_found is False + + def test_table_not_found_is_flagged(self): + service = _service_with_response( + side_effect=Exception( + "'where' operator: Failed to resolve table or column expression named 'DeviceInfo'" + ) + ) + results, table_not_found = asyncio.run(service._run_hunting_query("query")) + assert results == [] + assert table_not_found is True + + def test_generic_error_is_unavailable(self): + service = _service_with_response(side_effect=Exception("403 Forbidden")) + results, table_not_found = asyncio.run(service._run_hunting_query("query")) + assert results is None + assert table_not_found is False + + +class TestExposedCredentials: + def test_table_not_found_propagates_as_unavailable(self): + """Security Exposure Management tables missing -> None (MANUAL), not [] (PASS).""" + service = _service_with_response( + side_effect=Exception("Failed to resolve table ExposureGraphEdges") + ) + result = asyncio.run(service._get_exposed_credentials_privileged_users()) + assert result is None + + def test_null_response_propagates_as_unavailable(self): + service = _service_with_response(response=None) + result = asyncio.run(service._get_pending_cam_approvals()) + assert result is None diff --git a/tests/providers/m365/services/entra/entra_app_registration_no_unused_privileged_permissions/entra_app_registration_no_unused_privileged_permissions_test.py b/tests/providers/m365/services/entra/entra_app_registration_no_unused_privileged_permissions/entra_app_registration_no_unused_privileged_permissions_test.py index 49a294194a..5598e37580 100644 --- a/tests/providers/m365/services/entra/entra_app_registration_no_unused_privileged_permissions/entra_app_registration_no_unused_privileged_permissions_test.py +++ b/tests/providers/m365/services/entra/entra_app_registration_no_unused_privileged_permissions/entra_app_registration_no_unused_privileged_permissions_test.py @@ -45,7 +45,7 @@ class Test_entra_app_registration_no_unused_privileged_permissions: assert result[0].resource_id == "oauthApps" def test_no_oauth_apps_none(self): - """OAuth apps is None (App Governance not enabled): expected FAIL.""" + """OAuth apps is None (App Governance not enabled): expected MANUAL.""" entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN @@ -70,10 +70,10 @@ class Test_entra_app_registration_no_unused_privileged_permissions: result = check.execute() assert len(result) == 1 - assert result[0].status == "FAIL" + assert result[0].status == "MANUAL" assert ( result[0].status_extended - == "OAuth App Governance data is unavailable. Enable App Governance in Microsoft Defender for Cloud Apps and grant ThreatHunting.Read.All to evaluate unused privileged permissions." + == "Cannot evaluate unused privileged permissions: OAuth App Governance data is unavailable. Enable App Governance in Microsoft Defender for Cloud Apps and grant the ThreatHunting.Read.All permission to the scanning application." ) assert result[0].resource == {} assert result[0].resource_name == "OAuth Applications" diff --git a/tests/providers/m365/services/entra/entra_break_glass_account_fido2_security_key_registered/entra_break_glass_account_fido2_security_key_registered_test.py b/tests/providers/m365/services/entra/entra_break_glass_account_fido2_security_key_registered/entra_break_glass_account_fido2_security_key_registered_test.py index 1147b64e8e..8db15ca447 100644 --- a/tests/providers/m365/services/entra/entra_break_glass_account_fido2_security_key_registered/entra_break_glass_account_fido2_security_key_registered_test.py +++ b/tests/providers/m365/services/entra/entra_break_glass_account_fido2_security_key_registered/entra_break_glass_account_fido2_security_key_registered_test.py @@ -67,6 +67,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered: entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.users_error = None entra_client.user_registration_details_error = None with ( @@ -105,6 +106,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered: entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.users_error = None entra_client.user_registration_details_error = None with ( @@ -144,6 +146,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered: entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.users_error = None entra_client.user_registration_details_error = None with ( @@ -181,6 +184,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered: entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.users_error = None entra_client.user_registration_details_error = None with ( @@ -232,6 +236,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered: entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.users_error = None entra_client.user_registration_details_error = None with ( @@ -280,6 +285,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered: entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.users_error = None entra_client.user_registration_details_error = None with ( @@ -327,6 +333,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered: entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.users_error = None entra_client.user_registration_details_error = None with ( @@ -375,6 +382,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered: entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.users_error = None entra_client.user_registration_details_error = None with ( @@ -430,6 +438,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered: entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.users_error = None entra_client.user_registration_details_error = None with ( @@ -466,6 +475,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered: entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.users_error = None entra_client.user_registration_details_error = None with ( @@ -512,10 +522,11 @@ class Test_entra_break_glass_account_fido2_security_key_registered: assert result[0].resource_name == "BreakGlass1" def test_user_registration_details_permission_error(self): - """Test FAIL when there's a permission error reading user registration details.""" + """Test MANUAL when there's a permission error reading user registration details.""" entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.users_error = None entra_client.user_registration_details_error = "Insufficient privileges to read user registration details. Required permission: AuditLog.Read.All" with ( @@ -551,28 +562,27 @@ class Test_entra_break_glass_account_fido2_security_key_registered: result = check.execute() assert len(result) == 1 - assert result[0].status == "FAIL" + assert result[0].status == "MANUAL" assert ( - "Cannot verify FIDO2 security key registration for break glass account BreakGlass1" + "Cannot verify FIDO2 security key registration for break glass accounts" in result[0].status_extended ) assert "AuditLog.Read.All" in result[0].status_extended - assert result[0].resource_name == "BreakGlass1" - assert result[0].resource_id == bg_user_id + assert result[0].resource_name == "Break Glass Accounts" + assert result[0].resource_id == "breakGlassAccounts" - def test_user_registration_details_permission_error_with_missing_user(self): - """Per-user emission and missing-user short-circuit on the error path. + def test_user_registration_details_permission_error_multiple_users(self): + """The registration-details error is tenant-wide: one MANUAL, not one per user. - Two break-glass user IDs are excluded from all CAPs, but only one is - present in ``entra_client.users``. With ``user_registration_details_error`` - set, the present user must produce one preventive FAIL anchored to the - real user; the missing user must be skipped by the existing - ``if not user: continue`` guard rather than crash or yield a synthetic - finding. + Two break-glass users are excluded from all CAPs and both are present in + ``entra_client.users``. With ``user_registration_details_error`` set the + check must emit a single tenant-level MANUAL finding instead of one + per break-glass account. """ entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.users_error = None entra_client.user_registration_details_error = "Insufficient privileges to read user registration details. Required permission: AuditLog.Read.All" with ( @@ -590,37 +600,40 @@ class Test_entra_break_glass_account_fido2_security_key_registered: ) policy_id = str(uuid4()) - present_user_id = str(uuid4()) - missing_user_id = str(uuid4()) + first_user_id = str(uuid4()) + second_user_id = str(uuid4()) entra_client.conditional_access_policies = { policy_id: _make_policy( policy_id, - excluded_users=[present_user_id, missing_user_id], + excluded_users=[first_user_id, second_user_id], ), } entra_client.users = { - present_user_id: User( - id=present_user_id, + first_user_id: User( + id=first_user_id, name="BreakGlass1", on_premises_sync_enabled=False, authentication_methods=[], ), - # missing_user_id intentionally absent — exercises the - # `if not user: continue` short-circuit inside the loop. + second_user_id: User( + id=second_user_id, + name="BreakGlass2", + on_premises_sync_enabled=False, + authentication_methods=[], + ), } check = entra_break_glass_account_fido2_security_key_registered() result = check.execute() - # One finding for the present user; the missing one is skipped. + # One tenant-level finding, regardless of how many break glass users exist. assert len(result) == 1 - assert result[0].status == "FAIL" + assert result[0].status == "MANUAL" assert ( - "Cannot verify FIDO2 security key registration for break glass account BreakGlass1" + "Cannot verify FIDO2 security key registration for break glass accounts" in result[0].status_extended ) assert "AuditLog.Read.All" in result[0].status_extended - assert result[0].resource == entra_client.users[present_user_id] - assert result[0].resource_name == "BreakGlass1" - assert result[0].resource_id == present_user_id + assert result[0].resource_name == "Break Glass Accounts" + assert result[0].resource_id == "breakGlassAccounts" diff --git a/tests/providers/m365/services/entra/entra_seamless_sso_disabled/entra_seamless_sso_disabled_test.py b/tests/providers/m365/services/entra/entra_seamless_sso_disabled/entra_seamless_sso_disabled_test.py index 620d54b896..879ec2c387 100644 --- a/tests/providers/m365/services/entra/entra_seamless_sso_disabled/entra_seamless_sso_disabled_test.py +++ b/tests/providers/m365/services/entra/entra_seamless_sso_disabled/entra_seamless_sso_disabled_test.py @@ -169,7 +169,7 @@ class Test_entra_seamless_sso_disabled: assert result[0].resource_name == "Cloud Only Org" def test_insufficient_permissions_error(self): - """Test FAIL when there's a permission error reading directory sync settings.""" + """Test MANUAL when there's a permission error reading directory sync settings.""" entra_client = mock.MagicMock() with ( @@ -199,7 +199,7 @@ class Test_entra_seamless_sso_disabled: result = check.execute() assert len(result) == 1 - assert result[0].status == "FAIL" + assert result[0].status == "MANUAL" assert "Cannot verify Seamless SSO status" in result[0].status_extended assert "Insufficient privileges" in result[0].status_extended assert ( @@ -272,3 +272,39 @@ class Test_entra_seamless_sso_disabled: result = check.execute() assert len(result) == 0 + + def test_hybrid_org_without_sync_settings_is_manual(self): + """Hybrid org, no error, but no directory sync settings returned -> MANUAL.""" + 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_seamless_sso_disabled.entra_seamless_sso_disabled.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_seamless_sso_disabled.entra_seamless_sso_disabled import ( + entra_seamless_sso_disabled, + ) + + entra_client.directory_sync_settings = [] + entra_client.directory_sync_error = None + entra_client.organizations = [ + Organization( + id="org1", name="Hybrid Org", on_premises_sync_enabled=True + ) + ] + + result = entra_seamless_sso_disabled().execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + "no directory synchronization settings were returned" + in result[0].status_extended + ) + assert result[0].resource_id == "org1" diff --git a/tests/providers/m365/services/entra/entra_users_mfa_capable/entra_users_mfa_capable_test.py b/tests/providers/m365/services/entra/entra_users_mfa_capable/entra_users_mfa_capable_test.py index 86e8e38f22..ec1464968a 100644 --- a/tests/providers/m365/services/entra/entra_users_mfa_capable/entra_users_mfa_capable_test.py +++ b/tests/providers/m365/services/entra/entra_users_mfa_capable/entra_users_mfa_capable_test.py @@ -12,6 +12,7 @@ class Test_entra_users_mfa_capable: entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.users_error = None entra_client.user_registration_details_error = None with ( @@ -55,6 +56,7 @@ class Test_entra_users_mfa_capable: entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.users_error = None entra_client.user_registration_details_error = None with ( @@ -98,6 +100,7 @@ class Test_entra_users_mfa_capable: entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.users_error = None entra_client.user_registration_details_error = None with ( @@ -157,6 +160,7 @@ class Test_entra_users_mfa_capable: entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.users_error = None entra_client.user_registration_details_error = None with ( @@ -196,6 +200,7 @@ class Test_entra_users_mfa_capable: entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.users_error = None entra_client.user_registration_details_error = None with ( @@ -254,6 +259,7 @@ class Test_entra_users_mfa_capable: entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.users_error = None entra_client.user_registration_details_error = None with ( @@ -293,6 +299,7 @@ class Test_entra_users_mfa_capable: entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.users_error = None entra_client.user_registration_details_error = None with ( @@ -332,6 +339,7 @@ class Test_entra_users_mfa_capable: entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.users_error = None entra_client.user_registration_details_error = None with ( @@ -372,6 +380,7 @@ class Test_entra_users_mfa_capable: entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.users_error = None entra_client.user_registration_details_error = None with ( @@ -414,6 +423,7 @@ class Test_entra_users_mfa_capable: entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.users_error = None entra_client.user_registration_details_error = None with ( @@ -459,6 +469,7 @@ class Test_entra_users_mfa_capable: entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.users_error = None entra_client.user_registration_details_error = None with ( @@ -519,6 +530,7 @@ class Test_entra_users_mfa_capable: entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.users_error = None entra_client.user_registration_details_error = None with ( @@ -559,10 +571,11 @@ class Test_entra_users_mfa_capable: assert result[0].resource_id == user_id def test_user_registration_details_permission_error(self): - """Test FAIL when there's a permission error reading user registration details.""" + """Test a single tenant-level MANUAL when user registration details cannot be read.""" entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.users_error = None entra_client.user_registration_details_error = "Insufficient privileges to read user registration details. Required permission: AuditLog.Read.All" with ( @@ -595,26 +608,27 @@ class Test_entra_users_mfa_capable: result = check.execute() assert len(result) == 1 - assert result[0].status == "FAIL" + assert result[0].status == "MANUAL" assert ( - "Cannot verify MFA capability for user Test User" + "Cannot verify MFA capability for member users" in result[0].status_extended ) assert "AuditLog.Read.All" in result[0].status_extended - assert result[0].resource == entra_client.users[user_id] - assert result[0].resource_name == "Test User" - assert result[0].resource_id == user_id + assert result[0].resource_name == "Entra Users" + assert result[0].resource_id == "users" - def test_user_registration_details_permission_error_skips_guest_and_disabled(self): - """CIS-scope skip (Guest, disabled) still applies on the permission-error path. + def test_user_registration_details_permission_error_with_mixed_users(self): + """The permission-error path emits a single tenant-level MANUAL finding. - With ``user_registration_details_error`` set, only enabled member users - should receive a per-user "Cannot verify MFA capability" FAIL — guests - and disabled members are filtered out before the error branch runs. + With ``user_registration_details_error`` set, no per-user findings are + produced (a missing permission is not a per-user security issue): a + single MANUAL finding is emitted regardless of the guest/member/disabled + mix of users in the tenant. """ entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + entra_client.users_error = None entra_client.user_registration_details_error = "Insufficient privileges to read user registration details. Required permission: AuditLog.Read.All" with ( @@ -667,15 +681,44 @@ class Test_entra_users_mfa_capable: check = entra_users_mfa_capable() result = check.execute() - # Only the enabled member should be reported — Guest and - # disabled member are skipped before the error branch. + # A single tenant-level MANUAL finding is emitted regardless of + # how many users exist; no per-user findings are produced. assert len(result) == 1 - assert result[0].status == "FAIL" + assert result[0].status == "MANUAL" assert ( - "Cannot verify MFA capability for user Enabled Member" + "Cannot verify MFA capability for member users" in result[0].status_extended ) assert "AuditLog.Read.All" in result[0].status_extended - assert result[0].resource == entra_client.users[member_id] - assert result[0].resource_name == "Enabled Member" - assert result[0].resource_id == member_id + assert result[0].resource_name == "Entra Users" + assert result[0].resource_id == "users" + + def test_users_error_reports_single_manual(self): + """Users could not be retrieved from Graph -> one tenant-level MANUAL.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + entra_client.user_registration_details_error = None + entra_client.users_error = "Insufficient privileges to read users and directory roles. Required permissions: User.Read.All, Directory.Read.All or RoleManagement.Read.Directory" + entra_client.users = {} + + 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_users_mfa_capable.entra_users_mfa_capable.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_users_mfa_capable.entra_users_mfa_capable import ( + entra_users_mfa_capable, + ) + + result = entra_users_mfa_capable().execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "Directory.Read.All" in result[0].status_extended + assert result[0].resource_name == "Entra Users" 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 e38ac5c0e6..1c98973e0a 100644 --- a/tests/providers/m365/services/entra/microsoft365_entra_service_test.py +++ b/tests/providers/m365/services/entra/microsoft365_entra_service_test.py @@ -1951,3 +1951,42 @@ class Test_Entra_Service: assert [policy.id for policy in policies] == ["policy-1", "policy-2"] with_url_mock.assert_called_once_with("next-link") next_page_builder.get.assert_awaited_once() + + +class TestGetOAuthApps: + @staticmethod + def _entra_with_hunting_response(response): + service = entra_service.Entra.__new__(entra_service.Entra) + post = AsyncMock(return_value=response) + service.client = MagicMock() + service.client.security.microsoft_graph_security_run_hunting_query.post = post + return service + + def test_null_response_returns_none_not_empty(self): + """A null hunting response must propagate as None (MANUAL), not {} (PASS).""" + service = self._entra_with_hunting_response(None) + assert asyncio.run(service._get_oauth_apps()) is None + + def test_empty_results_is_confirmed_empty(self): + response = MagicMock() + response.results = [] + service = self._entra_with_hunting_response(response) + assert asyncio.run(service._get_oauth_apps()) == {} + + +class TestGetUsersError: + def test_users_error_set_on_graph_failure(self): + """A failing /users request must set users_error and return no users.""" + service = entra_service.Entra.__new__(entra_service.Entra) + service.users_error = None + # SimpleNamespace: check tests assign attributes on the MagicMock + # class, which would shadow instance child mocks here. + service.client = SimpleNamespace( + users=SimpleNamespace(get=AsyncMock(side_effect=Exception("boom"))) + ) + + users = asyncio.run(service._get_users()) + + assert users == {} + assert service.users_error is not None + assert "Unable to retrieve users from Microsoft Graph" in service.users_error diff --git a/tests/providers/m365/services/exchange/exchange_shared_mailbox_sign_in_disabled/exchange_shared_mailbox_sign_in_disabled_test.py b/tests/providers/m365/services/exchange/exchange_shared_mailbox_sign_in_disabled/exchange_shared_mailbox_sign_in_disabled_test.py index 7cd9191049..18e921ec04 100644 --- a/tests/providers/m365/services/exchange/exchange_shared_mailbox_sign_in_disabled/exchange_shared_mailbox_sign_in_disabled_test.py +++ b/tests/providers/m365/services/exchange/exchange_shared_mailbox_sign_in_disabled/exchange_shared_mailbox_sign_in_disabled_test.py @@ -11,6 +11,8 @@ class Test_exchange_shared_mailbox_sign_in_disabled: exchange_client.shared_mailboxes = [] entra_client = mock.MagicMock() + + entra_client.users_error = None entra_client.users = {} with ( @@ -80,6 +82,7 @@ class Test_exchange_shared_mailbox_sign_in_disabled: account_enabled=False, ) entra_client = mock.MagicMock() + entra_client.users_error = None entra_client.users = { "12345678-1234-1234-1234-123456789012": entra_user, } @@ -143,6 +146,7 @@ class Test_exchange_shared_mailbox_sign_in_disabled: account_enabled=True, ) entra_client = mock.MagicMock() + entra_client.users_error = None entra_client.users = { "87654321-4321-4321-4321-210987654321": entra_user, } @@ -199,6 +203,8 @@ class Test_exchange_shared_mailbox_sign_in_disabled: exchange_client.shared_mailboxes = [shared_mailbox] entra_client = mock.MagicMock() + + entra_client.users_error = None entra_client.users = {} with mock.patch( @@ -209,10 +215,10 @@ class Test_exchange_shared_mailbox_sign_in_disabled: result = check.execute() assert len(result) == 1 - assert result[0].status == "FAIL" + assert result[0].status == "MANUAL" assert ( result[0].status_extended - == "Shared mailbox orphan@contoso.com could not be found in Entra ID for verification." + == "Cannot verify sign-in status for shared mailbox orphan@contoso.com: the user could not be resolved in Entra ID." ) assert result[0].resource_name == "Orphan Mailbox" assert result[0].resource_id == "00000000-0000-0000-0000-000000000000" @@ -284,6 +290,8 @@ class Test_exchange_shared_mailbox_sign_in_disabled: ) entra_client = mock.MagicMock() + + entra_client.users_error = None entra_client.users = { "11111111-1111-1111-1111-111111111111": user_disabled, "22222222-2222-2222-2222-222222222222": user_enabled, @@ -310,8 +318,62 @@ class Test_exchange_shared_mailbox_sign_in_disabled: == "Shared mailbox insecure@contoso.com has sign-in enabled." ) - assert result[2].status == "FAIL" + assert result[2].status == "MANUAL" assert ( result[2].status_extended - == "Shared mailbox unknown@contoso.com could not be found in Entra ID for verification." + == "Cannot verify sign-in status for shared mailbox unknown@contoso.com: the user could not be resolved in Entra ID." ) + + def test_users_error_reports_single_tenant_manual(self): + """Entra users collection failed -> one tenant-level MANUAL, not one per mailbox.""" + from prowler.providers.m365.services.exchange.exchange_service import ( + SharedMailbox, + ) + + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_client.audited_domain = DOMAIN + exchange_client.shared_mailboxes = [ + SharedMailbox( + name=f"Mailbox {i}", + identity=f"mailbox{i}", + user_principal_name=f"mailbox{i}@contoso.com", + external_directory_object_id=f"00000000-0000-0000-0000-00000000000{i}", + ) + for i in range(2) + ] + + entra_client = mock.MagicMock() + entra_client.users_error = "Insufficient privileges to read users and directory roles. Required permissions: User.Read.All, Directory.Read.All or RoleManagement.Read.Directory" + entra_client.users = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_shared_mailbox_sign_in_disabled.exchange_shared_mailbox_sign_in_disabled.exchange_client", + new=exchange_client, + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_shared_mailbox_sign_in_disabled.exchange_shared_mailbox_sign_in_disabled.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.exchange.exchange_shared_mailbox_sign_in_disabled.exchange_shared_mailbox_sign_in_disabled import ( + exchange_shared_mailbox_sign_in_disabled, + ) + + result = exchange_shared_mailbox_sign_in_disabled().execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + "Cannot verify sign-in status for shared mailboxes" + in result[0].status_extended + ) + assert result[0].resource_name == "Shared Mailboxes" diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index c3c655e442..8687ddde54 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -4,6 +4,44 @@ All notable changes to the **Prowler UI** are documented in this file. +## [1.42.0] (Prowler v5.42.0) + +### 🚀 Added + +- PostHog Toolbar support in development with separate ingestion and app hosts [(#12582)](https://github.com/prowler-cloud/prowler/pull/12582) + +### 🐞 Fixed + +- Scan Jobs onboarding tour no longer targets an unmounted In Progress row from other tabs [(#12705)](https://github.com/prowler-cloud/prowler/pull/12705) +- Integration connection test polling now waits up to ~3 minutes instead of ~57 seconds before giving up, so it no longer reports a false failure on slower checks (e.g. Jira accounts with many projects) that were still going to succeed [(#12742)](https://github.com/prowler-cloud/prowler/pull/12742) +- Scans page filter widths and action button styling, with Launch Scan and Import Findings grouped beside the tabs and sized consistently with Configure Mutelist [(#12781)](https://github.com/prowler-cloud/prowler/pull/12781) + +### 🔐 Security + +- `nanoid` to 5.1.16, `js-yaml` to 4.3.1 and `postcss` to 8.5.23, plus transitive `hono`, `@hono/node-server`, `browserslist`, `qs`, `dompurify`, `brace-expansion`, `fast-uri`, `ip-address`, `mermaid`, `body-parser` and `@humanfs/node` to patched versions, resolving 40 npm audit advisories (21 high, 15 moderate, 4 low) [(#12758)](https://github.com/prowler-cloud/prowler/pull/12758) +- `next` to 16.3.3, patching an unauthenticated remote code execution in the Image Optimization API when AVIF files are used (GHSA-2xp9-vwfh-vxw4) [(#12778)](https://github.com/prowler-cloud/prowler/pull/12778) +- `sharp` to 0.35.4, patching two libheif vulnerabilities reachable through image decoding (GHSA-rgj7-g3m4-5g8c) [(#12778)](https://github.com/prowler-cloud/prowler/pull/12778) + +--- + +## [1.41.0] (Prowler v5.41.0) + +### 🚀 Added + +- Finding-report imports from Scans for Cloud and Private Cloud deployments [(#12554)](https://github.com/prowler-cloud/prowler/pull/12554) +- Slack integration: the connection check leaves its result on the card instead of only in a toast, naming the channel Slack refused or the channels it reached (Prowler Cloud only) [(#12677)](https://github.com/prowler-cloud/prowler/pull/12677) + +### 🔄 Changed + +- Slack integration: the bot is referred to as `@Prowler Cloud`, identifiers are set as inline code, and the private-channel hint links to its docs (Prowler Cloud only) [(#12677)](https://github.com/prowler-cloud/prowler/pull/12677) +- Slack integration: a selected private channel carries the same visible `Private` badge as the channel list, replacing the lock icon on the chip (Prowler Cloud only) [(#12677)](https://github.com/prowler-cloud/prowler/pull/12677) + +### 🐞 Fixed + +- Cached permissions now refresh from `/users/me?include=roles` after access token rotation [(#12640)](https://github.com/prowler-cloud/prowler/pull/12640) + +--- + ## [1.40.0] (Prowler v5.40.0) ### 🚀 Added diff --git a/ui/Dockerfile b/ui/Dockerfile index a7ff72e65f..22149a9fa9 100644 --- a/ui/Dockerfile +++ b/ui/Dockerfile @@ -100,6 +100,9 @@ ENV HOSTNAME="0.0.0.0" # - required: UI_API_BASE_URL, AUTH_URL, AUTH_SECRET (missing ⇒ fail fast at boot) # - optional: UI_API_DOCS_URL # - optional: UI_CLOUD_ENABLED ("true" only in Prowler Cloud deployments) +# - optional: UI_SELF_REGISTRATION_ENABLED (Prowler Cloud only; "false" hides sign-up, invited users can still register) +# - optional: UI_REGISTRY_ENABLED ("true" only after the Registry dependency, +# Cloud role grant, and controlled acceptance are ready; unset/false hides Registry) # - gated integrations (load only when *_ENABLED="true"; the value is then # required or boot fails). Their legacy names (NEXT_PUBLIC_SENTRY_*, # NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID, POSTHOG_KEY/HOST) still work: diff --git a/ui/__tests__/msw/handlers/ingestions.fixtures.ts b/ui/__tests__/msw/handlers/ingestions.fixtures.ts new file mode 100644 index 0000000000..25d7b16719 --- /dev/null +++ b/ui/__tests__/msw/handlers/ingestions.fixtures.ts @@ -0,0 +1,74 @@ +export const INGESTION_ID = "ingestion-123"; + +export interface IngestionFixture { + id: string; + totalRecords: number; + processedRecords: number; + invalidRecords: number; +} + +export const INGESTION_REJECTION = { + INVALID_REPORT: "invalid-report", + SUBSCRIPTION_REQUIRED: "subscription-required", + PERMISSION_DENIED: "permission-denied", + FILE_TOO_LARGE: "file-too-large", + RATE_LIMITED: "rate-limited", + UNEXPECTED: "unexpected", +} as const; + +export type IngestionRejection = + (typeof INGESTION_REJECTION)[keyof typeof INGESTION_REJECTION]; + +export interface IngestionRejectionFixture { + status: number; + message: string; +} + +export const ingestionRejectionFixture = ( + rejection: IngestionRejection, +): IngestionRejectionFixture => { + const fixtures = { + [INGESTION_REJECTION.INVALID_REPORT]: { + status: 400, + message: "The report is not a valid Prowler OCSF finding report.", + }, + [INGESTION_REJECTION.SUBSCRIPTION_REQUIRED]: { + status: 402, + message: "A Prowler Cloud subscription is required to import findings.", + }, + [INGESTION_REJECTION.PERMISSION_DENIED]: { + status: 403, + message: "You do not have permission to import findings.", + }, + [INGESTION_REJECTION.FILE_TOO_LARGE]: { + status: 413, + message: "The selected file exceeds the allowed upload size.", + }, + [INGESTION_REJECTION.RATE_LIMITED]: { + status: 429, + message: "Too many import requests. Please try again shortly.", + }, + [INGESTION_REJECTION.UNEXPECTED]: { + status: 500, + message: "Unable to start the import. Please try again.", + }, + } as const; + + return fixtures[rejection]; +}; + +export const ingestionFixture = (): IngestionFixture => ({ + id: INGESTION_ID, + totalRecords: 3, + processedRecords: 3, + invalidRecords: 1, +}); + +// Stopped partway: every record read is accounted for, so the unprocessed ones +// are reported as invalid. +export const partiallyProcessedIngestionFixture = (): IngestionFixture => ({ + id: INGESTION_ID, + totalRecords: 5, + processedRecords: 3, + invalidRecords: 2, +}); diff --git a/ui/__tests__/msw/handlers/ingestions.ts b/ui/__tests__/msw/handlers/ingestions.ts new file mode 100644 index 0000000000..45a61360e6 --- /dev/null +++ b/ui/__tests__/msw/handlers/ingestions.ts @@ -0,0 +1,100 @@ +import { delay, http, HttpResponse } from "msw"; + +import type { + IngestionFixture, + IngestionRejectionFixture, +} from "./ingestions.fixtures"; + +const API = "/api/ingestions"; + +// Counters stay zero until the job reaches a terminal status; `failed` still +// reports progress, which is what tells a partial import from one that landed nothing. +const ingestionResponse = ( + fixture: IngestionFixture, + status: "pending" | "processing" | "completed" | "failed", +) => { + const terminal = status === "completed" || status === "failed"; + return { + data: { + id: fixture.id, + status, + totalRecords: fixture.totalRecords, + processedRecords: terminal ? fixture.processedRecords : 0, + invalidRecords: terminal ? fixture.invalidRecords : 0, + }, + }; +}; + +interface IngestionHandlerOptions { + uploadRejection?: IngestionRejectionFixture; + uploadDelayMs?: number; + statusErrorAt?: number; + statusDelayMs?: number; + statusResponseGate?: Promise; + statusSequence?: Array<"processing" | "completed" | "failed">; + onStatusRequest?: (inFlight: number) => void; +} + +export const handlersForIngestion = ( + fixture: IngestionFixture, + { + uploadRejection, + uploadDelayMs, + statusErrorAt, + statusDelayMs, + statusResponseGate, + statusSequence, + onStatusRequest, + }: IngestionHandlerOptions = {}, +) => { + let statusRequestCount = 0; + let inFlightStatusRequests = 0; + + return [ + http.post(API, async ({ request }) => { + const formData = await request.formData(); + if (uploadDelayMs) await delay(uploadDelayMs); + if (!(formData.get("file") instanceof File)) { + return HttpResponse.json( + { error: "A file is required." }, + { status: 400 }, + ); + } + + if (uploadRejection) { + return HttpResponse.json( + { error: uploadRejection.message }, + { status: uploadRejection.status }, + ); + } + + return HttpResponse.json(ingestionResponse(fixture, "pending"), { + status: 202, + }); + }), + http.get(`${API}/:id`, async ({ params }) => { + if (params.id !== fixture.id) { + return HttpResponse.json({ error: "Not found." }, { status: 404 }); + } + + statusRequestCount += 1; + inFlightStatusRequests += 1; + onStatusRequest?.(inFlightStatusRequests); + if (statusDelayMs) await delay(statusDelayMs); + if (statusResponseGate) await statusResponseGate; + inFlightStatusRequests -= 1; + onStatusRequest?.(inFlightStatusRequests); + if (statusRequestCount === statusErrorAt) { + return HttpResponse.json( + { error: "Unable to retrieve the import status. Please try again." }, + { status: 503 }, + ); + } + + const status = + statusSequence?.[statusRequestCount - 1] ?? + (statusRequestCount === 1 ? "processing" : "completed"); + return HttpResponse.json(ingestionResponse(fixture, status)); + }), + ]; +}; diff --git a/ui/__tests__/msw/handlers/slack.fixtures.ts b/ui/__tests__/msw/handlers/slack.fixtures.ts index bdb8e3220a..ec267fed5b 100644 --- a/ui/__tests__/msw/handlers/slack.fixtures.ts +++ b/ui/__tests__/msw/handlers/slack.fixtures.ts @@ -73,7 +73,7 @@ export interface SlackConnectionFixture { export interface SlackChannelFixture { id: string; name: string; - /** Private channels are listed only where `@Prowler` has been invited. */ + /** Private channels are listed only where `@Prowler Cloud` has been invited. */ isPrivate: boolean; } @@ -332,7 +332,7 @@ export const SLACK_CHANNEL_NOT_FOUND_REFUSAL: SlackRefusalFixture = { /** * The channel is fine, the Prowler app is simply not in it — fixed with - * `/invite @Prowler`. Identical `detail` to the refusal above, deliberately. + * `/invite @Prowler Cloud`. Identical `detail` to the refusal above, deliberately. */ export const SLACK_NOT_IN_CHANNEL_REFUSAL: SlackRefusalFixture = { status: 400, diff --git a/ui/actions/auth/auth.test.ts b/ui/actions/auth/auth.test.ts index 9b8b44e05b..adf04ebcf1 100644 --- a/ui/actions/auth/auth.test.ts +++ b/ui/actions/auth/auth.test.ts @@ -23,7 +23,7 @@ vi.mock("@/lib/sentry-breadcrumbs", () => ({ import { createNewUser, getUserByMe } from "./auth"; -const userMeResponse = (roleAttributes: Record) => ({ +const userMeResponse = (roleAttributes: Record) => ({ data: { type: "users", id: "019b1234-5678-7abc-9def-0123456789ab", @@ -43,7 +43,7 @@ const userMeResponse = (roleAttributes: Record) => ({ ], }); -const mockUserMe = (roleAttributes: Record) => { +const mockUserMe = (roleAttributes: Record) => { fetchMock.mockResolvedValue( new Response(JSON.stringify(userMeResponse(roleAttributes)), { status: 200, @@ -132,6 +132,29 @@ describe("auth actions", () => { expect(requestUrl.searchParams.get("utm_source")).toBe("blackhat"); }); + it("should carry manage_ingestions into the session permissions", async () => { + // Given + mockUserMe({ manage_ingestions: true }); + + // When + const result = await getUserByMe("access-token"); + + // Then + expect(result.permissions.manage_ingestions).toBe(true); + }); + + it("should default manage_ingestions to false when the role omits it", async () => { + // Given + mockUserMe({ manage_scans: true }); + + // When + const result = await getUserByMe("access-token"); + + // Then + expect(result.permissions.manage_ingestions).toBe(false); + expect(result.permissions.manage_scans).toBe(true); + }); + it("should carry manage_lighthouse_ai_configuration into the session permissions", async () => { // Given mockUserMe({ manage_lighthouse_ai_configuration: true }); @@ -154,4 +177,120 @@ describe("auth actions", () => { expect(result.permissions.manage_lighthouse_ai_configuration).toBe(false); expect(result.permissions.manage_users).toBe(true); }); + + it("should carry an exact manage_registry permission into the session", async () => { + // Given + mockUserMe({ manage_registry: true }); + + // When + const result = await getUserByMe("access-token"); + + // Then + expect(result.permissions.manage_registry).toBe(true); + }); + + it.each([undefined, "true", "TRUE", 1])( + "should deny a malformed manage_registry value of %j", + async (manageRegistry) => { + // Given + mockUserMe({ manage_registry: manageRegistry }); + + // When + const result = await getUserByMe("access-token"); + + // Then + expect(result.permissions.manage_registry).toBe(false); + }, + ); + it("should forward an abort signal when loading the current user", async () => { + // Given + mockUserMe({ manage_users: true }); + const abortController = new AbortController(); + + // When + await getUserByMe("access-token", abortController.signal); + + // Then + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/api/v1/users/me?include=roles", + expect.objectContaining({ signal: abortController.signal }), + ); + }); + + it.each([ + { + status: 401, + detail: "Rejected by API", + message: "Invalid or expired token", + }, + { + status: 403, + detail: "Database password: super-secret", + message: "Access denied", + }, + { status: 404, detail: "Rejected by API", message: "User not found" }, + ])( + "should preserve a $status status when loading the current user fails", + async ({ status, detail, message }) => { + // Given + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ errors: [{ detail }] }), { status }), + ); + + // When + const result = getUserByMe("access-token"); + + // Then + await expect(result).rejects.toMatchObject({ message, status }); + }, + ); + + it("should preserve a 401 status when the error body is not JSON", async () => { + // Given + fetchMock.mockResolvedValue(new Response("Unauthorized", { status: 401 })); + + // When + const result = getUserByMe("access-token"); + + // Then + await expect(result).rejects.toMatchObject({ + message: "Invalid or expired token", + status: 401, + }); + }); + + it("should preserve a 403 status when the error body is not JSON", async () => { + // Given + fetchMock.mockResolvedValue(new Response("Forbidden", { status: 403 })); + + // When + const result = getUserByMe("access-token"); + + // Then + await expect(result).rejects.toMatchObject({ + message: "Access denied", + status: 403, + }); + }); + + it("should not expose upstream details for unexpected errors", async () => { + // Given + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + errors: [{ detail: "Database password: super-secret" }], + }), + { status: 500 }, + ), + ); + + // When + const result = getUserByMe("access-token"); + + // Then + await expect(result).rejects.toMatchObject({ + message: "Unable to load user", + status: 500, + }); + }); }); diff --git a/ui/actions/auth/auth.ts b/ui/actions/auth/auth.ts index 646c3d1426..0cd5df4d94 100644 --- a/ui/actions/auth/auth.ts +++ b/ui/actions/auth/auth.ts @@ -4,6 +4,7 @@ import { AuthError } from "next-auth"; import { signIn, signOut } from "@/auth.config"; import { apiBaseUrl } from "@/lib"; +import { fetchCurrentUser } from "@/lib/auth/current-user"; import { addAuthEvent } from "@/lib/sentry-breadcrumbs"; import type { UtmParams } from "@/lib/utm"; import type { SignInFormData, SignUpFormData } from "@/types"; @@ -140,62 +141,19 @@ export const getToken = async (formData: SignInFormData) => { } }; -export const getUserByMe = async (accessToken: string) => { - const url = new URL(`${apiBaseUrl}/users/me?include=roles`); +export const getUserByMe = async ( + accessToken: string, + signal?: AbortSignal, +) => { + const currentUser = await fetchCurrentUser(accessToken, { signal }); - try { - const response = await fetch(url.toString(), { - method: "GET", - headers: { - Accept: "application/vnd.api+json", - Authorization: `Bearer ${accessToken}`, - }, - }); - - const parsedResponse = await response.json(); - if (!response.ok) { - // Handle different HTTP error codes - switch (response.status) { - case 401: - throw new Error("Invalid or expired token"); - case 403: - throw new Error(parsedResponse.errors?.[0]?.detail); - case 404: - throw new Error("User not found"); - default: - throw new Error( - parsedResponse.errors?.[0]?.detail || "Unknown error", - ); - } - } - - const userRole = parsedResponse.included?.find( - (item: any) => item.type === "roles", - ); - - const permissions = { - manage_users: userRole.attributes.manage_users || false, - manage_account: userRole.attributes.manage_account || false, - manage_providers: userRole.attributes.manage_providers || false, - manage_scans: userRole.attributes.manage_scans || false, - manage_integrations: userRole.attributes.manage_integrations || false, - manage_billing: userRole.attributes.manage_billing || false, - manage_alerts: userRole.attributes.manage_alerts || false, - manage_lighthouse_ai_configuration: - userRole.attributes.manage_lighthouse_ai_configuration || false, - unlimited_visibility: userRole.attributes.unlimited_visibility || false, - }; - - return { - name: parsedResponse.data.attributes.name, - email: parsedResponse.data.attributes.email, - company: parsedResponse.data.attributes.company_name, - dateJoined: parsedResponse.data.attributes.date_joined, - permissions, - }; - } catch (error: any) { - throw new Error(error.message || "Network error or server unreachable"); - } + return { + name: currentUser.name, + email: currentUser.email, + company: currentUser.company, + dateJoined: currentUser.dateJoined, + permissions: currentUser.permissions, + }; }; export async function logOut() { diff --git a/ui/actions/integrations/index.ts b/ui/actions/integrations/index.ts index 1e05de036c..6ce0b274e4 100644 --- a/ui/actions/integrations/index.ts +++ b/ui/actions/integrations/index.ts @@ -2,7 +2,7 @@ export { createIntegration, deleteIntegration, getIntegrations, - pollConnectionTestStatus, + revalidateIntegrationConnectionPages, testIntegrationConnection, updateIntegration, } from "./integrations"; diff --git a/ui/actions/integrations/integrations.test.ts b/ui/actions/integrations/integrations.test.ts new file mode 100644 index 0000000000..f65def1442 --- /dev/null +++ b/ui/actions/integrations/integrations.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { fetchMock, revalidatePathMock } = vi.hoisted(() => ({ + fetchMock: vi.fn(), + revalidatePathMock: vi.fn(), +})); + +vi.mock("next/cache", () => ({ + revalidatePath: revalidatePathMock, +})); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", + getAuthHeaders: vi.fn().mockResolvedValue({ Authorization: "Bearer token" }), + parseStringify: (value: unknown) => JSON.parse(JSON.stringify(value)), +})); + +vi.mock("@/lib/server-actions-helper", () => ({ + handleApiError: () => ({ error: "An error occurred" }), + handleApiResponse: vi.fn(), +})); + +import { + revalidateIntegrationConnectionPages, + testIntegrationConnection, +} from "./integrations"; + +describe("testIntegrationConnection", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ data: { id: "task-1", type: "tasks" } }), { + status: 202, + }), + ); + }); + + it("returns the task immediately for shared background tracking", async () => { + // When + const response = await testIntegrationConnection("jira-1"); + + // Then + expect(response).toEqual({ + success: true, + message: "Connection test started. It may take some time to complete.", + taskId: "task-1", + data: { data: { id: "task-1", type: "tasks" } }, + }); + }); + + it("revalidates every integration page through one shared action", async () => { + // When + await revalidateIntegrationConnectionPages(); + + // Then + expect(revalidatePathMock.mock.calls).toEqual([ + ["/integrations/amazon-s3"], + ["/integrations/aws-security-hub"], + ["/integrations/jira"], + ["/integrations/slack"], + ]); + }); +}); diff --git a/ui/actions/integrations/integrations.ts b/ui/actions/integrations/integrations.ts index 4ac6300a3c..e90a4de502 100644 --- a/ui/actions/integrations/integrations.ts +++ b/ui/actions/integrations/integrations.ts @@ -2,25 +2,19 @@ import { revalidatePath } from "next/cache"; -import { pollTaskUntilSettled } from "@/actions/task/poll"; import { apiBaseUrl, getAuthHeaders, parseStringify } from "@/lib"; import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper"; -import { IntegrationType } from "@/types/integrations"; -import type { TaskState } from "@/types/tasks"; +import type { + IntegrationConnectionTestResponse, + IntegrationType, +} from "@/types/integrations"; -type TaskStartResponse = { - data: { id: string; type: "tasks" }; -}; - -type TestConnectionResponse = { - success: boolean; - message?: string; - taskId?: string; - data?: TaskStartResponse; - error?: string; - /** The id of the channel a channel-level failure named, when it named one. */ - failedChannelId?: string | null; -}; +const INTEGRATION_CONNECTION_PATHS = [ + "/integrations/amazon-s3", + "/integrations/aws-security-hub", + "/integrations/jira", + "/integrations/slack", +] as const; export const getIntegrations = async (searchParams?: URLSearchParams) => { const headers = await getAuthHeaders({ contentType: false }); @@ -266,59 +260,9 @@ export const deleteIntegration = async ( } }; -type ConnectionTaskResult = { - connected?: boolean; - error?: string | null; - // The failing channel's id, or null when the failure names no channel. - channel?: string | null; -}; - -type PollConnectionResult = - | { - success: true; - message: string; - taskState: TaskState; - result: ConnectionTaskResult | undefined; - } - | { - success: false; - message: string; - taskState?: TaskState; - result?: ConnectionTaskResult; - } - | { error: string }; - -const pollTaskUntilComplete = async ( - taskId: string, -): Promise => { - const settled = await pollTaskUntilSettled(taskId, { - maxAttempts: 20, - delayMs: 3000, - }); - - if (!settled.ok) { - return { error: settled.error }; - } - - const taskState = settled.state; - const result = settled.result; - - const isSuccessful = - taskState === "completed" && - result?.connected === true && - result?.error === null; - - const message = isSuccessful - ? "Connection test completed successfully." - : result?.error || "Connection test failed."; - - return { success: isSuccessful, message, taskState, result }; -}; - export const testIntegrationConnection = async ( id: string, - waitForCompletion = true, -): Promise => { +): Promise => { const headers = await getAuthHeaders({ contentType: true }); const url = new URL(`${apiBaseUrl}/integrations/${id}/connection`); @@ -330,43 +274,13 @@ export const testIntegrationConnection = async ( const taskId = data?.data?.id; if (taskId) { - // If waitForCompletion is false, return immediately with task started status - if (!waitForCompletion) { - return { - success: true, - message: - "Connection test started. It may take some time to complete.", - taskId, - data: parseStringify(data), - }; - } - - // Poll the task until completion - const pollResult = await pollTaskUntilComplete(taskId); - - revalidatePath("/integrations/amazon-s3"); - revalidatePath("/integrations/aws-security-hub"); - revalidatePath("/integrations/jira"); - revalidatePath("/integrations/slack"); - - if ("error" in pollResult) { - return { success: false, error: pollResult.error }; - } - - if (pollResult.success) { - return { - success: true, - message: - pollResult.message || "Connection test completed successfully!", - data: parseStringify(data), - }; - } else { - return { - success: false, - error: pollResult.message || "Connection test failed.", - failedChannelId: pollResult.result?.channel ?? null, - }; - } + return { + success: true, + message: + "Connection test started. It may take some time to complete.", + taskId, + data: parseStringify(data), + }; } else { return { success: false, @@ -386,35 +300,8 @@ export const testIntegrationConnection = async ( } }; -export const pollConnectionTestStatus = async ( - taskId: string, -): Promise => { - try { - const pollResult = await pollTaskUntilComplete(taskId); - - revalidatePath("/integrations/amazon-s3"); - revalidatePath("/integrations/aws-security-hub"); - revalidatePath("/integrations/jira"); - revalidatePath("/integrations/slack"); - - if ("error" in pollResult) { - return { success: false, error: pollResult.error }; - } - - if (pollResult.success) { - return { - success: true, - message: - pollResult.message || "Connection test completed successfully!", - }; - } else { - return { - success: false, - error: pollResult.message || "Connection test failed.", - failedChannelId: pollResult.result?.channel ?? null, - }; - } - } catch (_error) { - return { success: false, error: "Failed to check connection test status." }; +export const revalidateIntegrationConnectionPages = async (): Promise => { + for (const path of INTEGRATION_CONNECTION_PATHS) { + revalidatePath(path); } }; diff --git a/ui/actions/invitations/invitation.test.ts b/ui/actions/invitations/invitation.test.ts new file mode 100644 index 0000000000..4f9635a590 --- /dev/null +++ b/ui/actions/invitations/invitation.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { fetchMock, getAuthHeadersMock, handleApiResponseMock } = vi.hoisted( + () => ({ + fetchMock: vi.fn(), + getAuthHeadersMock: vi.fn(), + handleApiResponseMock: vi.fn(), + }), +); + +vi.mock("next/cache", () => ({ + revalidatePath: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + redirect: vi.fn(), +})); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", + getAuthHeaders: getAuthHeadersMock, +})); + +vi.mock("@/lib/server-actions-helper", () => ({ + handleApiError: vi.fn(), + handleApiResponse: handleApiResponseMock, +})); + +import { sendInvite } from "./invitation"; + +const inviteFormData = (source?: string) => { + const formData = new FormData(); + formData.append("email", "teammate@company.com"); + formData.append("role", "22222222-2222-4222-8222-222222222222"); + if (source) formData.append("source", source); + return formData; +}; + +describe("sendInvite", () => { + beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockReset().mockResolvedValue(new Response("{}")); + getAuthHeadersMock.mockReset().mockResolvedValue({}); + handleApiResponseMock + .mockReset() + .mockResolvedValue({ data: { id: "inv" } }); + }); + + it("posts to the invitations endpoint without a source by default", async () => { + // When + await sendInvite(inviteFormData()); + + // Then + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/api/v1/tenants/invitations", + expect.objectContaining({ method: "POST" }), + ); + }); + + it("forwards the invitation source as a query param", async () => { + // When + await sendInvite(inviteFormData("onboarding")); + + // Then + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/api/v1/tenants/invitations?source=onboarding", + expect.objectContaining({ method: "POST" }), + ); + const body = JSON.parse(fetchMock.mock.calls[0]?.[1]?.body as string); + expect(body.data.attributes).toEqual({ email: "teammate@company.com" }); + }); +}); diff --git a/ui/actions/invitations/invitation.ts b/ui/actions/invitations/invitation.ts index 72f591705a..620a6e1a1b 100644 --- a/ui/actions/invitations/invitation.ts +++ b/ui/actions/invitations/invitation.ts @@ -51,7 +51,12 @@ export const sendInvite = async (formData: FormData) => { const email = formData.get("email"); const role = formData.get("role"); + const source = formData.get("source"); const url = new URL(`${apiBaseUrl}/tenants/invitations`); + // Origin of the invitation (e.g. `onboarding`); the API may record it. + if (typeof source === "string" && source) { + url.searchParams.set("source", source); + } const body = JSON.stringify({ data: { diff --git a/ui/actions/onboarding/invite.ts b/ui/actions/onboarding/invite.ts new file mode 100644 index 0000000000..d7e9221056 --- /dev/null +++ b/ui/actions/onboarding/invite.ts @@ -0,0 +1,21 @@ +"use server"; + +import { getRoles } from "@/actions/roles"; +import type { InvitationRoleOption } from "@/types/onboarding-invite"; + +const ROLES_PAGE_SIZE = 50; + +// Roles the onboarding invite step can offer; empty when the read fails so +// the step can fall back to skipping rather than blocking the checkpoint. +export const getOnboardingInviteRoles = async (): Promise< + InvitationRoleOption[] +> => { + const rolesData = await getRoles({ pageSize: ROLES_PAGE_SIZE }); + const roles: unknown = rolesData?.data; + if (!Array.isArray(roles)) return []; + return roles.flatMap((role) => + typeof role?.id === "string" && typeof role?.attributes?.name === "string" + ? [{ id: role.id, name: role.attributes.name }] + : [], + ); +}; diff --git a/ui/actions/providers/dynamic-provider-credentials.test.ts b/ui/actions/providers/dynamic-provider-credentials.test.ts new file mode 100644 index 0000000000..f8ab619bc8 --- /dev/null +++ b/ui/actions/providers/dynamic-provider-credentials.test.ts @@ -0,0 +1,180 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import openaiSchema from "@/lib/provider-credentials/fixtures/openai-credential-schema.json"; +import templateSchema from "@/lib/provider-credentials/fixtures/template-credential-schema.json"; +const { fetchMock, getProviderSchemas, getAuthHeaders, revalidatePath } = + vi.hoisted(() => ({ + fetchMock: vi.fn(), + getProviderSchemas: vi.fn(), + getAuthHeaders: vi.fn(), + revalidatePath: vi.fn(), + })); +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.test/api/v1", + getAuthHeaders, +})); +vi.mock("next/cache", () => ({ revalidatePath })); +vi.mock("./provider-schemas", () => ({ getProviderSchemas })); + +import { saveDynamicProviderCredentials } from "./dynamic-provider-credentials"; + +const input = { + providerId: "account", + secretType: "api_key", + secret: { token: "private-value" }, +}; +const response = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status }); +const account = (secretId: string | null = null) => ({ + data: { + id: "account", + attributes: { provider: "acme" }, + relationships: { secret: { data: secretId ? { id: secretId } : null } }, + }, +}); + +describe("dynamic provider credential actions", () => { + beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockReset(); + getAuthHeaders.mockResolvedValue({ Authorization: "Bearer test" }); + getProviderSchemas.mockResolvedValue({ + status: "success", + providerType: "acme", + secretTypes: { + api_key: { + type: "object", + description: openaiSchema.description, + properties: { + token: { type: "string", format: "password", writeOnly: true }, + }, + required: ["token"], + }, + }, + }); + }); + it("validates the account's current schema and sends JSON credentials without the builtin mapping", async () => { + fetchMock + .mockResolvedValueOnce(response(account())) + .mockResolvedValueOnce(response({ data: { id: "saved" } }, 201)); + expect(await saveDynamicProviderCredentials(input)).toEqual({ + status: "saved", + secretId: "saved", + }); + expect(getProviderSchemas).toHaveBeenCalledWith("acme"); + const [url, request] = fetchMock.mock.calls[1]; + expect(url).toBe("https://api.test/api/v1/providers/secrets"); + expect(JSON.parse(request.body).data).toEqual({ + type: "provider-secrets", + attributes: { + secret_type: "api_key", + secret: { token: "private-value" }, + }, + relationships: { + provider: { data: { id: "account", type: "providers" } }, + }, + }); + }); + it("updates the authoritative existing secret, including after a retry", async () => { + fetchMock + .mockResolvedValueOnce(response(account("existing"))) + .mockResolvedValueOnce(response({ data: { id: "existing" } })); + expect((await saveDynamicProviderCredentials(input)).status).toBe("saved"); + expect( + fetchMock.mock.calls[1][0].endsWith("/providers/secrets/existing"), + ).toBe(true); + expect(fetchMock.mock.calls[1][1].method).toBe("PATCH"); + }); + it("validates and sends Template credentials with their JSON types", async () => { + // Given + const templateAccount = account(); + templateAccount.data.attributes.provider = "template"; + getProviderSchemas.mockResolvedValue({ + status: "success", + providerType: "template", + secretTypes: { static: templateSchema }, + }); + fetchMock + .mockResolvedValueOnce(response(templateAccount)) + .mockResolvedValueOnce(response({ data: { id: "saved" } }, 201)); + const secret = { + api_url: "https://api.example.test", + api_key: "fixture-key-not-a-secret", + verify_tls: false, + timeout_seconds: 60, + }; + + // When / Then + expect( + await saveDynamicProviderCredentials({ + ...input, + secretType: "static", + secret, + }), + ).toEqual({ + status: "saved", + secretId: "saved", + }); + expect(JSON.parse(fetchMock.mock.calls[1][1].body).data.attributes).toEqual( + { + secret_type: "static", + secret, + }, + ); + + // Server-side validation also rejects requests that bypass the form. + fetchMock.mockReset().mockResolvedValueOnce(response(templateAccount)); + expect( + await saveDynamicProviderCredentials({ + ...input, + secretType: "static", + secret: { ...secret, timeout_seconds: 301 }, + }), + ).toMatchObject({ + status: "invalid", + errors: { timeout_seconds: expect.any(String) }, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it.each([ + { ...input, secretType: "invented" }, + { ...input, secret: { token: "" } }, + { ...input, secret: { token: "x", unknown: "hidden" } }, + ])("does not write invalid credentials", async (values) => { + fetchMock.mockResolvedValueOnce(response(account())); + expect((await saveDynamicProviderCredentials(values)).status).not.toBe( + "saved", + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it("fails closed for an absent schema, revoked permission, and malformed accounts", async () => { + getProviderSchemas.mockResolvedValue({ + status: "success", + providerType: "acme", + secretTypes: {}, + }); + fetchMock.mockResolvedValueOnce(response(account())); + expect((await saveDynamicProviderCredentials(input)).status).toBe( + "schema_unavailable", + ); + fetchMock.mockResolvedValueOnce(response({}, 403)); + expect((await saveDynamicProviderCredentials(input)).status).toBe( + "access_denied", + ); + fetchMock.mockResolvedValueOnce(response({})); + expect((await saveDynamicProviderCredentials(input)).status).toBe("error"); + expect(fetchMock.mock.calls.every(([, options]) => !options.method)).toBe( + true, + ); + }); + it("does not echo a rejected secret in errors", async () => { + fetchMock + .mockResolvedValueOnce(response(account())) + .mockResolvedValueOnce( + response({ errors: [{ detail: "private-value invalid" }] }, 400), + ); + expect( + JSON.stringify(await saveDynamicProviderCredentials(input)), + ).not.toContain("private-value"); + }); +}); diff --git a/ui/actions/providers/dynamic-provider-credentials.ts b/ui/actions/providers/dynamic-provider-credentials.ts new file mode 100644 index 0000000000..79975fa2e7 --- /dev/null +++ b/ui/actions/providers/dynamic-provider-credentials.ts @@ -0,0 +1,115 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; + +import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { parseRegistryCredentialSchema } from "@/lib/provider-credentials/provider-credential-schema"; +import { validateCredentialValues } from "@/lib/provider-credentials/provider-credential-values"; +import { isKnownProviderType } from "@/types/providers"; + +import { getProviderSchemas } from "./provider-schemas"; + +const resourceId = z.string().regex(/^[a-zA-Z0-9_-]{1,100}$/); +const inputSchema = z.object({ + providerId: resourceId, + secretType: z.string().min(1), + secret: z.unknown(), +}); +const accountSchema = z.object({ + data: z.object({ + id: resourceId, + attributes: z.object({ provider: z.string() }), + relationships: z.object({ + secret: z.object({ data: z.object({ id: resourceId }).nullable() }), + }), + }), +}); + +export type DynamicCredentialsResult = + | { status: "saved"; secretId: string } + | { status: "invalid"; errors: Record } + | { status: "access_denied" | "schema_unavailable" | "error" }; + +export async function saveDynamicProviderCredentials( + input: unknown, +): Promise { + const parsed = inputSchema.safeParse(input); + if (!parsed.success) + return { + status: "invalid", + errors: { _form: "Check the provider and credential fields." }, + }; + const { providerId, secretType, secret } = parsed.data; + try { + const headers = await getAuthHeaders({ contentType: true }); + const accountResponse = await fetch( + `${apiBaseUrl}/providers/${encodeURIComponent(providerId)}`, + { headers, cache: "no-store" }, + ); + if (accountResponse.status === 401 || accountResponse.status === 403) + return { status: "access_denied" }; + if (!accountResponse.ok) return { status: "error" }; + const account = accountSchema.safeParse(await accountResponse.json()); + if ( + !account.success || + account.data.data.id !== providerId || + isKnownProviderType(account.data.data.attributes.provider) + ) + return { status: "error" }; + const schemas = await getProviderSchemas( + account.data.data.attributes.provider, + ); + if (schemas.status === "access_denied") return { status: "access_denied" }; + if ( + schemas.status !== "success" || + !Object.hasOwn(schemas.secretTypes, secretType) + ) + return { status: "schema_unavailable" }; + const schema = parseRegistryCredentialSchema( + schemas.secretTypes[secretType], + ); + if (!schema) return { status: "schema_unavailable" }; + const validated = validateCredentialValues(schema, secret); + if (!validated.valid) + return { status: "invalid", errors: validated.errors }; + + // Read the relationship again on every save so retries update a secret that + // was already created, including after a lost response. + const secretId = account.data.data.relationships.secret.data?.id; + const response = await fetch( + `${apiBaseUrl}/providers/secrets${secretId ? `/${encodeURIComponent(secretId)}` : ""}`, + { + method: secretId ? "PATCH" : "POST", + headers, + cache: "no-store", + body: JSON.stringify({ + data: { + type: "provider-secrets", + ...(secretId + ? { id: secretId } + : { + relationships: { + provider: { data: { id: providerId, type: "providers" } }, + }, + }), + attributes: { secret_type: secretType, secret: validated.secret }, + }, + }), + }, + ); + if (response.status === 401 || response.status === 403) + return { status: "access_denied" }; + // API validation details may echo credential values. Keep them out of both + // client errors and application logs. + if (!response.ok) return { status: "error" }; + const saved = z + .object({ data: z.object({ id: resourceId }) }) + .safeParse(await response.json()); + if (!saved.success) return { status: "error" }; + revalidatePath("/providers"); + return { status: "saved", secretId: saved.data.data.id }; + } catch { + return { status: "error" }; + } +} diff --git a/ui/actions/providers/index.ts b/ui/actions/providers/index.ts index 5532383f5f..d3580b7346 100644 --- a/ui/actions/providers/index.ts +++ b/ui/actions/providers/index.ts @@ -1 +1,2 @@ +export * from "./provider-schemas"; export * from "./providers"; diff --git a/ui/actions/providers/provider-schemas.adapter.test.ts b/ui/actions/providers/provider-schemas.adapter.test.ts new file mode 100644 index 0000000000..32c5e95fce --- /dev/null +++ b/ui/actions/providers/provider-schemas.adapter.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; + +import { + adaptProviderSchemas, + normalizeProviderType, +} from "./provider-schemas.adapter"; + +describe("provider schemas adapter", () => { + it("adapts a matching provider schema resource without interpreting schema keywords", () => { + // Given + const payload = { + data: { + type: "provider-schemas", + id: "acme", + attributes: { + secret_types: { + credentials: { + type: "object", + properties: { access_key: { type: "string" } }, + }, + }, + }, + }, + }; + + // When + const result = adaptProviderSchemas(payload, "acme"); + + // Then + expect(result).toEqual({ + status: "success", + providerType: "acme", + secretTypes: payload.data.attributes.secret_types, + }); + }); + + it.each([ + ["null", null], + ["string scalar", "secret"], + ["number scalar", 1], + ["boolean scalar", true], + ])("rejects %s secret_types values", (_description, secretType) => { + // Given + const payload = { + data: { + type: "provider-schemas", + id: "acme", + attributes: { secret_types: { credentials: secretType } }, + }, + }; + + // When + const result = adaptProviderSchemas(payload, "acme"); + + // Then + expect(result).toBeNull(); + }); + + it("rejects malformed or contradictory documents without reading schema keywords", () => { + // Given + const document = { + data: { + type: "provider-schemas", + id: "aws", + attributes: { secret_types: {} }, + }, + }; + + // When + const results = [ + { ...document, errors: [] }, + { data: { ...document.data, id: "aws " } }, + { data: { ...document.data, type: "providers" } }, + { data: { ...document.data, attributes: { secret_types: { key: [] } } } }, + ].map((payload) => adaptProviderSchemas(payload, "aws")); + + // Then + expect(results).toEqual([null, null, null, null]); + expect(normalizeProviderType(" AWS ")).toBe("aws"); + expect(normalizeProviderType(" ")).toBeNull(); + expect(normalizeProviderType("a".repeat(51))).toBeNull(); + }); +}); diff --git a/ui/actions/providers/provider-schemas.adapter.ts b/ui/actions/providers/provider-schemas.adapter.ts new file mode 100644 index 0000000000..9187afcf8f --- /dev/null +++ b/ui/actions/providers/provider-schemas.adapter.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; + +import { + PROVIDER_SCHEMA_STATUS, + type ProviderSchemasSuccessResult, +} from "@/types/provider-schema"; + +const providerTypeSchema = z.string().trim().toLowerCase().min(1).max(50); +const providerSchemasDocumentSchema = z.strictObject({ + data: z.strictObject({ + type: z.literal("provider-schemas"), + id: z.string().min(1).max(50), + attributes: z.strictObject({ + secret_types: z.record(z.string(), z.record(z.string(), z.unknown())), + }), + }), +}); + +export function normalizeProviderType(value: unknown): string | null { + const parsed = providerTypeSchema.safeParse(value); + return parsed.success ? parsed.data : null; +} + +export function adaptProviderSchemas( + payload: unknown, + normalizedProviderType: string, +): ProviderSchemasSuccessResult | null { + const parsed = providerSchemasDocumentSchema.safeParse(payload); + if (!parsed.success || parsed.data.data.id !== normalizedProviderType) { + return null; + } + + return { + status: PROVIDER_SCHEMA_STATUS.SUCCESS, + providerType: parsed.data.data.id, + secretTypes: parsed.data.data.attributes.secret_types, + }; +} diff --git a/ui/actions/providers/provider-schemas.test.ts b/ui/actions/providers/provider-schemas.test.ts new file mode 100644 index 0000000000..0f6ead52f6 --- /dev/null +++ b/ui/actions/providers/provider-schemas.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { authMock, fetchMock } = vi.hoisted(() => ({ + authMock: vi.fn(), + fetchMock: vi.fn(), +})); + +vi.mock("@/auth.config", () => ({ auth: authMock })); +vi.mock("@/lib", () => ({ apiBaseUrl: "https://api.test/api/v1" })); + +import { getProviderSchemas } from "./provider-schemas"; + +const schemaResponse = (providerType = "acme") => + new Response( + JSON.stringify({ + data: { + type: "provider-schemas", + id: providerType, + attributes: { secret_types: {} }, + }, + }), + { status: 200 }, + ); + +describe("getProviderSchemas", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + authMock.mockResolvedValue({ accessToken: "access-token" }); + fetchMock.mockResolvedValue(schemaResponse()); + }); + + it("requests the normalized provider schema with authenticated JSON:API headers", async () => { + // When + const result = await getProviderSchemas(" ACME "); + + // Then + expect(result).toEqual({ + status: "success", + providerType: "acme", + secretTypes: {}, + }); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.test/api/v1/provider-schemas/acme", + { + cache: "no-store", + headers: { + Accept: "application/vnd.api+json", + Authorization: "Bearer access-token", + }, + }, + ); + }); + + it("does not fetch invalid input and encodes a normalized path segment", async () => { + // Given + fetchMock.mockResolvedValueOnce(schemaResponse("acme/team")); + + // When + const invalid = await Promise.all([ + getProviderSchemas(" "), + getProviderSchemas("a".repeat(51)), + ]); + const encoded = await getProviderSchemas(" ACME/TEAM "); + + // Then + expect(invalid).toEqual([{ status: "error" }, { status: "error" }]); + expect(encoded).toMatchObject({ + status: "success", + providerType: "acme/team", + }); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.test/api/v1/provider-schemas/acme%2Fteam", + expect.any(Object), + ); + }); + + it("denies an unauthenticated request without fetching", async () => { + // Given + authMock.mockResolvedValue({}); + + // When + const result = await getProviderSchemas("acme"); + + // Then + expect(result).toEqual({ status: "access_denied" }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each([ + [401, { status: "access_denied" }], + [403, { status: "access_denied" }], + [404, { status: "not_found" }], + [409, { status: "unavailable" }], + [500, { status: "error" }], + ])("maps HTTP %i to a safe result", async (status, expected) => { + // Given + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ errors: [{ detail: "private detail" }] }), { + status, + }), + ); + + // When + const result = await getProviderSchemas("acme"); + + // Then + expect(result).toEqual(expected); + expect(JSON.stringify(result)).not.toContain("private detail"); + }); + + it("returns a generic safe error when fetch rejects", async () => { + // Given + const rejection = new Error("connection detail must not leak"); + fetchMock.mockRejectedValueOnce(rejection); + + // When + const result = await getProviderSchemas("acme"); + + // Then + expect(result).toEqual({ status: "error" }); + expect(JSON.stringify(result)).not.toContain(rejection.message); + }); + + it("distinguishes a malformed success document from a transport failure", async () => { + // Given + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ errors: [] })), + ); + + // When + const result = await getProviderSchemas("acme"); + + // Then + expect(result).toEqual({ status: "malformed" }); + }); +}); diff --git a/ui/actions/providers/provider-schemas.ts b/ui/actions/providers/provider-schemas.ts new file mode 100644 index 0000000000..afdf0868d2 --- /dev/null +++ b/ui/actions/providers/provider-schemas.ts @@ -0,0 +1,61 @@ +"use server"; + +import { auth } from "@/auth.config"; +import { apiBaseUrl } from "@/lib"; +import { + PROVIDER_SCHEMA_STATUS, + type ProviderSchemasResult, +} from "@/types/provider-schema"; + +import { + adaptProviderSchemas, + normalizeProviderType, +} from "./provider-schemas.adapter"; + +export async function getProviderSchemas( + providerType: unknown, +): Promise { + const normalizedProviderType = normalizeProviderType(providerType); + if (!normalizedProviderType) return { status: PROVIDER_SCHEMA_STATUS.ERROR }; + + let accessToken: string | undefined; + try { + accessToken = (await auth())?.accessToken?.trim(); + } catch { + return { status: PROVIDER_SCHEMA_STATUS.ERROR }; + } + if (!accessToken) return { status: PROVIDER_SCHEMA_STATUS.ACCESS_DENIED }; + + let response: Response; + try { + response = await fetch( + `${apiBaseUrl}/provider-schemas/${encodeURIComponent(normalizedProviderType)}`, + { + cache: "no-store", + headers: { + Accept: "application/vnd.api+json", + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + } catch { + return { status: PROVIDER_SCHEMA_STATUS.ERROR }; + } + + if (response.status === 401 || response.status === 403) { + return { status: PROVIDER_SCHEMA_STATUS.ACCESS_DENIED }; + } + if (response.status === 404) { + return { status: PROVIDER_SCHEMA_STATUS.NOT_FOUND }; + } + if (response.status === 409) { + return { status: PROVIDER_SCHEMA_STATUS.UNAVAILABLE }; + } + if (!response.ok) return { status: PROVIDER_SCHEMA_STATUS.ERROR }; + + const schema = adaptProviderSchemas( + await response.json().catch(() => undefined), + normalizedProviderType, + ); + return schema ?? { status: PROVIDER_SCHEMA_STATUS.MALFORMED }; +} diff --git a/ui/actions/providers/registry-provider.test.ts b/ui/actions/providers/registry-provider.test.ts new file mode 100644 index 0000000000..188cf43357 --- /dev/null +++ b/ui/actions/providers/registry-provider.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + getInstalledRegistryProviderOptions, + addProvider, + getProviders, + updateProvider, +} = vi.hoisted(() => ({ + getInstalledRegistryProviderOptions: vi.fn(), + addProvider: vi.fn(), + getProviders: vi.fn(), + updateProvider: vi.fn(), +})); +vi.mock("@/actions/registry/registry", () => ({ + getInstalledRegistryProviderOptions, +})); +vi.mock("./providers", () => ({ addProvider, getProviders, updateProvider })); + +import { addRegistryProvider } from "./registry-provider"; + +const formData = (alias = "Test") => { + const form = new FormData(); + form.set("providerType", "acme"); + form.set("providerUid", "account"); + form.set("providerAlias", alias); + return form; +}; +describe("Registry provider account creation", () => { + beforeEach(() => { + vi.clearAllMocks(); + getInstalledRegistryProviderOptions.mockResolvedValue({ + status: "ready", + options: [{ type: "acme", label: "Acme" }], + }); + getProviders.mockResolvedValue({ data: [] }); + }); + it("refuses removed artifacts and revoked permission before creating an account", async () => { + getInstalledRegistryProviderOptions + .mockResolvedValueOnce({ status: "access_denied" }) + .mockResolvedValueOnce({ status: "ready", options: [] }); + expect((await addRegistryProvider(formData()))?.errors).toBeDefined(); + expect((await addRegistryProvider(formData()))?.errors).toBeDefined(); + expect(addProvider).not.toHaveBeenCalled(); + }); + it("reuses a previously created account after a failed credential attempt or lost response", async () => { + const existing = { + id: "existing", + attributes: { provider: "acme", uid: "account", alias: "Test" }, + }; + getProviders.mockResolvedValue({ data: [existing] }); + expect(await addRegistryProvider(formData())).toEqual({ data: existing }); + expect(addProvider).not.toHaveBeenCalled(); + expect(updateProvider).not.toHaveBeenCalled(); + }); + it.each(["Test", "", " Edited "])( + "saves alias %j before resuming credentials for an existing account", + async (alias) => { + // Given + const existing = { + id: "existing", + attributes: { provider: "acme", uid: "account", alias: "Original" }, + }; + const updated = { + ...existing, + attributes: { ...existing.attributes, alias: alias.trim() }, + }; + getProviders.mockResolvedValue({ data: [existing] }); + updateProvider.mockResolvedValue({ data: updated }); + + // When + const result = await addRegistryProvider(formData(alias)); + + // Then + expect(result).toEqual({ data: updated }); + expect(Object.fromEntries(updateProvider.mock.calls[0][0])).toEqual({ + providerId: "existing", + providerAlias: alias.trim(), + }); + expect(addProvider).not.toHaveBeenCalled(); + }, + ); + it("keeps alias update failures visible instead of resuming with stale details", async () => { + // Given + const failure = { + errors: [ + { + detail: "Alias is invalid", + source: { pointer: "/data/attributes/alias" }, + }, + ], + }; + getProviders.mockResolvedValue({ + data: [ + { + id: "existing", + attributes: { provider: "acme", uid: "account", alias: "Original" }, + }, + ], + }); + updateProvider.mockResolvedValue(failure); + + // When / Then + await expect(addRegistryProvider(formData())).resolves.toEqual(failure); + expect(addProvider).not.toHaveBeenCalled(); + }); + it("creates a validated installed provider account", async () => { + addProvider.mockResolvedValue({ data: { id: "new" } }); + expect(await addRegistryProvider(formData())).toEqual({ + data: { id: "new" }, + }); + expect(addProvider).toHaveBeenCalledOnce(); + }); +}); diff --git a/ui/actions/providers/registry-provider.ts b/ui/actions/providers/registry-provider.ts new file mode 100644 index 0000000000..b02405b4ed --- /dev/null +++ b/ui/actions/providers/registry-provider.ts @@ -0,0 +1,59 @@ +"use server"; + +import { getInstalledRegistryProviderOptions } from "@/actions/registry/registry"; +import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields"; +import { REGISTRY_PROVIDER_DISCOVERY } from "@/lib/registry/provider-options"; +import { createAddProviderFormSchema } from "@/types/formSchemas"; +import { isKnownProviderType } from "@/types/providers"; + +import { addProvider, getProviders, updateProvider } from "./providers"; + +export async function addRegistryProvider(formData: FormData) { + const unavailable = { + errors: [ + { + detail: + "This Registry provider is no longer available. Check your permissions and installed artifacts, then try again.", + source: { pointer: "/data/attributes/provider" }, + }, + ], + }; + try { + const discovery = await getInstalledRegistryProviderOptions(); + if (discovery.status !== REGISTRY_PROVIDER_DISCOVERY.READY) + return unavailable; + const values = createAddProviderFormSchema( + discovery.options.map((option) => option.type), + ).safeParse(Object.fromEntries(formData)); + if (!values.success || isKnownProviderType(values.data.providerType)) + return unavailable; + const { providerType, providerUid } = values.data; + const existing = await getProviders({ + filters: { "filter[provider]": providerType, "filter[uid]": providerUid }, + pageSize: 100, + }); + // A previous request may have created the account before its response was + // lost. Reuse that identity when returning to the credential step. + if (!existing?.data) return unavailable; + const account = existing.data.find( + (provider) => + provider.attributes.provider === providerType && + provider.attributes.uid === providerUid, + ); + if (account) { + const alias = values.data.providerAlias.trim(); + if ((account.attributes.alias ?? "") === alias) return { data: account }; + const update = new FormData(); + update.set(ProviderCredentialFields.PROVIDER_ID, account.id); + update.set(ProviderCredentialFields.PROVIDER_ALIAS, alias); + return await updateProvider(update); + } + const validated = new FormData(); + Object.entries(values.data).forEach(([key, value]) => { + if (value !== undefined) validated.set(key, value); + }); + return await addProvider(validated); + } catch { + return unavailable; + } +} diff --git a/ui/actions/registry/registry.adapter.test.ts b/ui/actions/registry/registry.adapter.test.ts new file mode 100644 index 0000000000..7e96cea3f2 --- /dev/null +++ b/ui/actions/registry/registry.adapter.test.ts @@ -0,0 +1,809 @@ +import { describe, expect, it } from "vitest"; + +import { + REGISTRY_ENDPOINT, + REGISTRY_FAILURE, + REGISTRY_SUBMISSION, +} from "@/types/registry"; + +import { + adaptRegistryCredentialStatus, + adaptRegistryTenantArtifacts, + classifyRegistryFailure, + collectCompleteRegistryCatalog, + parseRegistryArtifactSubmission, +} from "./registry.adapter"; + +const credentialPayload = { + data: { + attributes: { + configured: true, + is_valid: true, + scopes: ["catalog:read"], + last_validated_at: "2026-03-20T12:00:00Z", + validation_status: "valid", + validation_pending: false, + key: "registry-secret-value", + masked_key: "reg_***", + pending_key: "queued-secret", + arbitrary_backend_detail: "do not expose", + }, + }, +}; + +const activeCredential = adaptRegistryCredentialStatus(credentialPayload); +const jsonError = (status: number, code: string) => + new Response( + JSON.stringify({ errors: [{ code, detail: "private detail" }] }), + { + status, + }, + ); + +describe("Registry adapter", () => { + it("reads the resolved installed version separately from the requested spec", () => { + // Given / When + const artifacts = adaptRegistryTenantArtifacts({ + data: [ + { + type: "registry-artifacts", + id: "template", + attributes: { + version_spec: "latest", + resolved_version: " 1.0.0 ", + }, + }, + ], + }); + // Then + expect(artifacts).toEqual([ + expect.objectContaining({ + normalizedName: "template", + versionSpec: "latest", + resolvedVersion: "1.0.0", + }), + ]); + }); + + it("reads the built-in providers an installed artifact adds checks to", () => { + // Given / When + const artifacts = adaptRegistryTenantArtifacts({ + data: [ + { + type: "registry-artifacts", + id: "local-acme-builtin-checks", + attributes: { + version_spec: "latest", + extends_provider_slugs: ["AWS", "aws", " gcp "], + }, + }, + { + type: "registry-artifacts", + id: "older-api", + attributes: { version_spec: "latest" }, + }, + ], + }); + + // Then + expect(artifacts).toMatchObject([ + { extendsProviderSlugs: ["aws", "gcp"] }, + { extendsProviderSlugs: [] }, + ]); + }); + + it.each([undefined, null, "", " "])( + "accepts an unknown resolved version %j", + (resolvedVersion) => { + // Given / When + const artifacts = adaptRegistryTenantArtifacts({ + data: [ + { + type: "registry-artifacts", + id: "template", + attributes: { + version_spec: "latest", + resolved_version: resolvedVersion, + }, + }, + ], + }); + // Then + expect(artifacts).toMatchObject([{ resolvedVersion: undefined }]); + }, + ); + + it("maps only documented non-secret credential status fields", () => { + // Given + const malformedPayload = { data: { attributes: { configured: true } } }; + + // When + const status = adaptRegistryCredentialStatus(credentialPayload); + + // Then + expect(status).toEqual({ + configured: true, + isValid: true, + scopes: ["catalog:read"], + lastValidatedAt: "2026-03-20T12:00:00Z", + validationStatus: "valid", + validationPending: false, + }); + expect(adaptRegistryCredentialStatus(malformedPayload)).toBeNull(); + }); + + it("normalizes an absent credential status with nullable validation fields", () => { + // Given + const absentCredentialPayload = { + data: { + attributes: { + configured: false, + is_valid: false, + scopes: [], + last_validated_at: null, + validation_status: null, + validation_pending: false, + }, + }, + }; + + // When + const status = adaptRegistryCredentialStatus(absentCredentialPayload); + + // Then + expect(status).toEqual({ + configured: false, + isValid: false, + scopes: [], + lastValidatedAt: undefined, + validationStatus: undefined, + validationPending: false, + }); + }); + + it("accepts only a matching artifact 202 task and fixed Content-Location path", async () => { + // Given + const response = new Response( + JSON.stringify({ data: { type: "tasks", id: "task-123" } }), + { + status: 202, + headers: { "Content-Location": "/api/v1/tasks/task-123" }, + }, + ); + + // When + const result = await parseRegistryArtifactSubmission(response); + + // Then + expect(result).toEqual({ + status: REGISTRY_SUBMISSION.PENDING, + taskId: "task-123", + }); + }); + + it("rejects a non-202 response or a mismatched task location", async () => { + // Given + const task = JSON.stringify({ data: { type: "tasks", id: "task-123" } }); + const wrongStatus = new Response(task, { status: 201 }); + const wrongLocation = new Response(task, { + status: 202, + headers: { "Content-Location": "/api/v1/tasks/other" }, + }); + + // When + const results = await Promise.all([ + parseRegistryArtifactSubmission(wrongStatus), + parseRegistryArtifactSubmission(wrongLocation), + ]); + + // Then + expect(results).toEqual([ + { status: REGISTRY_SUBMISSION.ERROR }, + { status: REGISTRY_SUBMISSION.ERROR }, + ]); + }); + + it("classifies every Registry 401 or 403 as access denied first", async () => { + // Given + const responses = [ + [401, REGISTRY_ENDPOINT.CREDENTIAL], + [403, REGISTRY_ENDPOINT.MUTATION], + [403, REGISTRY_ENDPOINT.PROVIDERS], + ] as const; + + // When + const results = await Promise.all( + responses.map(([status, endpoint]) => + classifyRegistryFailure( + jsonError(status, "registry_key_rejected"), + endpoint, + activeCredential, + ), + ), + ); + + // Then + expect(results).toEqual([ + { status: REGISTRY_FAILURE.ACCESS_DENIED }, + { status: REGISTRY_FAILURE.ACCESS_DENIED }, + { status: REGISTRY_FAILURE.ACCESS_DENIED }, + ]); + }); + + it("maps only a 409 with an authoritative no-active credential to onboarding", async () => { + // Given + const noCredential = adaptRegistryCredentialStatus({ + data: { + attributes: { + configured: false, + is_valid: false, + scopes: [], + validation_pending: false, + }, + }, + }); + + // When + const results = await Promise.all( + [noCredential, null].map((credential) => + classifyRegistryFailure( + new Response(null, { status: 409 }), + REGISTRY_ENDPOINT.AVAILABLE_ARTIFACTS, + credential, + ), + ), + ); + + // Then + expect(results).toEqual([ + { status: REGISTRY_FAILURE.ONBOARDING }, + { status: REGISTRY_FAILURE.ERROR }, + ]); + }); + + it("maps only exact documented 502 and 503 status-code pairs", async () => { + // Given + const rejected = jsonError(502, "registry_key_rejected"); + const unavailable = jsonError(503, "registry_unavailable"); + + // When + const results = await Promise.all([ + classifyRegistryFailure( + rejected, + REGISTRY_ENDPOINT.PROVIDERS, + activeCredential, + ), + classifyRegistryFailure( + unavailable, + REGISTRY_ENDPOINT.AVAILABLE_ARTIFACTS, + activeCredential, + ), + ]); + + // Then + expect(results).toEqual([ + { status: REGISTRY_FAILURE.RECONNECT }, + { status: REGISTRY_FAILURE.UNAVAILABLE }, + ]); + }); + + it("keeps wrong, malformed, and unrelated failures generic", async () => { + // Given + const malformed = new Response("key=private", { status: 503 }); + + // When + const results = await Promise.all([ + classifyRegistryFailure( + jsonError(502, "other_error"), + REGISTRY_ENDPOINT.PROVIDERS, + activeCredential, + ), + classifyRegistryFailure( + jsonError(502, "registry_unavailable"), + REGISTRY_ENDPOINT.PROVIDERS, + activeCredential, + ), + classifyRegistryFailure( + malformed, + REGISTRY_ENDPOINT.PROVIDERS, + activeCredential, + ), + ]); + + // Then + expect(results).toEqual([ + { status: REGISTRY_FAILURE.ERROR }, + { status: REGISTRY_FAILURE.ERROR }, + { status: REGISTRY_FAILURE.ERROR }, + ]); + }); + + it("degrades a non-terminal empty first catalog page", async () => { + // Given + const document = (page: number) => ({ + data: [], + meta: { pagination: { page, pages: 2, count: 0 } }, + }); + + // When + const result = await collectCompleteRegistryCatalog(async (page) => + document(page), + ); + + // Then + expect(result).toEqual({ + status: "incomplete", + reason: "invalid_page", + collectedCount: 0, + }); + }); + + it("accepts a terminal empty first catalog page", async () => { + // Given + const document = { + data: [], + meta: { pagination: { page: 1, pages: 1, count: 0 } }, + }; + + // When + const result = await collectCompleteRegistryCatalog(async () => document); + + // Then + expect(result).toEqual({ status: "complete", artifacts: [] }); + }); + + it("maps the flat owner attributes tolerantly", async () => { + // Given + const document = { + data: [ + { + type: "registry-artifacts", + id: "core", + attributes: { + owner_name: "Prowler", + owner_slug: "prowler", + owner_type: "organization", + owner_logo_url: "https://cdn.example/prowler.png", + }, + }, + { + type: "registry-artifacts", + id: "plain-owner", + attributes: { + owner_name: "Ada", + owner_slug: "ada", + owner_type: "user", + owner_logo_url: null, + }, + }, + { + type: "registry-artifacts", + id: "ownerless", + attributes: { owner_name: " ", owner_logo_url: " " }, + }, + ], + meta: { pagination: { page: 1, pages: 1, count: 3 } }, + }; + + // When + const result = await collectCompleteRegistryCatalog(async () => document); + + // Then + expect(result).toMatchObject({ + status: "complete", + artifacts: [ + { + normalizedName: "core", + owners: [ + { + type: "organization", + name: "Prowler", + logoUrl: "https://cdn.example/prowler.png", + }, + ], + }, + { + normalizedName: "ownerless", + owners: [], + }, + { + normalizedName: "plain-owner", + owners: [{ type: "user", name: "Ada", logoUrl: undefined }], + }, + ], + }); + }); + + it("reads the deployment's install verdict and refuses installs an older API never confirmed", async () => { + // Given + const document = { + data: [ + { + type: "registry-artifacts", + id: "aws-checks", + attributes: { + has_checks: true, + is_installable: true, + not_installable_reason: null, + }, + }, + { + type: "registry-artifacts", + id: "acme-checks", + attributes: { + has_checks: true, + is_installable: false, + not_installable_reason: "checks_target_is_not_builtin", + }, + }, + { + type: "registry-artifacts", + id: "older-api", + attributes: { has_provider: true }, + }, + ], + meta: { pagination: { page: 1, pages: 1, count: 3 } }, + }; + + // When + const result = await collectCompleteRegistryCatalog(async () => document); + + // Then + expect(result).toMatchObject({ + status: "complete", + artifacts: [ + { + normalizedName: "acme-checks", + isInstallable: false, + notInstallableReason: "checks_target_is_not_builtin", + }, + { normalizedName: "aws-checks", isInstallable: true }, + { normalizedName: "older-api", isInstallable: false }, + ], + }); + if (result.status !== "complete") throw new Error("Incomplete fixture"); + expect(result.artifacts[1]).not.toHaveProperty("notInstallableReason"); + }); + + it("keeps an artifact uninstallable when any catalog page refuses it", async () => { + // Given + const document = { + data: [ + { + type: "registry-artifacts", + id: "split", + attributes: { is_installable: true }, + }, + { + type: "registry-artifacts", + id: "split", + attributes: { + is_installable: false, + not_installable_reason: "artifact_compliance_not_supported", + }, + }, + ], + meta: { pagination: { page: 1, pages: 1, count: 2 } }, + }; + + // When + const result = await collectCompleteRegistryCatalog(async () => document); + + // Then + expect(result).toMatchObject({ + artifacts: [ + { + isInstallable: false, + notInstallableReason: "artifact_compliance_not_supported", + }, + ], + }); + }); + + it("defaults omitted built-in status and maps explicit built-ins", async () => { + // Given + const document = { + data: [ + { type: "registry-artifacts", id: "installable", attributes: {} }, + { + type: "registry-artifacts", + id: "built-in", + attributes: { is_builtin: true }, + }, + ], + meta: { pagination: { page: 1, pages: 1, count: 2 } }, + }; + + // When + const result = await collectCompleteRegistryCatalog(async () => document); + + // Then + expect(result).toMatchObject({ + status: "complete", + artifacts: [ + { normalizedName: "built-in", isBuiltin: true }, + { normalizedName: "installable", isBuiltin: false }, + ], + }); + }); + + it("rejects malformed built-in values and preserves built-in duplicates", async () => { + // Given + const document = (data: unknown[]) => ({ + data, + meta: { pagination: { page: 1, pages: 1, count: data.length } }, + }); + const resource = (id: string, isBuiltin: unknown) => ({ + type: "registry-artifacts", + id, + attributes: { is_builtin: isBuiltin }, + }); + + // When + const explicitFalse = await collectCompleteRegistryCatalog(async () => + document([resource("installable", false)]), + ); + const malformed = await Promise.all( + [null, "true", 1].map((isBuiltin) => + collectCompleteRegistryCatalog(async () => + document([resource("malformed", isBuiltin)]), + ), + ), + ); + const duplicate = await collectCompleteRegistryCatalog(async (page) => ({ + data: [resource("built-in", page === 2)], + meta: { pagination: { page, pages: 2, count: 2 } }, + })); + + // Then + expect(explicitFalse).toMatchObject({ + status: "complete", + artifacts: [{ normalizedName: "installable", isBuiltin: false }], + }); + expect(malformed).toEqual([ + { status: "incomplete", reason: "invalid_resource", collectedCount: 1 }, + { status: "incomplete", reason: "invalid_resource", collectedCount: 1 }, + { status: "incomplete", reason: "invalid_resource", collectedCount: 1 }, + ]); + expect(duplicate).toMatchObject({ + status: "complete", + artifacts: [{ normalizedName: "built-in", isBuiltin: true }], + }); + }); + + it("preserves artifact counts, including zero, without inventing missing counts", async () => { + // Given + const resources = [ + { id: "aws", attributes: { check_count: 645, compliance_count: 45 } }, + { id: "openai", attributes: { check_count: 2, compliance_count: 0 } }, + { id: "missing", attributes: {} }, + { + id: "unknown", + attributes: { check_count: null, compliance_count: null }, + }, + { id: "aws", attributes: { check_count: 645 } }, + ].map((resource) => ({ + type: "registry-available-artifacts", + ...resource, + })); + + // When + const result = await collectCompleteRegistryCatalog(async () => ({ + data: resources, + meta: { pagination: { page: 1, pages: 1, count: resources.length } }, + })); + + // Then + expect(result).toMatchObject({ + status: "complete", + artifacts: [ + { normalizedName: "aws", checkCount: 645, complianceCount: 45 }, + { + normalizedName: "missing", + checkCount: undefined, + complianceCount: undefined, + }, + { normalizedName: "openai", checkCount: 2, complianceCount: 0 }, + { + normalizedName: "unknown", + checkCount: undefined, + complianceCount: undefined, + }, + ], + }); + }); + + it("preserves the declared provider when merging complementary catalog entries", async () => { + // Given + const fetchPage = async (page: number) => ({ + data: [ + { + type: "registry-artifacts", + id: "external-package", + attributes: + page === 1 + ? { providers: ["aaa"], has_checks: true } + : { providers: ["zzz"], has_provider: true }, + }, + ], + meta: { pagination: { page, pages: 2, count: 2 } }, + }); + + // When + const result = await collectCompleteRegistryCatalog(fetchPage); + + // Then + expect(result).toMatchObject({ + status: "complete", + artifacts: [ + { hasProvider: true, providerSlug: "zzz", providers: ["aaa", "zzz"] }, + ], + }); + }); + + it("rejects duplicate catalog entries with conflicting declared providers", async () => { + // Given + const fetchPage = async (page: number) => ({ + data: [ + { + type: "registry-artifacts", + id: "external-package", + attributes: { + has_provider: true, + providers: [page === 1 ? "aaa" : "zzz"], + }, + }, + ], + meta: { pagination: { page, pages: 2, count: 2 } }, + }); + + // When / Then + await expect( + collectCompleteRegistryCatalog(fetchPage), + ).resolves.toMatchObject({ + status: "incomplete", + reason: "conflicting_duplicate", + }); + }); + + it("traverses, merges, and degrades unsafe catalog data", async () => { + // Given + + const resource = ( + id: string, + attributes: Record = {}, + ) => ({ type: "registry-artifacts", id, attributes }); + + const document = ( + page: number, + pages: number, + count: number, + data: unknown[], + ) => ({ data, meta: { pagination: { page, pages, count } } }); + const requests: Array<[number, string | null, string | null]> = []; + + // When + + const complete = await collectCompleteRegistryCatalog( + async (page, query) => { + requests.push([ + page, + query.get("page[number]"), + query.get("page[size]"), + ]); + return page === 1 + ? document(1, 2, 3, [ + resource("core", { + name: "Core", + providers: ["AWS"], + is_verified: true, + version_count: 1, + total_downloads: 2, + owner_name: "Prowler", + owner_type: "organization", + }), + resource("zeta"), + ]) + : document(2, 2, 3, [ + resource("core", { + description: "Registry core", + latest_version: "2.0.0", + providers: ["gcp"], + is_official: true, + has_checks: true, + version_count: 3, + total_downloads: 8, + }), + ]); + }, + ); + + const limits = await Promise.all( + [999, 1000, 1001].map(async (pages) => { + let requests = 0; + const result = await collectCompleteRegistryCatalog(async (page) => { + requests += 1; + return document(page, pages, pages, [resource(`item-${page}`)]); + }); + return [pages, requests, result] as const; + }), + ); + + const failures = await Promise.all([ + collectCompleteRegistryCatalog(async () => ({ data: {}, meta: {} })), + collectCompleteRegistryCatalog(async () => + document(1, 1, 2, [resource("one")]), + ), + collectCompleteRegistryCatalog(async (page) => + document(page === 1 ? 1 : 1, 2, 2, [resource(`item-${page}`)]), + ), + collectCompleteRegistryCatalog(async (page) => + document(page, page === 1 ? 2 : 3, 2, [resource(`item-${page}`)]), + ), + collectCompleteRegistryCatalog(async () => + document(1, 1, 1, [resource("")]), + ), + collectCompleteRegistryCatalog(async (page) => + document(page, 2, 2, [ + resource("duplicate", { name: page === 1 ? "One" : "Two" }), + ]), + ), + collectCompleteRegistryCatalog(async (page) => { + if (page === 2) throw new Error("offline"); + return document(1, 2, 2, [resource("first")]); + }), + ]); + + // Then + expect(requests).toEqual([ + [1, "1", "100"], + [2, "2", "100"], + ]); + + expect(complete).toMatchObject({ + status: "complete", + artifacts: [ + { + normalizedName: "core", + name: "Core", + description: "Registry core", + latestVersion: "2.0.0", + providers: ["aws", "gcp"], + isVerified: true, + isOfficial: true, + hasChecks: true, + versionCount: 3, + totalDownloads: 8, + owners: [{ type: "organization", name: "Prowler" }], + }, + { normalizedName: "zeta" }, + ], + }); + expect(limits.map(([pages, requests]) => [pages, requests])).toEqual([ + [999, 999], + [1000, 1000], + [1001, 1], + ]); + expect(limits[2]?.[2]).toEqual({ + status: "incomplete", + reason: "guard_exhausted", + collectedCount: 1, + }); + expect( + failures.map((result) => + result.status === "incomplete" ? result.reason : undefined, + ), + ).toEqual([ + "invalid_page", + "count_mismatch", + "invalid_page", + "invalid_page", + "invalid_resource", + "conflicting_duplicate", + "page_failed", + ]); + failures.forEach((result) => + expect(result).not.toHaveProperty("artifacts"), + ); + }); +}); diff --git a/ui/actions/registry/registry.adapter.ts b/ui/actions/registry/registry.adapter.ts new file mode 100644 index 0000000000..4ae9de0c7d --- /dev/null +++ b/ui/actions/registry/registry.adapter.ts @@ -0,0 +1,478 @@ +import { z } from "zod"; + +import { isActiveRegistryCredential } from "@/lib/registry/credential-task"; +import { + REGISTRY_ARTIFACT_REMOVAL, + REGISTRY_CATALOG, + REGISTRY_CATALOG_INCOMPLETE_REASON, + REGISTRY_ENDPOINT, + REGISTRY_FAILURE, + REGISTRY_MUTATION, + REGISTRY_SUBMISSION, + type RegistryArtifactRemovalConflict, + type RegistryCatalogArtifact, + type RegistryCatalogResult, + type RegistryCredentialStatus, + type RegistryTaskSubmissionResult, + type RegistryEndpoint, + type RegistryFailureResult, + type RegistryMutationResult, + type RegistryTenantArtifact, +} from "@/types/registry"; + +const REGISTRY_TASK_PATH_PREFIX = "/api/v1/tasks/"; +const REGISTRY_ERROR_CODE = { + KEY_REJECTED: "registry_key_rejected", + UNAVAILABLE: "registry_unavailable", +} as const; +// Opposite remedies, so a 409 is never read without its code. +const REGISTRY_REMOVAL_CONFLICT_CODE = { + IN_USE: "registry_artifact_in_use", + BUSY: "registry_artifact_busy", +} as const; +const REGISTRY_MUTATION_REFUSAL_COPY = { + no_installable_version: "No available version can be added.", + registry_artifact_not_found: "This artifact is no longer available.", + version_not_found: "This version is not available.", + version_not_processed: "This version is not ready to add yet.", + version_not_verified: "This version is not verified and cannot be added.", + version_yanked: "This version is no longer available.", +} as const; +const registryDiscoveryEndpoints = new Set([ + REGISTRY_ENDPOINT.PROVIDERS, + REGISTRY_ENDPOINT.AVAILABLE_ARTIFACTS, +]); + +const credentialStatusSchema = z.object({ + data: z.object({ + attributes: z.object({ + configured: z.boolean(), + is_valid: z.boolean(), + scopes: z.array(z.string()), + last_validated_at: z.string().nullish(), + validation_status: z.string().nullish(), + validation_pending: z.boolean(), + }), + }), +}); + +const taskSubmissionSchema = z.object({ + data: z.object({ + type: z.literal("tasks"), + id: z.string().min(1), + }), +}); + +const registryCollectionSchema = z.object({ data: z.array(z.unknown()) }); +const tenantArtifactsSchema = z.object({ + data: z.array( + z.object({ + type: z.string().trim().min(1), + id: z.string().trim().min(1), + attributes: z.object({ + version_spec: z.string().trim().min(1), + resolved_version: z.string().trim().nullish(), + extends_provider_slugs: z.array(z.string()).nullish(), + inserted_at: z.string().optional(), + updated_at: z.string().optional(), + }), + }), + ), +}); + +const errorDocumentSchema = z.object({ + errors: z.array(z.object({ code: z.string().min(1) })).min(1), +}); + +export function adaptRegistryCredentialStatus( + payload: unknown, +): RegistryCredentialStatus | null { + const parsed = credentialStatusSchema.safeParse(payload); + if (!parsed.success) return null; + + const { attributes } = parsed.data.data; + return { + configured: attributes.configured, + isValid: attributes.is_valid, + scopes: attributes.scopes, + lastValidatedAt: attributes.last_validated_at ?? undefined, + validationStatus: attributes.validation_status ?? undefined, + validationPending: attributes.validation_pending, + }; +} + +export function adaptRegistryTenantArtifacts( + payload: unknown, +): RegistryTenantArtifact[] | null { + const parsed = tenantArtifactsSchema.safeParse(payload); + if (!parsed.success) return null; + + return parsed.data.data.map(({ attributes, id }) => ({ + normalizedName: id, + versionSpec: attributes.version_spec, + resolvedVersion: attributes.resolved_version || undefined, + extendsProviderSlugs: unique( + (attributes.extends_provider_slugs ?? []) + .map((slug) => slug.trim().toLowerCase()) + .filter(Boolean), + ), + insertedAt: attributes.inserted_at, + updatedAt: attributes.updated_at, + })); +} + +export function isRegistryCollection(payload: unknown) { + return registryCollectionSchema.safeParse(payload).success; +} + +export class RegistryCatalogPageError extends Error { + constructor(readonly failure: RegistryFailureResult) { + super("Registry catalog page request failed"); + } +} + +export const parseRegistryCredentialSubmission = ( + response: Response, +): Promise => + parseRegistryTaskSubmission(response); + +export const parseRegistryArtifactSubmission = ( + response: Response, +): Promise => + parseRegistryTaskSubmission(response); + +async function parseRegistryTaskSubmission( + response: Response, +): Promise { + if (response.status !== 202) return { status: REGISTRY_SUBMISSION.ERROR }; + + const parsed = taskSubmissionSchema.safeParse( + await response.json().catch(() => undefined), + ); + const taskId = parsed.success ? parsed.data.data.id : undefined; + const location = response.headers.get("Content-Location"); + if ( + !taskId || + location !== `${REGISTRY_TASK_PATH_PREFIX}${encodeURIComponent(taskId)}` + ) { + return { status: REGISTRY_SUBMISSION.ERROR }; + } + + return { status: REGISTRY_SUBMISSION.PENDING, taskId }; +} + +export async function classifyRegistryMutationRefusal( + response: Response, +): Promise | null> { + const code = await getRegistryErrorCode(response); + const message = code + ? REGISTRY_MUTATION_REFUSAL_COPY[ + code as keyof typeof REGISTRY_MUTATION_REFUSAL_COPY + ] + : undefined; + return message ? { status: REGISTRY_MUTATION.REFUSED, message } : null; +} + +export async function classifyRegistryRemovalConflict( + response: Response, +): Promise { + const code = await getRegistryErrorCode(response); + if (code === REGISTRY_REMOVAL_CONFLICT_CODE.IN_USE) + return { status: REGISTRY_ARTIFACT_REMOVAL.IN_USE }; + if (code === REGISTRY_REMOVAL_CONFLICT_CODE.BUSY) + return { status: REGISTRY_ARTIFACT_REMOVAL.BUSY }; + return null; +} + +export async function classifyRegistryFailure( + response: Response, + endpoint: RegistryEndpoint, + credentialStatus: RegistryCredentialStatus | null, +): Promise { + if (response.status === 401 || response.status === 403) { + return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + } + + if (!isRegistryDiscoveryEndpoint(endpoint)) { + return { status: REGISTRY_FAILURE.ERROR }; + } + + if ( + response.status === 409 && + 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 }; + } + if (response.status === 503 && code === REGISTRY_ERROR_CODE.UNAVAILABLE) { + return { status: REGISTRY_FAILURE.UNAVAILABLE }; + } + + return { status: REGISTRY_FAILURE.ERROR }; +} + +function isRegistryDiscoveryEndpoint(endpoint: RegistryEndpoint) { + return registryDiscoveryEndpoints.has(endpoint); +} + +async function getRegistryErrorCode(response: Response) { + const parsed = errorDocumentSchema.safeParse( + await response + .clone() + .json() + .catch(() => undefined), + ); + return parsed.success ? parsed.data.errors[0]?.code : undefined; +} + +const REGISTRY_CATALOG_PAGE_SIZE = 100; +const REGISTRY_CATALOG_MAX_PAGES = 1000; +const safeInteger = z.number().int().nonnegative().safe(); +const catalogPageSchema = z.object({ + data: z.array(z.unknown()), + meta: z.object({ + pagination: z.object({ + page: safeInteger, + pages: safeInteger, + count: safeInteger, + }), + }), +}); +const catalogAttributesSchema = z.object({ + name: z.string().optional(), + description: z.string().optional(), + latest_version: z.string().optional(), + providers: z.array(z.string().trim().min(1)).optional(), + owner_name: z.string().optional(), + owner_type: z.string().optional(), + owner_logo_url: z.string().nullable().optional(), + is_verified: z.boolean().optional(), + is_official: z.boolean().optional(), + is_builtin: z.boolean().optional(), + is_meta: z.boolean().optional(), + has_provider: z.boolean().optional(), + has_checks: z.boolean().optional(), + has_compliance: z.boolean().optional(), + is_installable: z.boolean().optional(), + not_installable_reason: z.string().nullish(), + check_count: safeInteger.nullish(), + compliance_count: safeInteger.nullish(), + version_count: safeInteger.optional(), + total_downloads: safeInteger.optional(), +}); +const catalogResourceSchema = z.object({ + type: z.string().trim().min(1), + id: z.string().trim().min(1), + attributes: catalogAttributesSchema, +}); +type RegistryCatalogPageFetcher = ( + page: number, + searchParams: URLSearchParams, +) => Promise; + +export async function collectCompleteRegistryCatalog( + fetchPage: RegistryCatalogPageFetcher, +): Promise { + const resources: unknown[] = []; + let expectedPages: number | undefined; + let expectedCount: number | undefined; + for (let page = 1; ; page += 1) { + let payload: unknown; + try { + payload = await fetchPage( + page, + new URLSearchParams({ + "page[number]": String(page), + "page[size]": String(REGISTRY_CATALOG_PAGE_SIZE), + }), + ); + } catch (error) { + if (error instanceof RegistryCatalogPageError) throw error; + return incomplete("PAGE_FAILED", resources.length); + } + const parsed = catalogPageSchema.safeParse(payload); + if (!parsed.success) return incomplete("INVALID_PAGE", resources.length); + const { count, page: responsePage, pages } = parsed.data.meta.pagination; + if ( + responsePage !== page || + (expectedPages !== undefined && + (pages !== expectedPages || count !== expectedCount)) + ) + return incomplete("INVALID_PAGE", resources.length); + expectedPages ??= pages; + expectedCount ??= count; + if (page === 1 && pages > 1 && count === 0 && parsed.data.data.length === 0) + return incomplete("INVALID_PAGE", resources.length); + if (pages === 0) + return page === 1 && count === 0 && parsed.data.data.length === 0 + ? { status: REGISTRY_CATALOG.COMPLETE, artifacts: [] } + : incomplete("INVALID_PAGE", resources.length); + resources.push(...parsed.data.data); + if (pages > REGISTRY_CATALOG_MAX_PAGES) + return incomplete("GUARD_EXHAUSTED", resources.length); + if (page === pages) break; + if (page > pages) return incomplete("INVALID_PAGE", resources.length); + } + const merged = mergeCatalogResources(resources); + return merged.status === REGISTRY_CATALOG.INCOMPLETE || + resources.length === expectedCount + ? merged + : incomplete("COUNT_MISMATCH", resources.length); +} + +function mergeCatalogResources(resources: unknown[]): RegistryCatalogResult { + const artifacts = new Map(); + for (const resource of resources) { + const artifact = adaptCatalogArtifact(resource); + if (!artifact) return incomplete("INVALID_RESOURCE", resources.length); + const prior = artifacts.get(artifact.normalizedName); + const next = prior ? mergeArtifacts(prior, artifact) : artifact; + if (!next) return incomplete("CONFLICTING_DUPLICATE", resources.length); + artifacts.set(next.normalizedName, next); + } + return { + status: REGISTRY_CATALOG.COMPLETE, + artifacts: Array.from(artifacts.values()).sort((left, right) => + compare(left.normalizedName, right.normalizedName), + ), + }; +} + +function adaptCatalogArtifact( + resource: unknown, +): RegistryCatalogArtifact | null { + const parsed = catalogResourceSchema.safeParse(resource); + if (!parsed.success) return null; + const { attributes: a, id } = parsed.data; + const notInstallableReason = + a.is_installable === true + ? undefined + : text(a.not_installable_reason ?? undefined); + return { + normalizedName: id, + name: text(a.name), + description: text(a.description), + latestVersion: text(a.latest_version), + providers: unique( + a.providers?.map((provider) => provider.toLowerCase()) ?? [], + ), + ...(a.has_provider === true && a.providers?.[0] + ? { providerSlug: a.providers[0].toLowerCase() } + : {}), + owners: flatOwner(a), + isVerified: a.is_verified ?? false, + isOfficial: a.is_official ?? false, + isBuiltin: a.is_builtin ?? false, + isMeta: a.is_meta ?? false, + hasProvider: a.has_provider ?? false, + hasChecks: a.has_checks ?? false, + hasCompliance: a.has_compliance ?? false, + // An older API sends no verdict; never offer an install it did not confirm. + isInstallable: a.is_installable ?? false, + ...(notInstallableReason ? { notInstallableReason } : {}), + checkCount: a.check_count ?? undefined, + complianceCount: a.compliance_count ?? undefined, + versionCount: a.version_count ?? 0, + totalDownloads: a.total_downloads ?? 0, + }; +} + +function mergeArtifacts( + left: RegistryCatalogArtifact, + right: RegistryCatalogArtifact, +): RegistryCatalogArtifact | null { + const [name, description, latestVersion, providerSlug] = [ + mergeText(left.name, right.name), + mergeText(left.description, right.description), + mergeText(left.latestVersion, right.latestVersion), + mergeText(left.providerSlug, right.providerSlug), + ]; + if ( + [name, description, latestVersion, providerSlug].some( + (value) => value === null, + ) + ) + return null; + return { + ...left, + name: name ?? undefined, + description: description ?? undefined, + latestVersion: latestVersion ?? undefined, + providerSlug: providerSlug ?? undefined, + providers: unique([...left.providers, ...right.providers]), + owners: uniqueOwners([...left.owners, ...right.owners]), + isVerified: left.isVerified || right.isVerified, + isOfficial: left.isOfficial || right.isOfficial, + isBuiltin: left.isBuiltin || right.isBuiltin, + isMeta: left.isMeta || right.isMeta, + hasProvider: left.hasProvider || right.hasProvider, + hasChecks: left.hasChecks || right.hasChecks, + hasCompliance: left.hasCompliance || right.hasCompliance, + // Any page refusing the install wins, and its reason travels with it. + isInstallable: left.isInstallable && right.isInstallable, + notInstallableReason: + left.notInstallableReason ?? right.notInstallableReason, + checkCount: mergeCount(left.checkCount, right.checkCount), + complianceCount: mergeCount(left.complianceCount, right.complianceCount), + versionCount: Math.max(left.versionCount, right.versionCount), + totalDownloads: Math.max(left.totalDownloads, right.totalDownloads), + }; +} + +function incomplete( + reason: keyof typeof REGISTRY_CATALOG_INCOMPLETE_REASON, + collectedCount: number, +): RegistryCatalogResult { + return { + status: REGISTRY_CATALOG.INCOMPLETE, + reason: REGISTRY_CATALOG_INCOMPLETE_REASON[reason], + collectedCount, + }; +} +function text(value: string | undefined) { + return value?.trim() || undefined; +} +function mergeText(left: string | undefined, right: string | undefined) { + return left && right && left !== right ? null : (left ?? right); +} +function mergeCount(left: number | undefined, right: number | undefined) { + if (left === undefined) return right; + if (right === undefined) return left; + return Math.max(left, right); +} +function unique(values: string[]) { + return Array.from(new Set(values)).sort(compare); +} +function flatOwner( + a: z.infer, +): RegistryCatalogArtifact["owners"] { + const name = text(a.owner_name); + if (!name) return []; + return [ + { + name, + type: text(a.owner_type) ?? "", + logoUrl: text(a.owner_logo_url ?? undefined), + }, + ]; +} +function uniqueOwners(owners: RegistryCatalogArtifact["owners"]) { + return Array.from( + new Map( + owners.map((owner) => [`${owner.type}\u0000${owner.name}`, owner]), + ).values(), + ).sort((left, right) => + compare( + `${left.type}\u0000${left.name}`, + `${right.type}\u0000${right.name}`, + ), + ); +} +function compare(left: string, right: string) { + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/ui/actions/registry/registry.test.ts b/ui/actions/registry/registry.test.ts new file mode 100644 index 0000000000..7ae458cc3f --- /dev/null +++ b/ui/actions/registry/registry.test.ts @@ -0,0 +1,1222 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + authMock, + evaluateAccessMock, + evaluateProviderAccessMock, + fetchMock, + pollTaskUntilSettledMock, +} = vi.hoisted(() => ({ + authMock: vi.fn(), + evaluateAccessMock: vi.fn(), + evaluateProviderAccessMock: vi.fn(), + fetchMock: vi.fn(), + pollTaskUntilSettledMock: vi.fn(), +})); + +vi.mock("@/auth.config", () => ({ auth: authMock })); +vi.mock("@/lib", () => ({ apiBaseUrl: "https://api.test/api/v1" })); +vi.mock("@/actions/task/poll", () => ({ + pollTaskUntilSettled: pollTaskUntilSettledMock, +})); +vi.mock("@/lib/registry/access.server", () => ({ + evaluateRegistryAccess: evaluateAccessMock, + evaluateRegistryProviderAccess: evaluateProviderAccessMock, +})); + +import { + addRegistryArtifact, + confirmRegistryArtifactAddition, + disconnectRegistryCredential, + getRegistryBootstrap, + getInstalledRegistryProviderOptions, + refreshRegistryCollections, + removeRegistryArtifact, + refreshRegistryCredential, + submitRegistryCredential, +} from "./registry"; + +const activeCredential = { + configured: true, + isValid: true, + scopes: ["catalog:read"], + validationPending: false, +}; +const noCredential = { + configured: false, + isValid: false, + scopes: [], + validationPending: false, +}; +const pendingCredential = { + configured: true, + isValid: false, + scopes: [], + validationPending: true, +}; + +const jsonResponse = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/vnd.api+json" }, + }); +const credentialResponse = (credential = activeCredential) => + jsonResponse({ + data: { + attributes: { + configured: credential.configured, + is_valid: credential.isValid, + scopes: credential.scopes, + validation_pending: credential.validationPending, + }, + }, + }); +const tenantArtifactsResponse = () => + jsonResponse({ + data: [ + { + type: "registry-artifacts", + id: "prowler-aws", + attributes: { + version_spec: "latest", + inserted_at: "2026-03-20T12:00:00Z", + }, + }, + ], + }); +const catalogResponse = () => + jsonResponse({ + data: [ + { + type: "registry-artifacts", + id: "prowler-aws", + attributes: { name: "Prowler AWS", providers: ["aws"] }, + }, + ], + meta: { pagination: { page: 1, pages: 1, count: 1 } }, + }); + +beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + authMock.mockResolvedValue({ accessToken: "access-token" }); + evaluateAccessMock.mockResolvedValue({ status: "eligible" }); + evaluateProviderAccessMock.mockResolvedValue({ status: "eligible" }); + fetchMock.mockReset(); + pollTaskUntilSettledMock.mockReset(); +}); + +function mockRequestDeadlines() { + // Native AbortSignal.timeout uses real timers; drive it with the test clock. + vi.spyOn(AbortSignal, "timeout").mockImplementation((milliseconds) => { + const controller = new AbortController(); + setTimeout(() => controller.abort(), milliseconds); + return controller.signal; + }); +} + +describe("installed Registry provider discovery", () => { + function mockDiscovery({ + emptyMetadata = false, + failedEndpoint, + failureStatus = 500, + }: { + emptyMetadata?: boolean; + failedEndpoint?: string; + failureStatus?: number; + } = {}) { + fetchMock.mockImplementation((url: string) => { + const endpoint = new URL(url).pathname.split("/").pop(); + if (endpoint === failedEndpoint) return jsonResponse({}, failureStatus); + if (endpoint === "available-artifacts") + return jsonResponse({ + data: [ + { + type: "registry-artifacts", + id: "acme-package", + attributes: { + name: "Acme package", + providers: ["acme"], + has_provider: true, + }, + }, + ], + meta: { pagination: { page: 1, pages: 1, count: 1 } }, + }); + if (endpoint === "artifacts") + return jsonResponse({ + data: [ + { + type: "registry-artifacts", + id: "acme-package", + attributes: { version_spec: "latest" }, + }, + ], + }); + if (endpoint === "providers") + return jsonResponse({ + data: emptyMetadata + ? [] + : [ + { + id: "acme", + attributes: { + name: "Acme Cloud", + logo_url: "https://media.registry.test/acme.svg", + }, + }, + ], + }); + throw new Error(`Unexpected endpoint: ${endpoint}`); + }); + } + + it("joins catalog declarations, installed membership and provider metadata", async () => { + mockDiscovery(); + expect(await getInstalledRegistryProviderOptions()).toEqual({ + status: "ready", + options: [ + { + type: "acme", + label: "Acme Cloud", + logoUrl: "https://media.registry.test/acme.svg", + }, + ], + }); + }); + + it("allows installed-provider discovery without Registry management access", async () => { + // Given + mockDiscovery({ emptyMetadata: true }); + evaluateAccessMock.mockResolvedValue({ status: "ineligible" }); + // When / Then + expect(await getInstalledRegistryProviderOptions()).toEqual({ + status: "ready", + options: [{ type: "acme", label: "Acme package" }], + }); + expect(evaluateProviderAccessMock).toHaveBeenCalledWith("access-token"); + expect(evaluateAccessMock).not.toHaveBeenCalled(); + }); + + it.each([ + ["ineligible", "access_denied"], + ["unknown", "unknown"], + ] as const)( + "maps installed-provider access %s to %s", + async (status, expectedStatus) => { + // Given + evaluateProviderAccessMock.mockResolvedValue({ status }); + + // When + const result = await getInstalledRegistryProviderOptions(); + + // Then + expect(result).toEqual({ status: expectedStatus }); + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); + + it("uses the declared provider and artifact name when metadata is empty", async () => { + mockDiscovery({ emptyMetadata: true }); + expect(await getInstalledRegistryProviderOptions()).toEqual({ + status: "ready", + options: [{ type: "acme", label: "Acme package" }], + }); + }); + + it.each(["available-artifacts", "artifacts", "providers"])( + "returns an error when the %s read fails", + async (failedEndpoint) => { + mockDiscovery({ failedEndpoint }); + expect(await getInstalledRegistryProviderOptions()).toEqual({ + status: "error", + }); + }, + ); + + it.each(["available-artifacts", "artifacts", "providers"])( + "preserves access denial from the %s read", + async (failedEndpoint) => { + mockDiscovery({ failedEndpoint, failureStatus: 403 }); + expect(await getInstalledRegistryProviderOptions()).toEqual({ + status: "access_denied", + }); + }, + ); +}); + +describe("Registry guarded reads", () => { + it("recovers when a Registry read stalls", async () => { + // Given: the upstream responds only when its request is aborted. + vi.useFakeTimers(); + try { + mockRequestDeadlines(); + fetchMock.mockImplementation( + (_url, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => + reject(init.signal?.reason), + ); + }), + ); + const settled = vi.fn(); + + // When + void refreshRegistryCredential().then(settled); + await vi.advanceTimersByTimeAsync(30_000); + + // Then + expect(settled).toHaveBeenCalledWith({ status: "error" }); + } finally { + vi.useRealTimers(); + } + }); + + it.each([ + [ + "credential submission", + () => submitRegistryCredential("registry-test-key"), + ], + ["credential disconnection", disconnectRegistryCredential], + [ + "artifact addition", + () => addRegistryArtifact({ normalizedName: "external-package" }), + ], + ["artifact removal", () => removeRegistryArtifact("external-package")], + ])("recovers when %s stalls", async (_name, action) => { + // Given: prerequisite reads succeed, but the mutation never responds. + vi.useFakeTimers(); + try { + mockRequestDeadlines(); + fetchMock.mockImplementation((url: string, init?: RequestInit) => { + if (init?.method === "POST" || init?.method === "DELETE") { + return new Promise((_resolve, reject) => { + init.signal?.addEventListener("abort", () => + reject(init.signal?.reason), + ); + }); + } + if (url.includes("available-artifacts")) { + return Promise.resolve( + jsonResponse({ + data: [ + { + type: "registry-artifacts", + id: "external-package", + attributes: { + has_provider: true, + is_builtin: false, + is_installable: true, + }, + }, + ], + meta: { pagination: { page: 1, pages: 1, count: 1 } }, + }), + ); + } + return Promise.resolve(credentialResponse(noCredential)); + }); + const settled = vi.fn(); + + // When + void action().then(settled); + await vi.advanceTimersByTimeAsync(30_000); + + // Then + expect(settled).toHaveBeenCalledWith({ status: "error" }); + } finally { + vi.useRealTimers(); + } + }); + + it("denies Registry management actions before any Registry endpoint call", async () => { + // Given + evaluateAccessMock.mockResolvedValue({ status: "ineligible" }); + const actions = [ + getRegistryBootstrap, + refreshRegistryCredential, + refreshRegistryCollections, + () => submitRegistryCredential("registry-test-key"), + disconnectRegistryCredential, + ]; + + // When + const results = await Promise.all(actions.map((action) => action())); + + // Then + expect(results).toEqual(actions.map(() => ({ status: "access_denied" }))); + expect(evaluateAccessMock).toHaveBeenCalledTimes(actions.length); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each(["unknown", "ineligible"])( + "does not fetch Registry collections when access is %s", + async (status) => { + // Given + evaluateAccessMock.mockResolvedValue({ status }); + + // When + const results = await Promise.all([ + getRegistryBootstrap(), + refreshRegistryCredential(), + refreshRegistryCollections(), + ]); + + // Then + expect(results).toEqual([ + { status: "access_denied" }, + { status: "access_denied" }, + { status: status === "unknown" ? "error" : "access_denied" }, + ]); + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); + + it("bootstraps in credential, tenant-artifact, then complete-catalog order", async () => { + // Given + fetchMock + .mockResolvedValueOnce(credentialResponse()) + .mockResolvedValueOnce(tenantArtifactsResponse()) + .mockResolvedValueOnce(catalogResponse()); + + // When + const result = await getRegistryBootstrap(); + + // Then + expect(result).toEqual({ + status: "ready", + state: { + status: "ready", + credential: activeCredential, + catalog: { + status: "complete", + artifacts: [ + expect.objectContaining({ normalizedName: "prowler-aws" }), + ], + }, + tenantArtifacts: [ + { + normalizedName: "prowler-aws", + versionSpec: "latest", + extendsProviderSlugs: [], + insertedAt: "2026-03-20T12:00:00Z", + }, + ], + }, + }); + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + "https://api.test/api/v1/registry/credential", + "https://api.test/api/v1/registry/artifacts", + "https://api.test/api/v1/registry/available-artifacts?page%5Bnumber%5D=1&page%5Bsize%5D=100", + ]); + fetchMock.mock.calls.forEach(([, options]) => { + expect(options).toMatchObject({ + cache: "no-store", + headers: { + Accept: "application/vnd.api+json", + Authorization: "Bearer access-token", + }, + }); + }); + }); + + it.each([ + [noCredential, "onboarding"], + [pendingCredential, "validation_pending"], + ] as const)( + "blocks catalog bootstrap as %s credential is authoritative", + async (credential, expectedStatus) => { + // Given + fetchMock + .mockResolvedValueOnce(credentialResponse(credential)) + .mockResolvedValueOnce(tenantArtifactsResponse()); + + // When + const result = await getRegistryBootstrap(); + + // Then + expect(result).toEqual({ + status: "ready", + state: { + status: expectedStatus, + credential, + tenantArtifacts: [ + { + normalizedName: "prowler-aws", + versionSpec: "latest", + extendsProviderSlugs: [], + insertedAt: "2026-03-20T12:00:00Z", + }, + ], + }, + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }, + ); + + it("returns only a non-secret status read after a fresh guard", async () => { + // Given + fetchMock.mockResolvedValueOnce(credentialResponse()); + + // When + const result = await refreshRegistryCredential(); + + // Then + expect(result).toEqual({ status: "status", credential: activeCredential }); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.test/api/v1/registry/credential", + expect.objectContaining({ cache: "no-store" }), + ); + }); + + it("returns fresh complete collections", async () => { + // Given + fetchMock + .mockResolvedValueOnce(catalogResponse()) + .mockResolvedValueOnce(tenantArtifactsResponse()); + + // When + const result = await refreshRegistryCollections(); + + // Then + expect(result).toEqual({ + status: "complete", + catalog: { + status: "complete", + artifacts: [expect.objectContaining({ normalizedName: "prowler-aws" })], + }, + tenantArtifacts: [ + { + normalizedName: "prowler-aws", + versionSpec: "latest", + extendsProviderSlugs: [], + insertedAt: "2026-03-20T12:00:00Z", + }, + ], + }); + expect(evaluateAccessMock).toHaveBeenCalledWith("access-token"); + }); + + it("maps a discovery 409 to onboarding after an authoritative no-credential read", async () => { + // Given + fetchMock + .mockResolvedValueOnce(jsonResponse({ errors: [] }, 409)) + .mockResolvedValueOnce(credentialResponse(noCredential)); + + // When + const result = await refreshRegistryCollections(); + + // Then + expect(result).toEqual({ status: "onboarding" }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("maps documented read recovery without exposing retained or partial catalog data", async () => { + // Given + fetchMock.mockResolvedValueOnce( + jsonResponse({ errors: [{ code: "registry_key_rejected" }] }, 502), + ); + + // When + const reconnect = await refreshRegistryCollections(); + + // Then + expect(reconnect).toEqual({ status: "reconnect" }); + + // Given + fetchMock.mockResolvedValueOnce( + jsonResponse({ errors: [{ code: "registry_unavailable" }] }, 503), + ); + + // When + const unavailable = await refreshRegistryCollections(); + + // Then + expect(unavailable).toEqual({ status: "unavailable" }); + expect(unavailable).not.toHaveProperty("catalog"); + + // Given + fetchMock.mockResolvedValueOnce( + jsonResponse({ errors: [{ code: "other_failure" }] }, 502), + ); + + // When + const generic = await refreshRegistryCollections(); + + // Then + expect(generic).toEqual({ status: "error" }); + }); + + it("keeps a transient access check failure retryable when refreshing collections", async () => { + // Given: the API cannot answer the permission check during a transient outage. + evaluateAccessMock.mockResolvedValueOnce({ status: "unknown" }); + + // When / Then: preserve the current page instead of treating the outage as revocation. + expect(await refreshRegistryCollections()).toEqual({ status: "error" }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("maps Registry 401 and 403 to access denial before any recovery classification", async () => { + // Given + fetchMock.mockResolvedValueOnce( + jsonResponse({ errors: [{ code: "registry_key_rejected" }] }, 401), + ); + + // When + const credential = await refreshRegistryCredential(); + + // Then + expect(credential).toEqual({ status: "access_denied" }); + + // Given + fetchMock.mockResolvedValueOnce( + jsonResponse({ errors: [{ code: "registry_unavailable" }] }, 403), + ); + + // When + const collections = await refreshRegistryCollections(); + + // Then + expect(collections).toEqual({ status: "access_denied" }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("rechecks access between separate actions after permission revocation", async () => { + // Given + evaluateAccessMock + .mockResolvedValueOnce({ status: "eligible" }) + .mockResolvedValueOnce({ status: "ineligible" }); + fetchMock.mockResolvedValueOnce(credentialResponse()); + + // When + const first = await refreshRegistryCredential(); + const second = await refreshRegistryCollections(); + + // Then + expect(first).toEqual({ status: "status", credential: activeCredential }); + expect(second).toEqual({ status: "access_denied" }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(evaluateAccessMock).toHaveBeenCalledTimes(2); + }); + + it("returns the accepted validation task immediately without server-side polling", async () => { + // Given + const key = " registry-test-key "; + fetchMock + .mockResolvedValueOnce(credentialResponse(noCredential)) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ data: { type: "tasks", id: "task-123" } }), + { + status: 202, + headers: { "Content-Location": "/api/v1/tasks/task-123" }, + }, + ), + ); + + // When + const result = await submitRegistryCredential(key); + + // Then + expect(result).toEqual({ + status: "submitted", + taskId: "task-123", + priorConfigured: false, + }); + expect(pollTaskUntilSettledMock).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + "https://api.test/api/v1/registry/credential", + expect.objectContaining({ + body: JSON.stringify({ + data: { + type: "registry-credentials", + attributes: { api_key: key.trim() }, + }, + }), + cache: "no-store", + method: "POST", + }), + ); + expect(JSON.stringify(result)).not.toContain(key); + }); + + it("marks an accepted replacement as superseding a configured credential", async () => { + // Given + fetchMock + .mockResolvedValueOnce(credentialResponse(activeCredential)) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ data: { type: "tasks", id: "task-456" } }), + { + status: 202, + headers: { "Content-Location": "/api/v1/tasks/task-456" }, + }, + ), + ); + + // When + const result = await submitRegistryCredential("registry-replacement-key"); + + // Then + expect(result).toEqual({ + status: "submitted", + taskId: "task-456", + priorConfigured: true, + }); + expect(pollTaskUntilSettledMock).not.toHaveBeenCalled(); + }); + + it("re-reads credential and preserves authoritative My artifacts after disconnect", async () => { + // Given + fetchMock + .mockResolvedValueOnce(new Response(null, { status: 204 })) + .mockResolvedValueOnce(credentialResponse(noCredential)) + .mockResolvedValueOnce(tenantArtifactsResponse()); + + // When + const result = await disconnectRegistryCredential(); + + // Then + expect(result).toEqual({ + status: "disconnected", + credential: noCredential, + tenantArtifacts: [ + { + normalizedName: "prowler-aws", + versionSpec: "latest", + extendsProviderSlugs: [], + insertedAt: "2026-03-20T12:00:00Z", + }, + ], + }); + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + "https://api.test/api/v1/registry/credential", + "https://api.test/api/v1/registry/credential", + "https://api.test/api/v1/registry/artifacts", + ]); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "https://api.test/api/v1/registry/credential", + expect.objectContaining({ cache: "no-store", method: "DELETE" }), + ); + }); + + it("rejects a task-binding mismatch without returning the key or a task", async () => { + // Given + const key = "registry-test-key"; + fetchMock + .mockResolvedValueOnce(credentialResponse(noCredential)) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ data: { type: "tasks", id: "task-123" } }), + { + status: 202, + headers: { "Content-Location": "/api/v1/tasks/other-task" }, + }, + ), + ); + + // When + const result = await submitRegistryCredential(key); + + // Then + expect(result).toEqual({ status: "error" }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(pollTaskUntilSettledMock).not.toHaveBeenCalled(); + expect(JSON.stringify(result)).not.toContain(key); + }); + + it("rejects malformed accepted task data without a task identity", async () => { + // Given + fetchMock + .mockResolvedValueOnce(credentialResponse(noCredential)) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ data: { type: "not-a-task", id: "task-123" } }), + { + status: 202, + headers: { "Content-Location": "/api/v1/tasks/task-123" }, + }, + ), + ); + + // When + const result = await submitRegistryCredential("registry-test-key"); + + // Then + expect(result).toEqual({ status: "error" }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(result).not.toHaveProperty("taskId"); + }); + + it("preserves an active credential after a rejected replacement", async () => { + // Given + const key = "registry-replacement-key"; + fetchMock + .mockResolvedValueOnce(credentialResponse(activeCredential)) + .mockResolvedValueOnce(jsonResponse({ errors: [] }, 500)); + + // When + const result = await submitRegistryCredential(key); + + // Then + expect(result).toEqual({ + status: "replacement_failed", + credential: activeCredential, + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(JSON.stringify(result)).not.toContain(key); + }); + + it("handles action authorization failures safely", async () => { + // Given + fetchMock + .mockResolvedValueOnce(credentialResponse(noCredential)) + .mockResolvedValueOnce(jsonResponse({ errors: [] }, 401)) + .mockResolvedValueOnce(jsonResponse({ errors: [] }, 403)); + + // When + const rejected = await submitRegistryCredential("registry-test-key"); + const disconnected = await disconnectRegistryCredential(); + + // Then + expect(rejected).toEqual({ status: "access_denied" }); + expect(disconnected).toEqual({ status: "access_denied" }); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); +}); + +const installCatalogMock = vi.fn(); +describe("Registry artifact mutations", () => { + beforeEach(() => { + installCatalogMock.mockImplementation(() => + jsonResponse({ + data: [ + { + type: "registry-available-artifacts", + id: "later-guard", + attributes: { + has_provider: true, + is_builtin: false, + is_installable: true, + providers: ["acme"], + }, + }, + ], + meta: { pagination: { page: 1, pages: 1, count: 1 } }, + }), + ); + vi.stubGlobal("fetch", (url: string, options?: RequestInit) => + url.includes("/available-artifacts") + ? installCatalogMock(url, options) + : fetchMock(url, options), + ); + }); + + it.each([ + [ + { + has_checks: true, + is_installable: false, + not_installable_reason: "checks_target_is_not_builtin", + }, + "Its checks are written for a provider this deployment does not ship.", + ], + [ + { + has_checks: true, + is_installable: false, + not_installable_reason: "a_code_from_a_newer_api", + }, + "This artifact cannot be installed in this deployment.", + ], + [ + { has_provider: true, is_builtin: false }, + "This artifact cannot be installed in this deployment.", + ], + ])( + "refuses what the API says cannot be installed before POST: %j", + async (attributes, message) => { + // Given + installCatalogMock.mockImplementation(() => + jsonResponse({ + data: [ + { + type: "registry-available-artifacts", + id: "later-guard", + attributes, + }, + ], + meta: { pagination: { page: 1, pages: 1, count: 1 } }, + }), + ); + // When + const result = await addRegistryArtifact({ + normalizedName: "later-guard", + }); + // Then + expect(result).toEqual({ status: "refused", message }); + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); + + it("submits a checks artifact that defines no provider once the API calls it installable", async () => { + // Given + installCatalogMock.mockImplementation(() => + jsonResponse({ + data: [ + { + type: "registry-available-artifacts", + id: "later-guard", + attributes: { + has_provider: false, + has_checks: true, + is_installable: true, + providers: ["aws"], + }, + }, + ], + meta: { pagination: { page: 1, pages: 1, count: 1 } }, + }), + ); + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ data: { type: "tasks", id: "task-1" } }), { + status: 202, + headers: { "Content-Location": "/api/v1/tasks/task-1" }, + }), + ); + + // When + const result = await addRegistryArtifact({ normalizedName: "later-guard" }); + + // Then + expect(result).toEqual({ status: "submitted", taskId: "task-1" }); + }); + + it("returns an accepted Add task without reading My artifacts", async () => { + // Given + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ data: { type: "tasks", id: "artifact-task" } }), + { + status: 202, + headers: { "Content-Location": "/api/v1/tasks/artifact-task" }, + }, + ), + ); + + // When + const result = await addRegistryArtifact({ + normalizedName: "later-guard", + versionSpec: " 2.0.0 ", + }); + + // Then + expect(result).toEqual({ status: "submitted", taskId: "artifact-task" }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "https://api.test/api/v1/registry/artifacts", + expect.objectContaining({ + body: JSON.stringify({ + data: { + type: "registry-artifacts", + attributes: { + normalized_name: "later-guard", + version_spec: "2.0.0", + }, + }, + }), + cache: "no-store", + method: "POST", + }), + ); + }); + + it("defaults Add to latest", async () => { + // Given + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ data: { type: "tasks", id: "artifact-task" } }), + { + status: 202, + headers: { "Content-Location": "/api/v1/tasks/artifact-task" }, + }, + ), + ); + + // When + const result = await addRegistryArtifact({ normalizedName: "later-guard" }); + + // Then + expect(result).toEqual({ status: "submitted", taskId: "artifact-task" }); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "https://api.test/api/v1/registry/artifacts", + expect.objectContaining({ + body: JSON.stringify({ + data: { + type: "registry-artifacts", + attributes: { + normalized_name: "later-guard", + version_spec: "latest", + }, + }, + }), + }), + ); + }); + + it("rejects invalid accepted task bindings without reading My artifacts", async () => { + // Given + const document = JSON.stringify({ + data: { type: "tasks", id: "artifact-task" }, + }); + fetchMock + .mockResolvedValueOnce( + new Response(JSON.stringify({ data: { type: "tasks" } }), { + status: 202, + headers: { "Content-Location": "/api/v1/tasks/artifact-task" }, + }), + ) + .mockResolvedValueOnce(new Response(document, { status: 202 })) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ data: { type: "other", id: "artifact-task" } }), + { + status: 202, + headers: { "Content-Location": "/api/v1/tasks/artifact-task" }, + }, + ), + ) + .mockResolvedValueOnce( + new Response(document, { + status: 202, + headers: { "Content-Location": "/api/v1/tasks/other" }, + }), + ); + + // When + const outcomes = await Promise.all( + ["missing-id", "missing-location", "wrong-type", "wrong-location"].map( + () => addRegistryArtifact({ normalizedName: "later-guard" }), + ), + ); + + // Then + expect(outcomes).toEqual(Array(4).fill({ status: "error" })); + expect(fetchMock).toHaveBeenCalledTimes(4); + }); + + it("keeps a missing Registry credential synchronous", async () => { + // Given + fetchMock.mockResolvedValueOnce(jsonResponse({ errors: [] }, 409)); + + // When + const outcome = await addRegistryArtifact({ + normalizedName: "later-guard", + }); + + // Then + expect(outcome).toEqual({ status: "onboarding" }); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it.each([ + ["registry_artifact_not_found", "This artifact is no longer available."], + ["version_yanked", "This version is no longer available."], + [ + "version_not_verified", + "This version is not verified and cannot be added.", + ], + ["version_not_processed", "This version is not ready to add yet."], + ["version_not_found", "This version is not available."], + ["no_installable_version", "No available version can be added."], + ])("keeps membership unchanged for %s", async (code, message) => { + // Given + fetchMock.mockResolvedValueOnce( + jsonResponse( + { errors: [{ code }] }, + code === "registry_artifact_not_found" ? 404 : 400, + ), + ); + + // When + const result = await addRegistryArtifact({ + normalizedName: "later-guard", + versionSpec: "2.0.0", + }); + + // Then + expect(result).toEqual({ status: "refused", message }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it.each([ + ["registry_artifact_in_use", "in_use"], + ["registry_artifact_busy", "busy"], + ])( + "tells a Remove 409 %s apart as %s without refreshing membership", + async (code, expected) => { + // Given + fetchMock.mockResolvedValueOnce( + jsonResponse({ errors: [{ code }] }, 409), + ); + + // When + const result = await removeRegistryArtifact("aws-guard"); + + // Then + expect(result).toEqual({ status: expected }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }, + ); + + it("never asks someone to delete providers over a Remove 409 it cannot identify", async () => { + // Given + fetchMock.mockResolvedValueOnce(new Response(null, { status: 409 })); + + // When + const result = await removeRegistryArtifact("aws-guard"); + + // Then + expect(result).toEqual({ status: "error" }); + }); + + it.each([ + [401, "access_denied"], + [403, "access_denied"], + [400, "error"], + [500, "error"], + ])("preserves the Remove failure for HTTP %s", async (status, expected) => { + // Given + fetchMock.mockResolvedValueOnce(new Response(null, { status })); + + // When + const result = await removeRegistryArtifact("aws-guard"); + + // Then + expect(result).toEqual({ status: expected }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("reports a Remove network failure without refreshing membership", async () => { + // Given + fetchMock.mockRejectedValueOnce(new TypeError("Failed to fetch")); + + // When + const result = await removeRegistryArtifact("aws-guard"); + + // Then + expect(result).toEqual({ status: "error" }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("encodes the deletion identity and confirms Remove after an absent refresh", async () => { + // Given + fetchMock + .mockResolvedValueOnce(new Response(null, { status: 204 })) + .mockResolvedValueOnce(jsonResponse({ data: [] })); + + // When + const result = await removeRegistryArtifact("guard/with space"); + + // Then + expect(result).toEqual({ status: "confirmed", tenantArtifacts: [] }); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "https://api.test/api/v1/registry/artifacts/guard%2Fwith%20space", + expect.objectContaining({ cache: "no-store", method: "DELETE" }), + ); + }); + + it.each(["1.0.0", null, undefined])( + "does not confirm an update when the installed version is %j", + async (resolvedVersion) => { + // Given + fetchMock.mockResolvedValueOnce( + jsonResponse({ + data: [ + { + type: "registry-artifacts", + id: "template", + attributes: { + version_spec: "latest", + resolved_version: resolvedVersion, + }, + }, + ], + }), + ); + // When + const result = await confirmRegistryArtifactAddition("template", "1.1.0"); + // Then + expect(result).toEqual({ status: "refresh_failed" }); + }, + ); + + it("confirms an update only after reading its resolved target version", async () => { + // Given + fetchMock.mockResolvedValueOnce( + jsonResponse({ + data: [ + { + type: "registry-artifacts", + id: "template", + attributes: { version_spec: "latest", resolved_version: "1.1.0" }, + }, + ], + }), + ); + // When / Then + expect( + await confirmRegistryArtifactAddition("template", "1.1.0"), + ).toMatchObject({ + status: "confirmed", + tenantArtifacts: [ + { normalizedName: "template", resolvedVersion: "1.1.0" }, + ], + }); + }); + + it.each([ + [ + "Add", + () => addRegistryArtifact({ normalizedName: "later-guard" }), + { data: [] }, + { status: "error" }, + 1, + ], + [ + "Remove", + () => removeRegistryArtifact("later-guard"), + { + data: [ + { + type: "registry-artifacts", + id: "later-guard", + attributes: { version_spec: "latest" }, + }, + ], + }, + { status: "refresh_failed" }, + 2, + ], + ])( + "keeps membership unchanged when %s refresh contradicts acceptance", + async (_name, mutate, refreshedArtifacts, expected, calls) => { + // Given + fetchMock + .mockResolvedValueOnce(new Response(null, { status: 204 })) + .mockResolvedValueOnce(jsonResponse(refreshedArtifacts)); + + // When + const result = await mutate(); + + // Then + expect(result).toEqual(expected); + expect(fetchMock).toHaveBeenCalledTimes(calls); + }, + ); +}); diff --git a/ui/actions/registry/registry.ts b/ui/actions/registry/registry.ts new file mode 100644 index 0000000000..976373a965 --- /dev/null +++ b/ui/actions/registry/registry.ts @@ -0,0 +1,583 @@ +"use server"; + +import { z } from "zod"; + +import { auth } from "@/auth.config"; +import { apiBaseUrl } from "@/lib"; +import { REGISTRY_ACCESS } from "@/lib/registry/access"; +import { + evaluateRegistryAccess, + evaluateRegistryProviderAccess, +} from "@/lib/registry/access.server"; +import { isActiveRegistryCredential } from "@/lib/registry/credential-task"; +import { getRegistryNotInstallableMessage } from "@/lib/registry/installability"; +import { + buildRegistryProviderOptions, + REGISTRY_PROVIDER_DISCOVERY, + type RegistryProviderDiscoveryResult, +} from "@/lib/registry/provider-options"; +import { + REGISTRY_ARTIFACT_ACTION, + REGISTRY_BOOTSTRAP_STATE, + REGISTRY_CATALOG, + REGISTRY_CREDENTIAL_ACTION, + REGISTRY_CREDENTIAL_READ, + REGISTRY_ENDPOINT, + REGISTRY_FAILURE, + REGISTRY_MUTATION, + REGISTRY_SUBMISSION, + type RegistryAddArtifactInput, + type RegistryArtifactRemovalResult, + type RegistryBootstrapResult, + type RegistryBootstrapState, + type RegistryCollectionsResult, + type RegistryCredentialActionResult, + type RegistryCredentialReadResult, + type RegistryCredentialStatus, + type RegistryCredentialSubmitResult, + type RegistryFailureResult, + type RegistryMutationResult, +} from "@/types/registry"; + +import { + adaptRegistryCredentialStatus, + adaptRegistryTenantArtifacts, + classifyRegistryFailure, + classifyRegistryMutationRefusal, + classifyRegistryRemovalConflict, + collectCompleteRegistryCatalog, + isRegistryCollection, + parseRegistryArtifactSubmission, + parseRegistryCredentialSubmission, + RegistryCatalogPageError, +} from "./registry.adapter"; + +const REGISTRY_REQUEST_TIMEOUT_MS = 15_000; + +async function getRegistryAccess(): Promise { + const accessToken = (await auth())?.accessToken; + const access = await evaluateRegistryAccess(accessToken); + return access.status === REGISTRY_ACCESS.ELIGIBLE && accessToken?.trim() + ? accessToken + : null; +} + +async function readRegistryResponse( + accessToken: string, + resource: string, + endpoint: (typeof REGISTRY_ENDPOINT)[keyof typeof REGISTRY_ENDPOINT], + credential: RegistryCredentialStatus | null = null, + searchParams?: URLSearchParams, +): Promise { + const url = new URL(`${apiBaseUrl}/registry/${resource}`); + if (searchParams) url.search = searchParams.toString(); + + let response: Response; + try { + response = await fetch(url.toString(), { + cache: "no-store", + signal: AbortSignal.timeout(REGISTRY_REQUEST_TIMEOUT_MS), + headers: { + Accept: "application/vnd.api+json", + Authorization: `Bearer ${accessToken}`, + }, + }); + } catch { + return { status: REGISTRY_FAILURE.ERROR }; + } + if (response.ok) return response; + + return endpoint === REGISTRY_ENDPOINT.PROVIDERS || + endpoint === REGISTRY_ENDPOINT.AVAILABLE_ARTIFACTS + ? classifyDiscoveryFailure(response, endpoint, accessToken, credential) + : classifyRegistryFailure(response, endpoint, credential); +} + +async function readRegistryCredential(accessToken: string) { + const result = await readRegistryResponse( + accessToken, + "credential", + REGISTRY_ENDPOINT.CREDENTIAL, + ); + if (!(result instanceof Response)) return result; + + const credential = adaptRegistryCredentialStatus( + await result.json().catch(() => undefined), + ); + return credential + ? { status: REGISTRY_CREDENTIAL_READ.STATUS, credential } + : { status: REGISTRY_FAILURE.ERROR }; +} + +async function readRegistryTenantArtifacts(accessToken: string) { + const result = await readRegistryResponse( + accessToken, + "artifacts", + REGISTRY_ENDPOINT.MUTATION, + ); + if (!(result instanceof Response)) return result; + + const tenantArtifacts = adaptRegistryTenantArtifacts( + await result.json().catch(() => undefined), + ); + return tenantArtifacts + ? { status: "ready" as const, tenantArtifacts } + : { status: REGISTRY_FAILURE.ERROR }; +} + +async function classifyDiscoveryFailure( + response: Response, + endpoint: + | typeof REGISTRY_ENDPOINT.PROVIDERS + | typeof REGISTRY_ENDPOINT.AVAILABLE_ARTIFACTS, + accessToken: string, + credential: RegistryCredentialStatus | null, +) { + if (response.status === 409 && credential === null) { + const currentCredential = await readRegistryCredential(accessToken); + if (currentCredential.status === REGISTRY_FAILURE.ACCESS_DENIED) { + return currentCredential; + } + credential = + currentCredential.status === REGISTRY_CREDENTIAL_READ.STATUS + ? currentCredential.credential + : null; + } + return classifyRegistryFailure(response, endpoint, credential); +} + +async function readRegistryProviders( + accessToken: string, + credential: RegistryCredentialStatus | null, +) { + const result = await readRegistryResponse( + accessToken, + "providers", + REGISTRY_ENDPOINT.PROVIDERS, + credential, + ); + if (!(result instanceof Response)) return result; + const payload = await result.json().catch(() => undefined); + const metadata = z + .object({ + data: z.array( + z.object({ + id: z.string(), + attributes: z + .object({ + name: z.string().optional(), + logo_url: z.string().nullable().optional(), + }) + .optional(), + }), + ), + }) + .safeParse(payload); + return isRegistryCollection(payload) + ? { + status: "ready" as const, + providers: metadata.success + ? metadata.data.data.map((provider) => ({ + type: provider.id, + label: provider.attributes?.name || provider.id, + ...(provider.attributes?.logo_url + ? { logoUrl: provider.attributes.logo_url } + : {}), + })) + : [], + } + : { status: REGISTRY_FAILURE.ERROR }; +} + +export async function getInstalledRegistryProviderOptions(): Promise { + const access = (await auth())?.accessToken; + const permission = await evaluateRegistryProviderAccess(access); + if (permission.status === REGISTRY_ACCESS.UNKNOWN) + return { status: REGISTRY_PROVIDER_DISCOVERY.UNKNOWN }; + if (!access || permission.status !== REGISTRY_ACCESS.ELIGIBLE) + return { status: REGISTRY_PROVIDER_DISCOVERY.ACCESS_DENIED }; + const [catalog, installed, providers] = await Promise.all([ + readCompleteRegistryCatalog(access, null), + readRegistryTenantArtifacts(access), + readRegistryProviders(access, null), + ]); + if ( + [catalog.status, installed.status, providers.status].some( + (status) => status === REGISTRY_FAILURE.ACCESS_DENIED, + ) + ) + return { status: REGISTRY_PROVIDER_DISCOVERY.ACCESS_DENIED }; + if ( + catalog.status !== REGISTRY_CATALOG.COMPLETE || + installed.status !== "ready" || + providers.status !== "ready" + ) + return { status: REGISTRY_PROVIDER_DISCOVERY.ERROR }; + return { + status: REGISTRY_PROVIDER_DISCOVERY.READY, + options: buildRegistryProviderOptions( + catalog.artifacts, + installed.tenantArtifacts, + providers.providers, + ), + }; +} + +async function readCompleteRegistryCatalog( + accessToken: string, + credential: RegistryCredentialStatus | null, +) { + try { + return await collectCompleteRegistryCatalog(async (_page, searchParams) => { + const result = await readRegistryResponse( + accessToken, + "available-artifacts", + REGISTRY_ENDPOINT.AVAILABLE_ARTIFACTS, + credential, + searchParams, + ); + if (!(result instanceof Response)) + throw new RegistryCatalogPageError(result); + return result.json(); + }); + } catch (error) { + return error instanceof RegistryCatalogPageError + ? error.failure + : { status: REGISTRY_FAILURE.ERROR }; + } +} + +async function confirmRegistryMutation( + accessToken: string, + normalizedName: string, + shouldBePresent: boolean, + expectedVersion?: string, +): Promise { + const tenantArtifacts = await readRegistryTenantArtifacts(accessToken); + if (tenantArtifacts.status === REGISTRY_FAILURE.ACCESS_DENIED) + return tenantArtifacts; + if ( + tenantArtifacts.status !== "ready" || + tenantArtifacts.tenantArtifacts.some( + (artifact) => artifact.normalizedName === normalizedName, + ) !== shouldBePresent || + (expectedVersion !== undefined && + tenantArtifacts.tenantArtifacts.find( + (artifact) => artifact.normalizedName === normalizedName, + )?.resolvedVersion !== expectedVersion.trim()) + ) { + return { status: "refresh_failed" }; + } + return { + status: "confirmed", + tenantArtifacts: tenantArtifacts.tenantArtifacts, + }; +} + +function bootstrapReady( + state: RegistryBootstrapState, +): RegistryBootstrapResult { + return { status: REGISTRY_BOOTSTRAP_STATE.READY, state }; +} + +function bootstrapFailure( + failure: RegistryFailureResult, +): RegistryBootstrapResult { + if (failure.status === REGISTRY_FAILURE.ACCESS_DENIED) { + return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + } + return bootstrapReady({ + status: + failure.status === REGISTRY_FAILURE.ONBOARDING + ? REGISTRY_BOOTSTRAP_STATE.ERROR + : failure.status, + }); +} + +export async function getRegistryBootstrap(): Promise { + const access = await getRegistryAccess(); + if (!access) return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + + const credentialRead = await readRegistryCredential(access); + if (credentialRead.status !== REGISTRY_CREDENTIAL_READ.STATUS) { + return bootstrapFailure(credentialRead); + } + const tenantArtifactsRead = await readRegistryTenantArtifacts(access); + if (tenantArtifactsRead.status !== "ready") { + return bootstrapFailure(tenantArtifactsRead); + } + + const { credential } = credentialRead; + const { tenantArtifacts } = tenantArtifactsRead; + if (!isActiveRegistryCredential(credential)) { + return bootstrapReady({ + status: credential.validationPending + ? REGISTRY_BOOTSTRAP_STATE.VALIDATION_PENDING + : REGISTRY_BOOTSTRAP_STATE.ONBOARDING, + credential, + tenantArtifacts, + }); + } + + const catalog = await readCompleteRegistryCatalog(access, credential); + if (catalog.status === REGISTRY_FAILURE.ACCESS_DENIED) { + return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + } + if (catalog.status === REGISTRY_CATALOG.INCOMPLETE) { + return bootstrapReady({ + status: REGISTRY_BOOTSTRAP_STATE.INCOMPLETE, + catalog, + }); + } + if (catalog.status !== REGISTRY_CATALOG.COMPLETE) { + return bootstrapFailure(catalog); + } + + return bootstrapReady({ + status: REGISTRY_BOOTSTRAP_STATE.READY, + credential, + catalog, + tenantArtifacts, + }); +} + +export async function refreshRegistryCredential(): Promise { + const access = await getRegistryAccess(); + if (!access) return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + return readRegistryCredential(access); +} + +export async function refreshRegistryCollections(): Promise { + const access = (await auth())?.accessToken; + const permission = await evaluateRegistryAccess(access); + if (permission.status === REGISTRY_ACCESS.UNKNOWN) + return { status: REGISTRY_FAILURE.ERROR }; + if (permission.status !== REGISTRY_ACCESS.ELIGIBLE || !access?.trim()) + return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + + const catalog = await readCompleteRegistryCatalog(access, null); + if (catalog.status !== REGISTRY_CATALOG.COMPLETE) return catalog; + const tenantArtifactsRead = await readRegistryTenantArtifacts(access); + return tenantArtifactsRead.status === "ready" + ? { + status: REGISTRY_CATALOG.COMPLETE, + catalog, + tenantArtifacts: tenantArtifactsRead.tenantArtifacts, + } + : tenantArtifactsRead; +} + +export async function addRegistryArtifact({ + normalizedName, + versionSpec, +}: RegistryAddArtifactInput): Promise { + const access = await getRegistryAccess(); + if (!access) return { status: REGISTRY_FAILURE.ACCESS_DENIED } as const; + if ( + typeof normalizedName !== "string" || + !normalizedName.trim() || + (versionSpec !== undefined && typeof versionSpec !== "string") + ) + return { status: REGISTRY_FAILURE.ERROR }; + const catalog = await readCompleteRegistryCatalog(access, null); + if (catalog.status !== REGISTRY_CATALOG.COMPLETE) { + return catalog.status === REGISTRY_CATALOG.INCOMPLETE + ? { status: REGISTRY_FAILURE.ERROR } + : catalog; + } + const artifact = catalog.artifacts.find( + (entry) => entry.normalizedName === normalizedName, + ); + if (!artifact?.isInstallable) + return { + status: REGISTRY_MUTATION.REFUSED, + message: getRegistryNotInstallableMessage(artifact?.notInstallableReason), + }; + const selectedVersion = versionSpec?.trim() || "latest"; + + let response: Response; + try { + response = await fetch(`${apiBaseUrl}/registry/artifacts`, { + method: "POST", + cache: "no-store", + signal: AbortSignal.timeout(REGISTRY_REQUEST_TIMEOUT_MS), + headers: { + Accept: "application/vnd.api+json", + "Content-Type": "application/vnd.api+json", + Authorization: `Bearer ${access}`, + }, + body: JSON.stringify({ + data: { + type: "registry-artifacts", + attributes: { + normalized_name: normalizedName, + version_spec: selectedVersion, + }, + }, + }), + }); + } catch { + return { status: REGISTRY_FAILURE.ERROR } as const; + } + if (response.status === 401 || response.status === 403) { + return { status: REGISTRY_FAILURE.ACCESS_DENIED } as const; + } + if (response.status === 409) { + return { status: REGISTRY_FAILURE.ONBOARDING }; + } + if (!response.ok) { + return ( + (await classifyRegistryMutationRefusal(response)) ?? { + status: REGISTRY_FAILURE.ERROR, + } + ); + } + + const submission = await parseRegistryArtifactSubmission(response); + return submission.status === REGISTRY_SUBMISSION.PENDING + ? { status: REGISTRY_ARTIFACT_ACTION.SUBMITTED, taskId: submission.taskId } + : { status: REGISTRY_FAILURE.ERROR }; +} + +export async function confirmRegistryArtifactAddition( + normalizedName: string, + expectedVersion?: string, +): Promise { + const access = await getRegistryAccess(); + if (!access) return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + if ( + expectedVersion !== undefined && + (typeof expectedVersion !== "string" || !expectedVersion.trim()) + ) { + return { status: REGISTRY_FAILURE.ERROR }; + } + return confirmRegistryMutation(access, normalizedName, true, expectedVersion); +} + +export async function removeRegistryArtifact( + normalizedName: string, +): Promise { + const access = await getRegistryAccess(); + if (!access) return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + + let response: Response; + try { + response = await fetch( + `${apiBaseUrl}/registry/artifacts/${encodeURIComponent(normalizedName)}`, + { + method: "DELETE", + cache: "no-store", + signal: AbortSignal.timeout(REGISTRY_REQUEST_TIMEOUT_MS), + headers: { + Accept: "application/vnd.api+json", + Authorization: `Bearer ${access}`, + }, + }, + ); + } catch { + return { status: REGISTRY_FAILURE.ERROR }; + } + if (response.status === 401 || response.status === 403) { + return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + } + if (response.status === 409) { + return ( + (await classifyRegistryRemovalConflict(response)) ?? { + status: REGISTRY_FAILURE.ERROR, + } + ); + } + if (!response.ok) return { status: REGISTRY_FAILURE.ERROR }; + + return confirmRegistryMutation(access, normalizedName, false); +} + +export async function submitRegistryCredential( + key: string, +): Promise { + const access = await getRegistryAccess(); + if (!access) return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + + const priorCredential = await readRegistryCredential(access); + if (priorCredential.status !== REGISTRY_CREDENTIAL_READ.STATUS) { + return priorCredential; + } + + let response: Response; + try { + response = await fetch(`${apiBaseUrl}/registry/credential`, { + method: "POST", + cache: "no-store", + signal: AbortSignal.timeout(REGISTRY_REQUEST_TIMEOUT_MS), + headers: { + Accept: "application/vnd.api+json", + "Content-Type": "application/vnd.api+json", + Authorization: `Bearer ${access}`, + }, + body: JSON.stringify({ + data: { + type: "registry-credentials", + attributes: { api_key: key.trim() }, + }, + }), + }); + } catch { + return { status: REGISTRY_FAILURE.ERROR }; + } + if (response.status === 401 || response.status === 403) { + return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + } + + // The task settles client-side through the task watcher; this action only + // hands back the verified task identity so the caller can watch it. + const submission = await parseRegistryCredentialSubmission(response); + if (submission.status !== REGISTRY_SUBMISSION.PENDING) { + return priorCredential.credential.configured + ? { + status: REGISTRY_CREDENTIAL_ACTION.REPLACEMENT_FAILED, + credential: priorCredential.credential, + } + : { status: REGISTRY_FAILURE.ERROR }; + } + + return { + status: REGISTRY_CREDENTIAL_ACTION.SUBMITTED, + taskId: submission.taskId, + priorConfigured: priorCredential.credential.configured, + }; +} + +export async function disconnectRegistryCredential(): Promise { + const access = await getRegistryAccess(); + if (!access) return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + + let response: Response; + try { + response = await fetch(`${apiBaseUrl}/registry/credential`, { + method: "DELETE", + cache: "no-store", + signal: AbortSignal.timeout(REGISTRY_REQUEST_TIMEOUT_MS), + headers: { + Accept: "application/vnd.api+json", + Authorization: `Bearer ${access}`, + }, + }); + } catch { + return { status: REGISTRY_FAILURE.ERROR }; + } + if (response.status === 401 || response.status === 403) { + return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + } + + const credential = await readRegistryCredential(access); + const tenantArtifacts = await readRegistryTenantArtifacts(access); + if (credential.status !== REGISTRY_CREDENTIAL_READ.STATUS) return credential; + if (tenantArtifacts.status !== "ready") return tenantArtifacts; + if (!response.ok) return { status: REGISTRY_FAILURE.ERROR }; + + return { + status: REGISTRY_CREDENTIAL_ACTION.DISCONNECTED, + credential: credential.credential, + tenantArtifacts: tenantArtifacts.tenantArtifacts, + }; +} diff --git a/ui/actions/roles/roles.test.ts b/ui/actions/roles/roles.test.ts index 3b253cce58..6c79579b99 100644 --- a/ui/actions/roles/roles.test.ts +++ b/ui/actions/roles/roles.test.ts @@ -50,6 +50,7 @@ const makeRoleFormData = () => { formData.set("manage_scans", "false"); formData.set("manage_alerts", "true"); formData.set("manage_lighthouse_ai_configuration", "true"); + formData.set("manage_registry", "true"); formData.set("unlimited_visibility", "false"); return formData; }; @@ -73,6 +74,36 @@ describe("role actions", () => { vi.unstubAllEnvs(); }); + it("includes manage_registry when creating and updating a role in Prowler Cloud", async () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "true"); + + // When + await addRole(makeRoleFormData()); + const createAttributes = lastRequestBody().data.attributes; + await updateRole(makeRoleFormData(), "role-1"); + const updateAttributes = lastRequestBody().data.attributes; + + // Then + expect(createAttributes.manage_registry).toBe(true); + expect(updateAttributes.manage_registry).toBe(true); + }); + + it("omits manage_registry when creating and updating a role outside Prowler Cloud", async () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "false"); + + // When + await addRole(makeRoleFormData()); + const createAttributes = lastRequestBody().data.attributes; + await updateRole(makeRoleFormData(), "role-1"); + const updateAttributes = lastRequestBody().data.attributes; + + // Then + expect(createAttributes).not.toHaveProperty("manage_registry"); + expect(updateAttributes).not.toHaveProperty("manage_registry"); + }); + it("includes manage_alerts when creating a role in Prowler Cloud", async () => { // Given vi.stubEnv("UI_CLOUD_ENABLED", "true"); diff --git a/ui/actions/roles/roles.ts b/ui/actions/roles/roles.ts index 972d6d12e8..00302165e9 100644 --- a/ui/actions/roles/roles.ts +++ b/ui/actions/roles/roles.ts @@ -116,6 +116,8 @@ export const addRole = async (formData: FormData) => { formData.get("manage_alerts") === "true"; payload.data.attributes.manage_lighthouse_ai_configuration = formData.get("manage_lighthouse_ai_configuration") === "true"; + payload.data.attributes.manage_registry = + formData.get("manage_registry") === "true"; } // Add provider groups relationships only if there are items @@ -175,6 +177,8 @@ export const updateRole = async (formData: FormData, roleId: string) => { formData.get("manage_alerts") === "true"; payload.data.attributes.manage_lighthouse_ai_configuration = formData.get("manage_lighthouse_ai_configuration") === "true"; + payload.data.attributes.manage_registry = + formData.get("manage_registry") === "true"; } // Add provider groups relationships only if there are items diff --git a/ui/app/(auth)/(guest-only)/sign-in/page.tsx b/ui/app/(auth)/(guest-only)/sign-in/page.tsx index c36226e61f..1ee3c37989 100644 --- a/ui/app/(auth)/(guest-only)/sign-in/page.tsx +++ b/ui/app/(auth)/(guest-only)/sign-in/page.tsx @@ -4,6 +4,7 @@ import { isGithubOAuthEnabled, isGoogleOAuthEnabled, } from "@/lib/helper"; +import { isSelfRegistrationEnabled } from "@/lib/shared/env"; const SignIn = () => { const GOOGLE_AUTH_URL = getAuthUrl("google"); @@ -15,6 +16,7 @@ const SignIn = () => { githubAuthUrl={GITHUB_AUTH_URL} isGoogleOAuthEnabled={isGoogleOAuthEnabled} isGithubOAuthEnabled={isGithubOAuthEnabled} + isSelfRegistrationEnabled={isSelfRegistrationEnabled()} /> ); }; diff --git a/ui/app/(auth)/(guest-only)/sign-up/page.test.tsx b/ui/app/(auth)/(guest-only)/sign-up/page.test.tsx new file mode 100644 index 0000000000..d1abc10660 --- /dev/null +++ b/ui/app/(auth)/(guest-only)/sign-up/page.test.tsx @@ -0,0 +1,86 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import SignUp from "./page"; + +const { redirectMock, isSelfRegistrationEnabledMock } = vi.hoisted(() => ({ + redirectMock: vi.fn(), + isSelfRegistrationEnabledMock: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + redirect: redirectMock, +})); + +vi.mock("@/lib/shared/env", () => ({ + isCloud: () => false, + isSelfRegistrationEnabled: isSelfRegistrationEnabledMock, +})); + +vi.mock("@/lib/helper", () => ({ + getAuthUrl: () => "", + isGithubOAuthEnabled: false, + isGoogleOAuthEnabled: false, +})); + +vi.mock("@/components/auth/oss", () => ({ + AuthForm: ({ invitationToken }: { invitationToken?: string | null }) => ( +
+ ), +})); + +const renderPage = (searchParams: Record = {}) => + SignUp({ searchParams: Promise.resolve(searchParams) }); + +describe("SignUp page", () => { + beforeEach(() => { + vi.clearAllMocks(); + // next/navigation's redirect() never returns; mirror that so the page + // stops rendering the way it does in Next. + redirectMock.mockImplementation((url: string) => { + throw new Error(`NEXT_REDIRECT:${url}`); + }); + }); + + describe("when self-registration is enabled", () => { + it("should render the sign-up form", async () => { + // Given + isSelfRegistrationEnabledMock.mockReturnValue(true); + + // When + render(await renderPage()); + + // Then + expect(screen.getByTestId("auth-form")).toBeInTheDocument(); + expect(redirectMock).not.toHaveBeenCalled(); + }); + }); + + describe("when self-registration is disabled", () => { + it("should redirect to sign-in without an invitation", async () => { + // Given + isSelfRegistrationEnabledMock.mockReturnValue(false); + + // When / Then + await expect(renderPage()).rejects.toThrow("NEXT_REDIRECT:/sign-in"); + }); + + it("should still render the form for an invited user", async () => { + // Given + isSelfRegistrationEnabledMock.mockReturnValue(false); + + // When + render(await renderPage({ invitation_token: "TESTING1234567" })); + + // Then + expect(screen.getByTestId("auth-form")).toHaveAttribute( + "data-invitation-token", + "TESTING1234567", + ); + expect(redirectMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/ui/app/(auth)/(guest-only)/sign-up/page.tsx b/ui/app/(auth)/(guest-only)/sign-up/page.tsx index 0415c6b0f3..884eedfa70 100644 --- a/ui/app/(auth)/(guest-only)/sign-up/page.tsx +++ b/ui/app/(auth)/(guest-only)/sign-up/page.tsx @@ -1,10 +1,12 @@ +import { redirect } from "next/navigation"; + import { AuthForm } from "@/components/auth/oss"; import { getAuthUrl, isGithubOAuthEnabled, isGoogleOAuthEnabled, } from "@/lib/helper"; -import { isCloud } from "@/lib/shared/env"; +import { isCloud, isSelfRegistrationEnabled } from "@/lib/shared/env"; import { SearchParamsProps } from "@/types"; const SignUp = async ({ @@ -17,6 +19,9 @@ const SignUp = async ({ typeof resolvedSearchParams?.invitation_token === "string" ? resolvedSearchParams.invitation_token : null; + if (!invitationToken && !isSelfRegistrationEnabled()) { + redirect("/sign-in"); + } const isCloudEnv = isCloud(); const GOOGLE_AUTH_URL = getAuthUrl("google"); diff --git a/ui/app/(prowler)/alerts/alerts-page.harness.ts b/ui/app/(prowler)/alerts/alerts-page.harness.ts index aab52fefa2..870a207434 100644 --- a/ui/app/(prowler)/alerts/alerts-page.harness.ts +++ b/ui/app/(prowler)/alerts/alerts-page.harness.ts @@ -384,7 +384,7 @@ export class AlertsPageHarness extends BrowserHarness { ).map((chip) => (chip.textContent ?? "").replace(/\s+/g, " ").trim()); } - /** The chip renders `#name` with an sr-only "Private" marker. */ + /** The chip renders `#name` followed by a "Private" badge when it is one. */ private static toChannelChip(text: string): SelectedChannelChip { return { isPrivate: /Private/.test(text), diff --git a/ui/app/(prowler)/integrations/slack/slack-integration.harness.ts b/ui/app/(prowler)/integrations/slack/slack-integration.harness.ts index b3b735cad0..3504f72ef5 100644 --- a/ui/app/(prowler)/integrations/slack/slack-integration.harness.ts +++ b/ui/app/(prowler)/integrations/slack/slack-integration.harness.ts @@ -15,6 +15,10 @@ import type { SlackFixture } from "@/__tests__/msw/handlers/slack.fixtures"; import { worker } from "@/__tests__/msw/worker"; import { render } from "@/__tests__/render-browser"; import { setSlackAuthorizedChannels } from "@/actions/integrations/slack"; +import { + CHECK_STATUS, + type CheckStatus, +} from "@/components/integrations/slack/slack-connection-check-status"; import { SLACK_CONNECT_PARAMS } from "@/lib/integrations/slack-connect-status"; import { IntegrationsContent } from "../integrations-content"; @@ -335,6 +339,69 @@ export class SlackIntegrationHarness extends BrowserHarness { ); } + async connectionSuccessToast(): Promise { + return this.waitFor( + () => this.toastText(/Connection test successful/), + 15000, + "the connection success toast", + ); + } + + /** + * The check's standing state, read from the card's own marker. The status is + * the card's alone: the toast reports a check that just ran and then goes, + * where this retires on its own once the channels it covered have moved. + */ + async connectionCheckStatus(): Promise { + const region = await this.waitFor( + () => this.q("[data-connection-check-status]"), + 10000, + "the connection check status", + ); + return region.getAttribute("data-connection-check-status") as CheckStatus; + } + + /** + * What the card says the last check found, or null when it shows no outcome + * at all. The element is always mounted — `sr-only` while empty — so an empty + * one has to read as "no outcome", not as an outcome that says nothing. + */ + connectionCheckOutcome(): string | null { + const outcome = this.q("[data-connection-check-outcome]"); + return (outcome?.textContent ?? "").replace(/\s+/g, " ").trim() || null; + } + + /** The same copy, waited for: the outcome lands a render after the answer. */ + async connectionCheckOutcomeText(): Promise { + return this.waitFor( + () => this.connectionCheckOutcome(), + 15000, + "the connection check outcome", + ); + } + + /** + * Wait until the card has taken its finding back — no outcome, and resting. + * Both, together: a status that says nothing while the line it decorated is + * still on screen would be the half-retired state the derivation exists to + * make impossible. + */ + async waitForRetiredConnectionCheck(): Promise { + await this.waitFor( + () => { + const status = this.q("[data-connection-check-status]")?.getAttribute( + "data-connection-check-status", + ); + return status === CHECK_STATUS.IDLE && + this.connectionCheckOutcome() === null + ? true + : null; + }, + 10000, + "the connection check finding to be retired", + ); + } + /** * The "last checked" line as rendered, or null when the page shows none — * which is what a workspace whose connection was never checked shows. @@ -342,7 +409,7 @@ export class SlackIntegrationHarness extends BrowserHarness { lastCheckedLine(): string | null { const line = Array.from( this.container.querySelectorAll("p"), - ).find((p) => /^Last checked:/.test((p.textContent ?? "").trim())); + ).find((p) => /^Last checked\b/.test((p.textContent ?? "").trim())); return line ? (line.textContent ?? "").trim() : null; } @@ -350,16 +417,24 @@ export class SlackIntegrationHarness extends BrowserHarness { return this.countRequests("POST", "/connection"); } - /** The outcome of a check under way, started by the button or by a save. */ + /** + * The outcome of a check under way, started by the button or by a save, read + * from the card's status. + * + * Deliberately not read from the copy: the toast titles the page has always + * raised are the only text saying "Connection test succeeded/failed", and + * they are portaled outside the card — so a reader going by text would settle + * on the toast and report an outcome the card never showed. The toasts have + * assertions of their own, which is what keeps the two surfaces independent. + */ async connectionOutcome(): Promise { return this.waitFor( () => { - if (this.containsText(/Connection test successful/)) { - return CONNECTION_OUTCOME.SUCCESS; - } - if (this.containsText(/Connection test failed/)) { - return CONNECTION_OUTCOME.FAILURE; - } + const status = this.q("[data-connection-check-status]")?.getAttribute( + "data-connection-check-status", + ); + if (status === CHECK_STATUS.PASSED) return CONNECTION_OUTCOME.SUCCESS; + if (status === CHECK_STATUS.FAILED) return CONNECTION_OUTCOME.FAILURE; return null; }, 15000, @@ -508,8 +583,8 @@ export class SlackIntegrationHarness extends BrowserHarness { /** * Re-read the workspace's channels, the way a user does after inviting - * `@Prowler` to one in Slack. Waits for the read to have settled, not for the - * click alone. + * `@Prowler Cloud` to one in Slack. Waits for the read to have settled, not + * for the click alone. */ async refreshChannels(): Promise { const readsBefore = this.channelListCallCount; @@ -783,11 +858,16 @@ export class SlackIntegrationHarness extends BrowserHarness { "the authorized channel chips", ); + // Order-insensitive: where the chip puts its "Private" marker relative to + // the name is a presentation choice, not something to assert through. return chips.map((chip) => { const text = (chip.textContent ?? "").trim(); return { - name: text.replace(/^Private/, "").replace(/^#/, ""), - isPrivate: /^Private/.test(text), + name: text + .replace(/Private/g, "") + .replace(/^#/, "") + .trim(), + isPrivate: /Private/.test(text), }; }); } @@ -825,14 +905,27 @@ export class SlackIntegrationHarness extends BrowserHarness { return this.containsText(/Could not read the workspace/); } + private channelInviteHintParagraph(): HTMLElement | null { + return ( + Array.from(this.container.querySelectorAll("p")).find( + (element) => /invites? @Prowler Cloud/.test(element.textContent ?? ""), + ) ?? null + ); + } + /** The invite copy that says how to make a private channel appear. */ channelInviteHint(): string | null { - const hint = Array.from( - this.container.querySelectorAll("p"), - ).find((element) => /invites? @Prowler/.test(element.textContent ?? "")); + const hint = this.channelInviteHintParagraph(); return hint ? (hint.textContent ?? "").trim() : null; } + /** Where the invite hint sends a user stuck on a missing private channel. */ + channelInviteHintDocsUrl(): string | null { + const link = + this.channelInviteHintParagraph()?.querySelector("a"); + return link ? link.href : null; + } + // --- Disconnecting ------------------------------------------------------ get disconnectCallCount(): number { diff --git a/ui/app/(prowler)/integrations/slack/slack-page.integration.test.tsx b/ui/app/(prowler)/integrations/slack/slack-page.integration.test.tsx index 05235da60c..96292509e1 100644 --- a/ui/app/(prowler)/integrations/slack/slack-page.integration.test.tsx +++ b/ui/app/(prowler)/integrations/slack/slack-page.integration.test.tsx @@ -205,8 +205,24 @@ describe("a connected workspace", () => { expect(await harness.connectedWorkspaceName()).toBe(WORKSPACE_NAME); expect(await harness.connectionBadge()).toBe("Connected"); + // Read positively as well as negatively (:254): without this, a reworded + // line the harness stops recognising would leave that null-only check + // passing vacuously. + expect(harness.lastCheckedLine()).toMatch(/2026\/08\/10/); expect(await harness.offersConnectionTest()).toBe(true); expect(await harness.testConnection()).toBe(CONNECTION_OUTCOME.SUCCESS); + // And — the card says what the check reached, naming the one channel it + // covered. Asserted off the card's own region, not the page's text: the + // toast below says "Connection test successful" too, so a text match would + // stand with this line never rendered. + expect(await harness.connectionCheckOutcomeText()).toBe( + `#${SLACK_PUBLIC_CHANNEL.name} is reachable.`, + ); + // And — the toast, which is the other half and goes on its own. Read + // separately so neither surface can vouch for the other. + expect(await harness.connectionSuccessToast()).toMatch( + /Connection test successful/, + ); // One workspace per tenant (design D10): no second install on offer, and no // consent URL minted for a page that would never use it. expect(harness.offersInstall()).toBe(false); @@ -356,8 +372,8 @@ describe("authorizing destination channels", () => { }, 60000); it("offers a private channel the app was invited to, marked as private, and authorizes it", async () => { - // Given — `@Prowler` was invited to one private channel; `groups:read` is - // membership-gated (D2). + // Given — `@Prowler Cloud` was invited to one private channel; + // `groups:read` is membership-gated (D2). const harness = new SlackIntegrationHarness(connectedSlackFixture()); await harness.mount(); @@ -381,7 +397,7 @@ describe("authorizing destination channels", () => { ]); }, 60000); - it("offers a private channel once @Prowler is invited to it and the list is refreshed", async () => { + it("offers a private channel once @Prowler Cloud is invited to it and the list is refreshed", async () => { // Given — a workspace whose only channels are public: `groups:read` is // membership-gated (design D2). const harness = new SlackIntegrationHarness( @@ -397,8 +413,8 @@ describe("authorizing destination channels", () => { SLACK_PRIVATE_CHANNEL.name, ); - // When — `@Prowler` is invited to a private channel, and the user refreshes - // instead of reconnecting the workspace. + // When — `@Prowler Cloud` is invited to a private channel, and the user + // refreshes instead of reconnecting the workspace. harness.fixture.channels.push({ ...SLACK_PRIVATE_CHANNEL }); await harness.refreshChannels(); @@ -423,7 +439,7 @@ describe("authorizing destination channels", () => { // Then — the user is told what to do, not merely that the list is empty. const message = await harness.channelPickerMessage(); expect(message).toMatch(/No channels available yet/); - expect(message).toMatch(/invite @Prowler/); + expect(message).toMatch(/invite @Prowler Cloud/); expect(await harness.authorizedChannels()).toEqual([]); expect(await harness.offersConnectionTest()).toBe(false); }, 30000); @@ -443,6 +459,12 @@ describe("authorizing destination channels", () => { // Then expect(await harness.connectionOutcome()).toBe(CONNECTION_OUTCOME.SUCCESS); expect(harness.connectionCheckCallCount).toBe(1); + // And — the card names the channel the chained check covered, which the + // save hands it directly: the set the check ran against is not on record + // as state yet when the summary is built. + expect(await harness.connectionCheckOutcomeText()).toBe( + `#${SLACK_PUBLIC_CHANNEL.name} is reachable.`, + ); // And — everything waiting on a destination moves with the save, in the // same paint: no reload to find the check on offer for later. expect(await harness.offersConnectionTest()).toBe(true); @@ -474,6 +496,11 @@ describe("authorizing destination channels", () => { // Then — only the check failed, so the destinations stay on record, and // the failure names the one channel Slack refused. expect(await harness.connectionOutcome()).toBe(CONNECTION_OUTCOME.FAILURE); + // The card and the toast each name it, and each is read where it lives: + // the toast goes, and what the card keeps is what the user comes back to. + expect(await harness.connectionCheckOutcomeText()).toMatch( + new RegExp(`^Slack refused #${SLACK_PRIVATE_CHANNEL.name}\\b`), + ); expect(await harness.connectionFailureToast()).toMatch( new RegExp(`Slack refused #${SLACK_PRIVATE_CHANNEL.name}`), ); @@ -516,6 +543,41 @@ describe("authorizing destination channels", () => { expect(harness.offersChannelsSave()).toBe(false); }, 60000); + it("takes back a passing result once the set it vouched for is no longer the set on record", async () => { + // Given — a check that passed against the one channel then authorized, said + // in the card in those terms. + const harness = new SlackIntegrationHarness(configuredSlackFixture()); + await harness.mount(); + expect(await harness.testConnection()).toBe(CONNECTION_OUTCOME.SUCCESS); + expect(await harness.connectionCheckOutcomeText()).toBe( + `#${SLACK_PUBLIC_CHANNEL.name} is reachable.`, + ); + + // When — a channel nothing has checked joins the record, from elsewhere, so + // no new check runs to overwrite the standing one. + await harness.channelsRecordedElsewhere([ + SLACK_PUBLIC_CHANNEL.name, + SLACK_SECOND_PUBLIC_CHANNEL.name, + ]); + await harness.refreshPageData(); + + // Then — the result is withdrawn rather than left standing: a line saying + // the channels are reachable, over a set one of them was never tried + // against, would be a claim the check never made. + await harness.waitForRetiredConnectionCheck(); + // And — the badge steps back with it, because the record does too: the API + // clears its own verdict on a changed set for the same reason the card + // clears the finding, so the two never disagree about what was checked. + expect(await harness.connectionBadge()).toBe("Not checked yet"); + // And — only the finding is withdrawn, not the setup: the wider set is on + // record, with the check still there to be run over it. + expect(await harness.authorizedChannels()).toEqual([ + SLACK_PUBLIC_CHANNEL.name, + SLACK_SECOND_PUBLIC_CHANNEL.name, + ]); + expect(await harness.offersConnectionTest()).toBe(true); + }, 60000); + it("says which permission is missing when Slack refuses the channel listing, leaving the authorized set alone", async () => { // Given — an authorized set, and an install missing a scope the listing // needs. The API names it in `code` (contract, Errors), not in `detail`. @@ -536,7 +598,7 @@ describe("authorizing destination channels", () => { // Slack's reason is a protocol token: it travels in `code` and is never // shown. expect(message).not.toMatch(SLACK_MISSING_SCOPE_CODE); - expect(harness.channelInviteHint()).toMatch(/invites @Prowler/); + expect(harness.channelInviteHint()).toMatch(/invites @Prowler Cloud/); // And — a listing Prowler could not read says nothing about the channels // already authorized. @@ -546,6 +608,20 @@ describe("authorizing destination channels", () => { expect(await harness.offersConnectionTest()).toBe(true); }, 30000); + it("sends a user stuck on a missing private channel to the docs section about it", async () => { + // Given — a connected workspace, so the picker's invite copy is on screen. + const harness = new SlackIntegrationHarness(connectedSlackFixture()); + + // When + await harness.mount(); + + // Then — the whole anchored URL: the anchor is the point of the link, and + // it is derived from a docs heading that a rewording would silently move. + expect(harness.channelInviteHintDocsUrl()).toBe( + "https://docs.prowler.com/user-guide/tutorials/prowler-app-slack-integration#why-a-private-channel-is-missing-from-the-channel-list", + ); + }, 30000); + it("names the wait Slack asked for when it rate limits the channel listing", async () => { // Given — `conversations.list` is Slack tier 2 and paginated (contract, // Errors); the `429` carries the wait in `Retry-After`. @@ -632,7 +708,7 @@ describe("authorizing destination channels", () => { ]); }, 30000); - it("says to invite @Prowler when Slack refuses a channel because the app is not in it", async () => { + it("says to invite @Prowler Cloud when Slack refuses a channel because the app is not in it", async () => { // Given — a private channel the app was removed from. The API validates the // set against Slack on the way in and refuses with `not_in_channel`. const harness = new SlackIntegrationHarness( @@ -649,7 +725,7 @@ describe("authorizing destination channels", () => { // Then — the one fix the user can carry out themselves, in Slack. expect(refusal).toMatch(/Prowler is not in that channel/); - expect(refusal).toMatch(/Invite @Prowler to it in Slack/); + expect(refusal).toMatch(/Invite @Prowler Cloud to it in Slack/); expect(refusal).not.toMatch(SLACK_NOT_IN_CHANNEL_CODE); // And — nothing was recorded, so there is still nothing to check against. @@ -657,7 +733,7 @@ describe("authorizing destination channels", () => { expect(await harness.offersConnectionTest()).toBe(false); }, 60000); - it("says the channel is gone, not that @Prowler needs inviting, when Slack no longer has it", async () => { + it("says the channel is gone, not that @Prowler Cloud needs inviting, when Slack no longer has it", async () => { // Given — a channel archived since the listing was read. The API's `detail` // is word-for-word the one for `not_in_channel`, so only `code` tells them // apart. @@ -677,7 +753,7 @@ describe("authorizing destination channels", () => { // channel that no longer exists. expect(refusal).toMatch(/no longer exists in the workspace/); expect(refusal).toMatch(/Choose another one/); - expect(refusal).not.toMatch(/Invite @Prowler/); + expect(refusal).not.toMatch(/Invite @Prowler Cloud/); expect(refusal).not.toMatch(SLACK_UNKNOWN_CHANNEL_DETAIL); expect(await harness.authorizedChannels()).toEqual([]); }, 60000); @@ -689,7 +765,7 @@ describe("authorizing destination channels", () => { // Then — checking again posts nothing: the confirmation is one-time // (design D7), not a fresh message every run. - expect(harness.connectionCheckHint()).toMatch(/nothing is posted/); + expect(harness.connectionCheckHint()).toMatch(/Nothing is posted/); expect(harness.connectionCheckHint()).not.toMatch(/test message/i); // When — a second channel is authorized. @@ -711,7 +787,7 @@ describe("authorizing destination channels", () => { expect(await harness.connectionOutcome()).toBe(CONNECTION_OUTCOME.SUCCESS); await harness.refreshPageData(); expect( - await harness.connectionCheckHintMatching(/nothing is posted/), + await harness.connectionCheckHintMatching(/Nothing is posted/), ).toMatch(/every authorized channel/); }, 60000); @@ -831,7 +907,7 @@ describe("authorizing destination channels", () => { // Given — a finished setup whose channel an earlier check confirmed. const harness = new SlackIntegrationHarness(configuredSlackFixture()); await harness.mount(); - expect(harness.connectionCheckHint()).toMatch(/nothing is posted/); + expect(harness.connectionCheckHint()).toMatch(/Nothing is posted/); // When — the same workspace is approved again. The exchange is the // callback route's doing (covered in `callback/route.test.ts`); here it diff --git a/ui/app/(prowler)/layout.tsx b/ui/app/(prowler)/layout.tsx index 4d0d06e092..4a6244ffec 100644 --- a/ui/app/(prowler)/layout.tsx +++ b/ui/app/(prowler)/layout.tsx @@ -6,6 +6,7 @@ import { ReactNode, Suspense } from "react"; import { getProviders } from "@/actions/providers"; import { getScansByState } from "@/actions/scans/scans"; +import { auth } from "@/auth.config"; import MainLayout from "@/components/layout/main-layout/main-layout"; import { OnboardingCheckpointWatcher, @@ -20,6 +21,8 @@ import { GlobalSidePanel } from "@/components/side-panel"; import { FeedbackSurvey } from "@/components/survey/feedback-survey"; import { fontMono, fontSans } from "@/config/fonts"; import { siteConfig } from "@/config/site"; +import { REGISTRY_ACCESS } from "@/lib/registry/access"; +import { evaluateRegistryAccess } from "@/lib/registry/access.server"; import { isCloud } from "@/lib/shared/env"; import { cn } from "@/lib/utils"; import { StoreInitializer } from "@/store/ui/store-initializer"; @@ -56,11 +59,21 @@ export default async function RootLayout({ // Skip Cloud-only onboarding fetches and orchestrators in OSS. const cloudEnabled = isCloud(); + // One-time server-side Registry gate per request: only an ELIGIBLE answer + // shows the sidebar entry; UNKNOWN and INELIGIBLE both hide it. Started + // here so it resolves in parallel with the Cloud onboarding fetches. + const registryAccessPromise = auth().then((session) => + evaluateRegistryAccess(session?.accessToken), + ); + // Fail-open: unknown scan state is treated as "has data" so the banner never blocks // progression on a fetch error. let hasCompletedScan = true; // Tri-state: true = has providers, false = zero providers, undefined = fetch failed (gate fails open). let hasProviders: boolean | undefined = false; + // Scopes the onboarding steps' local markers, so resolving them for one + // tenant does not silence them for another. + let tenantId: string | null = null; if (cloudEnabled) { const [providersData, scansByState] = await Promise.all([ @@ -76,8 +89,12 @@ export default async function RootLayout({ hasProviders = Array.isArray(providersData?.data) ? providersData.data.length > 0 : undefined; + tenantId = (await auth())?.tenantId ?? null; } + const registryEligible = + (await registryAccessPromise).status === REGISTRY_ACCESS.ELIGIBLE; + return ( @@ -98,12 +115,14 @@ export default async function RootLayout({ {/* Store uses boolean; gate receives tri-state to fail open on fetch errors. */} - + {cloudEnabled && ( <> {/* Single mount point so the watcher survives post-connect navigation. */} - + {/* Persistent banner shown only while a guided sequence is active. */} diff --git a/ui/app/(prowler)/registry/page.tsx b/ui/app/(prowler)/registry/page.tsx new file mode 100644 index 0000000000..d5e3ab63de --- /dev/null +++ b/ui/app/(prowler)/registry/page.tsx @@ -0,0 +1,26 @@ +import { redirect } from "next/navigation"; + +import { getRegistryBootstrap } from "@/actions/registry/registry"; +import { RegistryExplorer } from "@/components/registry/registry-explorer"; +import { ContentLayout } from "@/components/shadcn/content-layout/content-layout"; +import { getRegistryPresentation } from "@/lib/registry/presentation"; +import { readEnv } from "@/lib/runtime-env"; +import { REGISTRY_FAILURE } from "@/types/registry"; + +export const dynamic = "force-dynamic"; + +export default async function RegistryPage() { + const bootstrap = await getRegistryBootstrap(); + if (bootstrap.status === REGISTRY_FAILURE.ACCESS_DENIED) redirect("/profile"); + + return ( + + + + ); +} diff --git a/ui/app/(prowler)/scans/page.tsx b/ui/app/(prowler)/scans/page.tsx index a8330f64aa..4b308921e4 100644 --- a/ui/app/(prowler)/scans/page.tsx +++ b/ui/app/(prowler)/scans/page.tsx @@ -193,6 +193,9 @@ export default async function Scans({ const hasManageScansPermission = Boolean( session?.user?.permissions?.manage_scans, ); + const hasManageIngestionsPermission = Boolean( + session?.user?.permissions?.manage_ingestions, + ); const activeScanCount = await getActiveScanCount(resolvedSearchParams); // Mirrors ScansPageShell's launch gate: it only mounts the view-first-scan trigger // when Launch Scan is usable (manage_scans + a connected provider). Without the @@ -218,6 +221,7 @@ export default async function Scans({ providers={providers} providerGroups={providerGroups} hasManageScansPermission={hasManageScansPermission} + hasManageIngestionsPermission={hasManageIngestionsPermission} activeScanCount={activeScanCount} > ; +} + +export class ScansPageHarness extends BrowserHarness { + private maxInFlightStatusRequests = 0; + private mounted: ReturnType | null = null; + private releaseHeldStatusResponse: (() => void) | null = null; + private statusDelayMs = 0; + + async mount({ + hasManageIngestionsPermission = true, + hasManageScansPermission = false, + holdStatusResponse = false, + uploadRejection, + uploadDelayMs, + statusErrorAt, + statusDelayMs, + statusSequence, + }: MountOptions = {}): Promise { + const statusResponseGate = holdStatusResponse + ? new Promise((resolve) => { + this.releaseHeldStatusResponse = resolve; + }) + : undefined; + + worker.use( + ...handlersForIngestion(this.fixture, { + uploadRejection, + uploadDelayMs, + statusErrorAt, + statusDelayMs, + statusResponseGate, + statusSequence, + onStatusRequest: (inFlight) => { + this.maxInFlightStatusRequests = Math.max( + this.maxInFlightStatusRequests, + inFlight, + ); + }, + }), + ); + this.trackRequests(worker); + this.statusDelayMs = statusDelayMs ?? 0; + + this.mounted = render( + + + , + ); + } + + async leaveScansPage(): Promise { + const mounted = await this.mounted; + if (!mounted) throw new Error("leaveScansPage: the page is not mounted"); + mounted.unmount(); + this.mounted = null; + } + + hasImportFindingsAction(): boolean { + return this.buttonByText(/Import Findings/) !== null; + } + + async openImportFindings(): Promise { + await this.clickButton(/Import Findings/); + await this.waitForText(/Import findings/i); + } + + async selectFile(file: File): Promise { + const input = await this.waitFor(() => + this.container.querySelector('input[type="file"]'), + ); + await this.user.upload(input, file); + } + + async dropFile(file: File): Promise { + await this.attemptDrop(file); + await this.waitFor(() => + this.q('[data-testid="import-findings-dropzone"]')?.textContent?.includes( + file.name, + ), + ); + } + + /** Drop without waiting for the file to be taken: dropFile hangs on a refused drop. */ + async attemptDrop(file: File): Promise { + const dropzone = await this.waitFor(() => + this.q('[data-testid="import-findings-dropzone"] [role="button"]'), + ); + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file); + dropzone.dispatchEvent( + new DragEvent("drop", { bubbles: true, dataTransfer }), + ); + } + + /** Both halves matter: drop and keyboard gate on the zone, the file picker on the input. */ + isDropzoneFrozen(): boolean { + const zone = this.q( + '[data-testid="import-findings-dropzone"] [role="button"]', + ); + const input = this.container.querySelector( + '[data-testid="import-findings-dropzone"] input[type="file"]', + ); + return ( + zone?.getAttribute("aria-disabled") === "true" && input?.disabled === true + ); + } + + async activateDropzoneWithKeyboard(): Promise { + const dropzone = await this.waitFor(() => + this.q('[data-testid="import-findings-dropzone"] [role="button"]'), + ); + const input = await this.waitFor(() => + this.container.querySelector('input[type="file"]'), + ); + let opened = false; + input.addEventListener("click", () => { + opened = true; + }); + + dropzone.focus(); + await this.user.keyboard("[Enter]"); + return opened; + } + + isImportEnabled(): boolean { + return !this.buttonByText(/Start import/i)?.disabled; + } + + async waitForValidationMessage(message: RegExp): Promise { + await this.waitForText(message); + } + + async waitForUploadError(message: RegExp): Promise { + await this.waitForText(message); + } + + async waitForUploadInProgress(): Promise { + await this.waitFor( + () => this.buttonByText(/Importing/i), + 5000, + "the upload to report progress", + ); + } + + isUploadInProgress(): boolean { + const submit = this.buttonByText(/Importing/i); + return submit !== null && submit.disabled; + } + + async closeImportFindings(): Promise { + await this.user.keyboard("[Escape]"); + await this.waitFor(() => + this.q('[role="dialog"]') === null ? true : null, + ); + } + + async closeImmediatelyBeforeStatusCompletes(): Promise { + const closeButton = await this.waitForButton(/^Close$/i); + const releaseStatusResponse = this.releaseHeldStatusResponse; + if (!releaseStatusResponse) { + throw new Error( + "closeImmediatelyBeforeStatusCompletes: no status response is held", + ); + } + + closeButton.click(); + releaseStatusResponse(); + this.releaseHeldStatusResponse = null; + await this.waitFor(() => + this.q('[role="dialog"]') === null ? true : null, + ); + } + + selectedFileName(): string | null { + const dropzone = this.q('[data-testid="import-findings-dropzone"]'); + return dropzone?.textContent?.match(/[^\s]+\.json/i)?.[0] ?? null; + } + + async submitImport(): Promise { + await this.clickButton(/Start import/i); + } + + async retryUpload(): Promise { + await this.clickButton(/Retry import/i); + await this.waitFor(() => (this.ingestionPostCount === 2 ? true : null)); + } + + /** Anchored on "Import completed": the failed summary reports the same counters. */ + async waitForCompletedSummary(): Promise { + const { processedRecords, totalRecords, invalidRecords } = this.fixture; + await this.waitForText( + new RegExp( + `Import completed: ${totalRecords} total records, ${processedRecords} processed, ${invalidRecords} invalid`, + "i", + ), + 15000, + ); + } + + hasCompletionNotification(): boolean { + return this.containsText(/Findings import completed/i); + } + + hasCompletedSummary(): boolean { + return this.containsText(/Import completed:/i); + } + + hasStopTrackingAction(): boolean { + return this.buttonByText(/Stop tracking/i) !== null; + } + + async waitForCompletionNotification(): Promise { + await this.waitFor( + () => this.hasCompletionNotification(), + 15000, + "the import completion notification", + ); + } + + async waitForTrackingStatus(): Promise { + await this.waitForText(/Import is processing/i); + } + + async waitForStatusError(): Promise { + await this.waitForText(/Unable to retrieve the import status/i, 15000); + } + + async waitForFailedImport(): Promise { + await this.waitForText(/Import failed/i, 15000); + } + + async waitForFailedImportSummary(): Promise { + const { processedRecords, totalRecords, invalidRecords } = this.fixture; + await this.waitForText( + new RegExp( + `${processedRecords} of ${totalRecords} records processed, ${invalidRecords} invalid`, + "i", + ), + 15000, + ); + } + + async retryStatus(): Promise { + await this.clickButton(/Retry status/i); + await this.waitFor(() => + this.ingestionStatusPollCount >= 2 ? true : null, + ); + } + + async waitForFirstStatusPoll(): Promise { + await this.waitFor( + () => this.ingestionStatusPollCount >= 1, + 5000, + "the first status poll", + ); + } + + /** Outlast an uncancelled poll: its response lands, then the modal's 5s interval passes and the next would fire. */ + async waitPastTheNextPoll(): Promise { + await this.waitForTransition(this.statusDelayMs + 5700); + } + + get ingestionPostCount(): number { + return this.countRequests("POST", "/api/ingestions"); + } + + get ingestionStatusPollCount(): number { + return this.countRequests("GET", "/api/ingestions/"); + } + + get maximumInFlightStatusRequests(): number { + return this.maxInFlightStatusRequests; + } + + /** A page refresh is the only thing that brings an imported scan into the table. */ + get pageRefreshCount(): number { + return vi.mocked(readMockedRouter().refresh).mock.calls.length; + } + + async uploadedFileName(): Promise { + const entry = [...this.requestLog] + .reverse() + .find( + (request) => + request.method === "POST" && + new URL(request.url).pathname === "/api/ingestions", + ); + if (!entry) return null; + + const file = (await entry.request.formData()).get("file"); + return file instanceof File ? file.name : null; + } +} diff --git a/ui/app/(prowler)/scans/scans-page.integration.test.tsx b/ui/app/(prowler)/scans/scans-page.integration.test.tsx new file mode 100644 index 0000000000..d150a2d0f8 --- /dev/null +++ b/ui/app/(prowler)/scans/scans-page.integration.test.tsx @@ -0,0 +1,326 @@ +import { describe, expect } from "vitest"; + +import { it } from "@/__tests__/fixtures"; +import { + INGESTION_REJECTION, + ingestionFixture, + ingestionRejectionFixture, + partiallyProcessedIngestionFixture, +} from "@/__tests__/msw/handlers/ingestions.fixtures"; + +import { ScansPageHarness } from "./scans-page.harness"; + +describe("Scans page import findings", () => { + it("imports one valid finding file without scan permission or providers", async () => { + const harness = new ScansPageHarness(ingestionFixture()); + await harness.mount(); + + expect(harness.hasImportFindingsAction()).toBe(true); + await harness.openImportFindings(); + await harness.selectFile( + new File(['[{"type":"finding"}]'], "findings.ocsf.json", { + type: "application/json", + }), + ); + await harness.submitImport(); + + await harness.waitForCompletedSummary(); + // The summary is on screen, so the toast would have been raised in the same + // render: its absence is the open dialog suppressing it. + expect(harness.hasCompletionNotification()).toBe(false); + expect(harness.pageRefreshCount).toBe(1); + expect(harness.ingestionPostCount).toBe(1); + expect(await harness.uploadedFileName()).toBe("findings.ocsf.json"); + expect(harness.ingestionStatusPollCount).toBeGreaterThanOrEqual(2); + }); + + it("hides Import Findings in Local Server", async ({ seedRuntimeConfig }) => { + seedRuntimeConfig({ cloudEnabled: false }); + const harness = new ScansPageHarness(ingestionFixture()); + await harness.mount(); + + expect(harness.hasImportFindingsAction()).toBe(false); + }); + + it("hides Import Findings without Manage Ingestions", async () => { + const harness = new ScansPageHarness(ingestionFixture()); + await harness.mount({ hasManageIngestionsPermission: false }); + + expect(harness.hasImportFindingsAction()).toBe(false); + }); + + it("supports keyboard activation and drag-and-drop selection", async () => { + const harness = new ScansPageHarness(ingestionFixture()); + await harness.mount(); + await harness.openImportFindings(); + + await expect(harness.activateDropzoneWithKeyboard()).resolves.toBe(true); + await harness.dropFile( + new File(["[]"], "dropped.OCSF.JSON", { type: "application/json" }), + ); + + expect(harness.selectedFileName()).toBe("dropped.OCSF.JSON"); + expect(harness.isImportEnabled()).toBe(true); + }); + + it("replaces a selected file and rejects unsupported and empty selections", async () => { + const harness = new ScansPageHarness(ingestionFixture()); + await harness.mount(); + await harness.openImportFindings(); + await harness.selectFile(new File(["[]"], "first.ocsf.json")); + await harness.selectFile(new File(["[]"], "replacement.ocsf.json")); + + expect(harness.selectedFileName()).toBe("replacement.ocsf.json"); + await harness.selectFile(new File(["[]"], "unsupported.json")); + await harness.waitForValidationMessage(/\.ocsf\.json/i); + expect(harness.ingestionPostCount).toBe(0); + + await harness.selectFile(new File([], "empty.ocsf.json")); + await harness.waitForValidationMessage(/empty/i); + expect(harness.ingestionPostCount).toBe(0); + }); + + it("clears an unsubmitted selection after closing the dialog", async () => { + const harness = new ScansPageHarness(ingestionFixture()); + await harness.mount(); + await harness.openImportFindings(); + await harness.selectFile(new File(["[]"], "findings.ocsf.json")); + await harness.closeImportFindings(); + await harness.openImportFindings(); + + expect(harness.selectedFileName()).toBeNull(); + expect(harness.isImportEnabled()).toBe(false); + }); + + for (const rejection of Object.values(INGESTION_REJECTION)) { + it(`keeps the file recoverable after a ${rejection} upload rejection`, async () => { + const rejectionFixture = ingestionRejectionFixture(rejection); + const harness = new ScansPageHarness(ingestionFixture()); + await harness.mount({ uploadRejection: rejectionFixture }); + await harness.openImportFindings(); + await harness.selectFile(new File(["[]"], "findings.ocsf.json")); + await harness.submitImport(); + + await harness.waitForUploadError( + new RegExp(rejectionFixture.message, "i"), + ); + expect(harness.selectedFileName()).toBe("findings.ocsf.json"); + expect(harness.ingestionPostCount).toBe(1); + + await harness.retryUpload(); + expect(harness.ingestionPostCount).toBe(2); + }); + } + + it("continues tracking an accepted import while the dialog is closed", async () => { + const harness = new ScansPageHarness(ingestionFixture()); + await harness.mount({ statusSequence: ["processing", "completed"] }); + await harness.openImportFindings(); + await harness.selectFile(new File(["[]"], "findings.ocsf.json")); + await harness.submitImport(); + await harness.waitForTrackingStatus(); + await harness.closeImportFindings(); + + await harness.openImportFindings(); + await harness.waitForCompletedSummary(); + expect(harness.ingestionPostCount).toBe(1); + }); + + it("keeps an in-flight submission after closing and reopening the dialog", async () => { + const harness = new ScansPageHarness(ingestionFixture()); + await harness.mount({ + uploadDelayMs: 3000, + statusSequence: ["processing", "completed"], + }); + await harness.openImportFindings(); + await harness.selectFile(new File(["[]"], "findings.ocsf.json")); + await harness.submitImport(); + await harness.waitForUploadInProgress(); + await harness.closeImportFindings(); + await harness.openImportFindings(); + + expect(harness.isUploadInProgress()).toBe(true); + expect(harness.selectedFileName()).toBe("findings.ocsf.json"); + + await harness.waitForTrackingStatus(); + expect(harness.ingestionPostCount).toBe(1); + }); + + it("refuses to swap the file while an import is in flight", async () => { + const harness = new ScansPageHarness(ingestionFixture()); + await harness.mount({ + uploadDelayMs: 3000, + statusSequence: ["processing", "completed"], + }); + await harness.openImportFindings(); + await harness.selectFile(new File(["[]"], "findings.ocsf.json")); + await harness.submitImport(); + await harness.waitForUploadInProgress(); + + expect(harness.isDropzoneFrozen()).toBe(true); + await harness.attemptDrop(new File(["[]"], "swapped.ocsf.json")); + await expect(harness.activateDropzoneWithKeyboard()).resolves.toBe(false); + + expect(harness.selectedFileName()).toBe("findings.ocsf.json"); + // A swap would reset to ready, re-enabling submission for a second POST. + expect(harness.isUploadInProgress()).toBe(true); + + await harness.waitForTrackingStatus(); + expect(harness.ingestionPostCount).toBe(1); + }); + + it("notifies when an import completes while the dialog is closed", async () => { + const harness = new ScansPageHarness(ingestionFixture()); + await harness.mount({ statusSequence: ["processing", "completed"] }); + await harness.openImportFindings(); + await harness.selectFile(new File(["[]"], "findings.ocsf.json")); + await harness.submitImport(); + await harness.waitForTrackingStatus(); + await harness.closeImportFindings(); + + await harness.waitForCompletionNotification(); + expect(harness.ingestionPostCount).toBe(1); + }); + + it("offers a fresh import when reopening after a background completion", async () => { + const harness = new ScansPageHarness(ingestionFixture()); + await harness.mount({ statusSequence: ["processing", "completed"] }); + await harness.openImportFindings(); + await harness.selectFile(new File(["[]"], "findings.ocsf.json")); + await harness.submitImport(); + await harness.waitForTrackingStatus(); + await harness.closeImportFindings(); + await harness.waitForCompletionNotification(); + + await harness.openImportFindings(); + // Without the reopen reset the summary renders in place of the dropzone and + // the submit, leaving no way to import a second report. + expect(harness.hasCompletedSummary()).toBe(false); + await harness.selectFile(new File(["[]"], "another.ocsf.json")); + expect(harness.selectedFileName()).toBe("another.ocsf.json"); + expect(harness.isImportEnabled()).toBe(true); + expect(harness.ingestionPostCount).toBe(1); + }); + + it("notifies when the dialog closes immediately before the import completes", async () => { + const harness = new ScansPageHarness(ingestionFixture()); + await harness.mount({ + holdStatusResponse: true, + statusSequence: ["completed"], + }); + await harness.openImportFindings(); + await harness.selectFile(new File(["[]"], "findings.ocsf.json")); + await harness.submitImport(); + await harness.waitForFirstStatusPoll(); + + await harness.closeImmediatelyBeforeStatusCompletes(); + + await harness.waitForCompletionNotification(); + expect(harness.pageRefreshCount).toBe(1); + expect(harness.ingestionPostCount).toBe(1); + }); + + it("retries a failed terminal import with the selected file", async () => { + const harness = new ScansPageHarness(ingestionFixture()); + await harness.mount({ statusSequence: ["failed"] }); + await harness.openImportFindings(); + await harness.selectFile(new File(["[]"], "findings.ocsf.json")); + await harness.submitImport(); + await harness.waitForFailedImport(); + expect(harness.pageRefreshCount).toBe(0); + + await harness.retryUpload(); + expect(harness.ingestionPostCount).toBe(2); + }); + + it("reports how many records a failed import processed", async () => { + const harness = new ScansPageHarness(partiallyProcessedIngestionFixture()); + await harness.mount({ statusSequence: ["failed"] }); + await harness.openImportFindings(); + await harness.selectFile(new File(["[]"], "findings.ocsf.json")); + await harness.submitImport(); + + await harness.waitForFailedImport(); + await harness.waitForFailedImportSummary(); + expect(harness.ingestionPostCount).toBe(1); + }); + + it("clears a terminal result after closing the dialog", async () => { + const harness = new ScansPageHarness(ingestionFixture()); + await harness.mount({ statusSequence: ["failed"] }); + await harness.openImportFindings(); + await harness.selectFile(new File(["[]"], "findings.ocsf.json")); + await harness.submitImport(); + await harness.waitForFailedImport(); + await harness.closeImportFindings(); + await harness.openImportFindings(); + + expect(harness.selectedFileName()).toBeNull(); + expect(harness.isImportEnabled()).toBe(false); + }); + + it("retries a transient status failure without re-uploading", async () => { + const harness = new ScansPageHarness(ingestionFixture()); + await harness.mount({ + statusErrorAt: 1, + statusSequence: ["processing", "completed"], + }); + await harness.openImportFindings(); + await harness.selectFile(new File(["[]"], "findings.ocsf.json")); + await harness.submitImport(); + await harness.waitForStatusError(); + + await harness.retryStatus(); + expect(harness.ingestionPostCount).toBe(1); + }); + + it("holds a stuck import instead of freeing a second upload", async () => { + const harness = new ScansPageHarness(ingestionFixture()); + await harness.mount({ statusErrorAt: 1 }); + await harness.openImportFindings(); + await harness.selectFile(new File(["[]"], "findings.ocsf.json")); + await harness.submitImport(); + await harness.waitForStatusError(); + + // The ingestion API has no cancellation and every POST opens a new job, so + // letting go of an accepted one would only duplicate it. + expect(harness.hasStopTrackingAction()).toBe(false); + expect(harness.isDropzoneFrozen()).toBe(true); + expect(harness.isImportEnabled()).toBe(false); + + await harness.retryStatus(); + expect(harness.ingestionPostCount).toBe(1); + }); + + it("never overlaps status polls for an accepted import", async () => { + const harness = new ScansPageHarness(ingestionFixture()); + // The delay has to outlast POLL_INTERVAL_MS: an interval-driven poll fires + // its second request while this first one is still in flight. + await harness.mount({ + statusDelayMs: 7500, + statusSequence: ["completed"], + }); + await harness.openImportFindings(); + await harness.selectFile(new File(["[]"], "findings.ocsf.json")); + await harness.submitImport(); + + await harness.waitForCompletedSummary(); + expect(harness.maximumInFlightStatusRequests).toBe(1); + }); + + it("stops polling a tracked import after leaving the page", async () => { + const harness = new ScansPageHarness(ingestionFixture()); + await harness.mount({ statusDelayMs: 500 }); + await harness.openImportFindings(); + await harness.selectFile(new File(["[]"], "findings.ocsf.json")); + await harness.submitImport(); + await harness.waitForFirstStatusPoll(); + await harness.leaveScansPage(); + const pollsWhenLeaving = harness.ingestionStatusPollCount; + + // An unaborted poll answers into the dead page and chains the next one, + // refreshing and toasting over whatever route the user moved to. + await harness.waitPastTheNextPoll(); + expect(harness.ingestionStatusPollCount).toBe(pollsWhenLeaving); + }); +}); diff --git a/ui/app/api/auth/callback/github/route.test.ts b/ui/app/api/auth/callback/github/route.test.ts new file mode 100644 index 0000000000..5e454e778e --- /dev/null +++ b/ui/app/api/auth/callback/github/route.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { fetchMock, signInMock } = vi.hoisted(() => ({ + fetchMock: vi.fn(), + signInMock: vi.fn(), +})); + +vi.mock("@/auth.config", () => ({ + signIn: signInMock, +})); + +vi.mock("@/lib/helper", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", + baseUrl: "https://app.example.com", +})); + +import { GET } from "./route"; + +describe("GitHub OAuth callback route", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + signInMock.mockResolvedValue({}); + }); + + it("redirects to sign-in with a specific error when self-registration is disabled", async () => { + // Given + fetchMock.mockResolvedValue( + Response.json( + { errors: [{ code: "self_registration_disabled", status: "403" }] }, + { status: 403 }, + ), + ); + const request = new Request( + "https://app.example.com/api/auth/callback/github?code=oauth-code", + ); + + // When + const response = await GET(request); + + // Then + expect(fetchMock.mock.calls[0][0]).toBe( + "https://api.example.com/api/v1/tokens/github", + ); + expect(response.headers.get("location")).toBe( + "https://app.example.com/sign-in?error=SelfRegistrationDisabled", + ); + expect(signInMock).not.toHaveBeenCalled(); + }); + + it("keeps the generic failure for other token exchange errors", async () => { + // Given + fetchMock.mockResolvedValue( + Response.json({ errors: [{ status: "400" }] }, { status: 400 }), + ); + const request = new Request( + "https://app.example.com/api/auth/callback/github?code=oauth-code", + ); + + // When + const response = await GET(request); + + // Then + expect(response.headers.get("location")).toBe( + "https://app.example.com/sign-in?error=AuthenticationFailed", + ); + }); +}); diff --git a/ui/app/api/auth/callback/github/route.ts b/ui/app/api/auth/callback/github/route.ts index a152206125..76f9ba790f 100644 --- a/ui/app/api/auth/callback/github/route.ts +++ b/ui/app/api/auth/callback/github/route.ts @@ -7,6 +7,7 @@ import { getAttributionParamsFromCallbackPath, getInvitationTokenFromCallbackPath, getSafeCallbackPath, + isSelfRegistrationDisabledResponse, } from "@/lib/auth-callback-url"; import { apiBaseUrl, baseUrl } from "@/lib/helper"; @@ -44,6 +45,11 @@ export async function GET(req: Request) { }); if (!response.ok) { + if (await isSelfRegistrationDisabledResponse(response)) { + return NextResponse.redirect( + new URL("/sign-in?error=SelfRegistrationDisabled", baseUrl), + ); + } throw new Error("Failed to exchange code for tokens"); } diff --git a/ui/app/api/auth/callback/google/route.test.ts b/ui/app/api/auth/callback/google/route.test.ts index 96599d345a..bb1b8de3f4 100644 --- a/ui/app/api/auth/callback/google/route.test.ts +++ b/ui/app/api/auth/callback/google/route.test.ts @@ -51,4 +51,44 @@ describe("Google OAuth callback route", () => { expect(body.get("promo_code")).toBe("black-hat-2026"); expect(body.get("utm_source")).toBe("blackhat"); }); + + it("redirects to sign-in with a specific error when self-registration is disabled", async () => { + // Given + fetchMock.mockResolvedValue( + Response.json( + { errors: [{ code: "self_registration_disabled", status: "403" }] }, + { status: 403 }, + ), + ); + const request = new Request( + "https://app.example.com/api/auth/callback/google?code=oauth-code", + ); + + // When + const response = await GET(request); + + // Then + expect(response.headers.get("location")).toBe( + "https://app.example.com/sign-in?error=SelfRegistrationDisabled", + ); + expect(signInMock).not.toHaveBeenCalled(); + }); + + it("keeps the generic failure for other token exchange errors", async () => { + // Given + fetchMock.mockResolvedValue( + Response.json({ errors: [{ status: "400" }] }, { status: 400 }), + ); + const request = new Request( + "https://app.example.com/api/auth/callback/google?code=oauth-code", + ); + + // When + const response = await GET(request); + + // Then + expect(response.headers.get("location")).toBe( + "https://app.example.com/sign-in?error=AuthenticationFailed", + ); + }); }); diff --git a/ui/app/api/auth/callback/google/route.ts b/ui/app/api/auth/callback/google/route.ts index fc8e263a94..fdf81c57bb 100644 --- a/ui/app/api/auth/callback/google/route.ts +++ b/ui/app/api/auth/callback/google/route.ts @@ -7,6 +7,7 @@ import { getAttributionParamsFromCallbackPath, getInvitationTokenFromCallbackPath, getSafeCallbackPath, + isSelfRegistrationDisabledResponse, } from "@/lib/auth-callback-url"; import { apiBaseUrl, baseUrl } from "@/lib/helper"; @@ -44,6 +45,11 @@ export async function GET(req: Request) { }); if (!response.ok) { + if (await isSelfRegistrationDisabledResponse(response)) { + return NextResponse.redirect( + new URL("/sign-in?error=SelfRegistrationDisabled", baseUrl), + ); + } throw new Error("Failed to exchange code for tokens"); } diff --git a/ui/app/api/ingestions/[ingestionId]/route.test.ts b/ui/app/api/ingestions/[ingestionId]/route.test.ts new file mode 100644 index 0000000000..f0723192d1 --- /dev/null +++ b/ui/app/api/ingestions/[ingestionId]/route.test.ts @@ -0,0 +1,231 @@ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest"; + +import { GET } from "./route"; + +const { getAuthHeadersMock, isCloudMock } = vi.hoisted(() => ({ + getAuthHeadersMock: vi.fn(), + isCloudMock: vi.fn(), +})); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", + getAuthHeaders: getAuthHeadersMock, +})); + +vi.mock("@/lib/shared/env", () => ({ + isCloud: isCloudMock, +})); + +describe("GET /api/ingestions/[ingestionId]", () => { + const server = setupServer(); + + beforeAll(() => server.listen({ onUnhandledRequest: "error" })); + + afterEach(() => { + server.resetHandlers(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + afterAll(() => server.close()); + + it("returns not found in OSS and Local Server before reading the ingestion identifier", async () => { + isCloudMock.mockReturnValue(false); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const response = await GET(new Request("http://localhost/api/ingestions"), { + params: Promise.resolve({ ingestionId: "ingestion-123" }), + }); + + expect(response.status).toBe(404); + expect(getAuthHeadersMock).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("forwards a Cloud status request and translates its typed response", async () => { + isCloudMock.mockReturnValue(true); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + server.use( + http.get("https://api.example.com/api/v1/ingestions/ingestion-123", () => + HttpResponse.json({ + data: { + id: "ingestion-123", + type: "ingestions", + attributes: { + status: "processing", + summary: { total: 5, processed: 3, invalid: 1 }, + requested_at: "2026-08-26T11:23:20.265770Z", + started_at: "2026-08-26T11:23:20.372762Z", + completed_at: null, + }, + }, + }), + ), + ); + + const response = await GET(new Request("http://localhost/api/ingestions"), { + params: Promise.resolve({ ingestionId: "ingestion-123" }), + }); + + await expect(response.json()).resolves.toEqual({ + data: { + id: "ingestion-123", + status: "processing", + totalRecords: 5, + processedRecords: 3, + invalidRecords: 1, + }, + }); + }); + + it("forwards an identifier that needs escaping as one encoded path segment", async () => { + isCloudMock.mockReturnValue(true); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + let requestedUrl = ""; + server.use( + http.get("https://api.example.com/api/v1/ingestions/*", ({ request }) => { + requestedUrl = request.url; + return HttpResponse.json({ + data: { id: "2026/08 report", attributes: { status: "pending" } }, + }); + }), + ); + + const response = await GET(new Request("http://localhost/api/ingestions"), { + params: Promise.resolve({ ingestionId: "2026/08 report" }), + }); + + expect(requestedUrl).toBe( + "https://api.example.com/api/v1/ingestions/2026%2F08%20report", + ); + expect(response.status).toBe(200); + }); + + it("sanitizes unreadable upstream status failures", async () => { + isCloudMock.mockReturnValue(true); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + server.use( + http.get( + "https://api.example.com/api/v1/ingestions/ingestion-123", + () => + new HttpResponse("upstream details", { + status: 503, + headers: { "content-type": "text/html" }, + }), + ), + ); + + const response = await GET(new Request("http://localhost/api/ingestions"), { + params: Promise.resolve({ ingestionId: "ingestion-123" }), + }); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toEqual({ + error: "Unable to retrieve the import status. Please try again.", + }); + }); + + it("returns a safe bad gateway response when the ingestion API connection fails", async () => { + // Given + isCloudMock.mockReturnValue(true); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + vi.stubGlobal( + "fetch", + vi.fn().mockRejectedValue(new Error("socket hang up from api.internal")), + ); + + // When + const response = await GET(new Request("http://localhost/api/ingestions"), { + params: Promise.resolve({ ingestionId: "ingestion-123" }), + }); + + // Then + expect(response.status).toBe(502); + await expect(response.json()).resolves.toEqual({ + error: "Unable to retrieve the import status. Please try again.", + }); + }); + + it("does not expose structured upstream status failures", async () => { + isCloudMock.mockReturnValue(true); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + server.use( + http.get("https://api.example.com/api/v1/ingestions/ingestion-123", () => + HttpResponse.json( + { errors: [{ code: "internal_error", detail: "upstream details" }] }, + { status: 503 }, + ), + ), + ); + + const response = await GET(new Request("http://localhost/api/ingestions"), { + params: Promise.resolve({ ingestionId: "ingestion-123" }), + }); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toEqual({ + error: "Unable to retrieve the import status. Please try again.", + }); + }); + + it("tracks a status response that has not reported its summary yet", async () => { + isCloudMock.mockReturnValue(true); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + server.use( + http.get("https://api.example.com/api/v1/ingestions/ingestion-123", () => + HttpResponse.json({ + data: { + type: "ingestions", + id: "ingestion-123", + attributes: { status: "pending" }, + }, + }), + ), + ); + + const response = await GET(new Request("http://localhost/api/ingestions"), { + params: Promise.resolve({ ingestionId: "ingestion-123" }), + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + data: { + id: "ingestion-123", + status: "pending", + totalRecords: 0, + processedRecords: 0, + invalidRecords: 0, + }, + }); + }); + + it("rejects malformed accepted status responses", async () => { + isCloudMock.mockReturnValue(true); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + server.use( + http.get("https://api.example.com/api/v1/ingestions/ingestion-123", () => + HttpResponse.json({ data: { id: "ingestion-123", attributes: {} } }), + ), + ); + + const response = await GET(new Request("http://localhost/api/ingestions"), { + params: Promise.resolve({ ingestionId: "ingestion-123" }), + }); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toEqual({ + error: "Unable to retrieve the import status. Please try again.", + }); + }); +}); diff --git a/ui/app/api/ingestions/[ingestionId]/route.ts b/ui/app/api/ingestions/[ingestionId]/route.ts new file mode 100644 index 0000000000..aa4555c657 --- /dev/null +++ b/ui/app/api/ingestions/[ingestionId]/route.ts @@ -0,0 +1,59 @@ +import { NextResponse } from "next/server"; + +import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { parseIngestion } from "@/lib/ingestions"; +import { isCloud } from "@/lib/shared/env"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +interface IngestionRouteContext { + params: Promise<{ + ingestionId: string; + }>; +} + +const INVALID_INGESTION_RESPONSE = + "Unable to retrieve the import status. Please try again."; + +export async function GET( + _request: Request, + { params }: IngestionRouteContext, +) { + if (!isCloud()) return new Response(null, { status: 404 }); + + const { ingestionId } = await params; + if (!ingestionId) return new Response(null, { status: 404 }); + + const headers = await getAuthHeaders({ contentType: false }); + let upstreamResponse: Response; + try { + upstreamResponse = await fetch( + `${apiBaseUrl}/ingestions/${encodeURIComponent(ingestionId)}`, + { headers, cache: "no-store" }, + ); + } catch { + return NextResponse.json( + { error: INVALID_INGESTION_RESPONSE }, + { status: 502 }, + ); + } + const payload = await upstreamResponse.json().catch(() => undefined); + + if (!upstreamResponse.ok) { + return NextResponse.json( + { error: INVALID_INGESTION_RESPONSE }, + { status: upstreamResponse.status }, + ); + } + + const ingestion = parseIngestion(payload); + if (!ingestion) { + return NextResponse.json( + { error: INVALID_INGESTION_RESPONSE }, + { status: 502 }, + ); + } + + return NextResponse.json({ data: ingestion }); +} diff --git a/ui/app/api/ingestions/route.test.ts b/ui/app/api/ingestions/route.test.ts new file mode 100644 index 0000000000..3142c1375b --- /dev/null +++ b/ui/app/api/ingestions/route.test.ts @@ -0,0 +1,345 @@ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest"; + +import { POST } from "./route"; + +// Browser MSW intercepts the same-origin request before Next can run this +// Route Handler, so these tests call POST directly. +const { getAuthHeadersMock, isCloudMock } = vi.hoisted(() => ({ + getAuthHeadersMock: vi.fn(), + isCloudMock: vi.fn(), +})); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", + getAuthHeaders: getAuthHeadersMock, +})); + +vi.mock("@/lib/shared/env", () => ({ + isCloud: isCloudMock, +})); + +// A browser upload always reaches the route with a length on the wire, and the +// route refuses anything it cannot measure, so a forwarded Request needs one. +const uploadRequest = (body = "report") => + new Request("http://localhost/api/ingestions", { + method: "POST", + headers: { + "content-length": String(new TextEncoder().encode(body).length), + }, + body, + }); + +describe("POST /api/ingestions", () => { + const server = setupServer(); + + beforeAll(() => server.listen({ onUnhandledRequest: "error" })); + + afterEach(() => { + server.resetHandlers(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + afterAll(() => server.close()); + + it("returns not found in OSS and Local Server without authenticating or forwarding the upload", async () => { + isCloudMock.mockReturnValue(false); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const request = new Request("http://localhost/api/ingestions", { + method: "POST", + headers: { "content-type": "multipart/form-data; boundary=report" }, + body: "--report--", + }); + + const response = await POST(request); + + expect(response.status).toBe(404); + expect(request.body?.locked).toBe(false); + expect(getAuthHeadersMock).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("forwards the original multipart stream and boundary in Cloud", async () => { + isCloudMock.mockReturnValue(true); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + let contentType: string | null = null; + let contentLength: string | null = null; + let uploadedBody = ""; + server.use( + http.post( + "https://api.example.com/api/v1/ingestions", + async ({ request }) => { + contentType = request.headers.get("content-type"); + contentLength = request.headers.get("content-length"); + uploadedBody = await request.text(); + return HttpResponse.json({ + data: { + id: "ingestion-123", + type: "ingestions", + attributes: { + status: "pending", + summary: { total: 0, processed: 0, invalid: 0 }, + requested_at: "2026-08-26T11:23:20.265770Z", + started_at: null, + completed_at: null, + }, + }, + }); + }, + ), + ); + // Built by hand: a Request created from FormData carries no content-length + // header, which is what this test pins. + const boundary = "----ingestionBoundary"; + const multipartBody = [ + `--${boundary}`, + 'Content-Disposition: form-data; name="file"; filename="findings.ocsf.json"', + "Content-Type: application/json", + "", + "finding report", + `--${boundary}--`, + "", + ].join("\r\n"); + const request = new Request("http://localhost/api/ingestions", { + method: "POST", + headers: { + "content-type": `multipart/form-data; boundary=${boundary}`, + "content-length": String( + new TextEncoder().encode(multipartBody).length, + ), + }, + body: multipartBody, + }); + + const response = await POST(request); + + expect(contentType).toBe(`multipart/form-data; boundary=${boundary}`); + expect(contentLength).toBe( + String(new TextEncoder().encode(multipartBody).length), + ); + expect(uploadedBody).toBe(multipartBody); + await expect(response.json()).resolves.toEqual({ + data: { + id: "ingestion-123", + status: "pending", + totalRecords: 0, + processedRecords: 0, + invalidRecords: 0, + }, + }); + }); + + it("refuses an upload it cannot measure instead of forwarding it chunked", async () => { + isCloudMock.mockReturnValue(true); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + // A Request built from FormData carries no content-length, and the ingestion + // API parses no file out of the chunked body that would be forwarded. + const request = new Request("http://localhost/api/ingestions", { + method: "POST", + headers: { "content-type": "multipart/form-data; boundary=report" }, + body: "--report--", + }); + + const response = await POST(request); + + expect(response.status).toBe(411); + expect(fetchMock).not.toHaveBeenCalled(); + expect(request.body?.locked).toBe(false); + await expect(response.json()).resolves.toEqual({ + error: "Unable to start the import. Please try again.", + }); + }); + + it("sanitizes unexpected upstream error pages", async () => { + isCloudMock.mockReturnValue(true); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + server.use( + http.post( + "https://api.example.com/api/v1/ingestions", + () => + new HttpResponse("upstream details", { + status: 502, + headers: { "content-type": "text/html" }, + }), + ), + ); + + const response = await POST(uploadRequest()); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toEqual({ + error: "Unable to start the import. Please try again.", + }); + }); + + it("returns a safe bad gateway response when the ingestion API connection fails", async () => { + // Given + isCloudMock.mockReturnValue(true); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + vi.stubGlobal( + "fetch", + vi.fn().mockRejectedValue(new Error("connect ECONNREFUSED api.internal")), + ); + + // When + const response = await POST(uploadRequest()); + + // Then + expect(response.status).toBe(502); + await expect(response.json()).resolves.toEqual({ + error: "Unable to start the import. Please try again.", + }); + }); + + it.each([ + [400, "invalid", "The report is not a valid Prowler OCSF finding report."], + [ + 402, + "subscription_required", + "A Prowler Cloud subscription is required to import findings.", + ], + [ + 403, + "permission_denied", + "You do not have permission to import findings.", + ], + [ + 413, + "file_too_large", + "The selected file exceeds the allowed upload size.", + ], + [ + 429, + "rate_limited", + "Too many import requests. Please try again shortly.", + ], + ])( + "maps known upstream rejection %i/%s to safe import guidance", + async (status, code, message) => { + isCloudMock.mockReturnValue(true); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + server.use( + http.post("https://api.example.com/api/v1/ingestions", () => + HttpResponse.json( + { errors: [{ code, detail: "internal implementation detail" }] }, + { status }, + ), + ), + ); + + const response = await POST(uploadRequest()); + + expect(response.status).toBe(status); + await expect(response.json()).resolves.toEqual({ error: message }); + }, + ); + + const STATUS_TIER_REJECTIONS = [ + [400, "The report is not a valid Prowler OCSF finding report."], + [402, "A Prowler Cloud subscription is required to import findings."], + [403, "You do not have permission to import findings."], + [413, "The selected file exceeds the allowed upload size."], + [429, "Too many import requests. Please try again shortly."], + ] as const; + + it.each(STATUS_TIER_REJECTIONS)( + "maps a codeless upstream rejection to the %i status guidance", + async (status, message) => { + isCloudMock.mockReturnValue(true); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + server.use( + http.post("https://api.example.com/api/v1/ingestions", () => + HttpResponse.json( + { detail: "internal implementation detail" }, + { status }, + ), + ), + ); + + const response = await POST(uploadRequest()); + + expect(response.status).toBe(status); + await expect(response.json()).resolves.toEqual({ error: message }); + }, + ); + + it.each(STATUS_TIER_REJECTIONS)( + "falls through an unrecognized error code to the %i status guidance", + async (status, message) => { + isCloudMock.mockReturnValue(true); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + server.use( + http.post("https://api.example.com/api/v1/ingestions", () => + HttpResponse.json( + { + errors: [ + { + code: "invalid_findings", + detail: "internal implementation detail", + }, + ], + }, + { status }, + ), + ), + ); + + const response = await POST(uploadRequest()); + + expect(response.status).toBe(status); + await expect(response.json()).resolves.toEqual({ error: message }); + }, + ); + + // An empty id parses into a trackable-looking job whose poll URL, + // `/api/ingestions/`, matches no route: a created import reads as a failure. + it.each([ + ["no job identifier", { attributes: { status: "pending" } }], + ["an empty job identifier", { id: "", attributes: { status: "pending" } }], + ])("refuses an accepted response with %s", async (_shape, data) => { + isCloudMock.mockReturnValue(true); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + server.use( + http.post("https://api.example.com/api/v1/ingestions", () => + HttpResponse.json({ data }), + ), + ); + + const response = await POST(uploadRequest()); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toEqual({ + error: "Unable to start the import. Please try again.", + }); + }); + + it("rejects accepted responses that cannot start a trackable ingestion", async () => { + isCloudMock.mockReturnValue(true); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + server.use( + http.post("https://api.example.com/api/v1/ingestions", () => + HttpResponse.json({ data: { id: "ingestion-123", attributes: {} } }), + ), + ); + + const response = await POST(uploadRequest()); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toEqual({ + error: "Unable to start the import. Please try again.", + }); + }); +}); diff --git a/ui/app/api/ingestions/route.ts b/ui/app/api/ingestions/route.ts new file mode 100644 index 0000000000..d8bf4a0ce6 --- /dev/null +++ b/ui/app/api/ingestions/route.ts @@ -0,0 +1,114 @@ +import { NextResponse } from "next/server"; + +import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { parseIngestion } from "@/lib/ingestions"; +import { isCloud } from "@/lib/shared/env"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +const INVALID_INGESTION_RESPONSE = + "Unable to start the import. Please try again."; +const INGESTION_REJECTION_BY_CODE = { + invalid: "The report is not a valid Prowler OCSF finding report.", + subscription_required: + "A Prowler Cloud subscription is required to import findings.", + permission_denied: "You do not have permission to import findings.", + file_too_large: "The selected file exceeds the allowed upload size.", + rate_limited: "Too many import requests. Please try again shortly.", +} as const; + +const INGESTION_REJECTION_BY_STATUS = { + 400: INGESTION_REJECTION_BY_CODE.invalid, + 402: INGESTION_REJECTION_BY_CODE.subscription_required, + 403: INGESTION_REJECTION_BY_CODE.permission_denied, + 413: INGESTION_REJECTION_BY_CODE.file_too_large, + 429: INGESTION_REJECTION_BY_CODE.rate_limited, +} as const; + +const ingestionRejectionMessage = ( + payload: unknown, + status: number, + fallback: string, +): string => { + if (typeof payload === "object" && payload !== null && "errors" in payload) { + const errors = payload.errors; + if (Array.isArray(errors) && typeof errors[0]?.code === "string") { + const message = + INGESTION_REJECTION_BY_CODE[ + errors[0].code as keyof typeof INGESTION_REJECTION_BY_CODE + ]; + if (message) return message; + } + } + + return ( + INGESTION_REJECTION_BY_STATUS[ + status as keyof typeof INGESTION_REJECTION_BY_STATUS + ] ?? fallback + ); +}; + +export async function POST(request: Request) { + if (!isCloud()) return new Response(null, { status: 404 }); + + const headers = await getAuthHeaders({ contentType: false }); + const contentType = request.headers.get("content-type"); + const contentLength = request.headers.get("content-length"); + + if (contentType) headers["Content-Type"] = contentType; + // Without the length the stream is forwarded chunked, and the ingestion API + // parses no file out of a chunked multipart body: refuse rather than spend + // the upload on a request that cannot succeed. + if (!contentLength) { + return NextResponse.json( + { error: INVALID_INGESTION_RESPONSE }, + { status: 411 }, + ); + } + headers["Content-Length"] = contentLength; + + const upstreamRequest: RequestInit & { duplex: "half" } = { + method: "POST", + headers, + body: request.body, + duplex: "half", + cache: "no-store", + }; + let upstreamResponse: Response; + try { + upstreamResponse = await fetch(`${apiBaseUrl}/ingestions`, upstreamRequest); + } catch { + return NextResponse.json( + { error: INVALID_INGESTION_RESPONSE }, + { status: 502 }, + ); + } + const payload = await upstreamResponse.json().catch(() => undefined); + + if (!upstreamResponse.ok) { + return NextResponse.json( + { + error: ingestionRejectionMessage( + payload, + upstreamResponse.status, + INVALID_INGESTION_RESPONSE, + ), + }, + { status: upstreamResponse.status }, + ); + } + + const ingestion = parseIngestion(payload); + if (!ingestion) { + return NextResponse.json( + { error: INVALID_INGESTION_RESPONSE }, + { status: 502 }, + ); + } + + return NextResponse.json( + { data: ingestion }, + { status: upstreamResponse.status }, + ); +} diff --git a/ui/auth.config.test.ts b/ui/auth.config.test.ts index 7125ce9845..0bb714cc93 100644 --- a/ui/auth.config.test.ts +++ b/ui/auth.config.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { authConfig } from "./auth.config"; +import { UserMeError } from "./lib/auth-errors"; import type { RolePermissionAttributes } from "./types/users"; const { getUserByMeMock } = vi.hoisted(() => ({ @@ -34,17 +35,51 @@ const RESTRICTED_PERMISSIONS: RolePermissionAttributes = { manage_account: false, manage_providers: false, manage_scans: false, + manage_ingestions: false, manage_integrations: false, + manage_billing: false, manage_alerts: false, + manage_registry: false, + manage_lighthouse_ai_configuration: false, unlimited_visibility: false, }; const ELEVATED_PERMISSIONS: RolePermissionAttributes = { ...RESTRICTED_PERMISSIONS, manage_users: true, + manage_registry: true, manage_scans: true, }; +const accessTokenFor = (tenantId: string, expiration: number) => + `header.${Buffer.from( + JSON.stringify({ sub: "user-1", tenant_id: tenantId, exp: expiration }), + ).toString("base64url")}.signature`; + +const successfulRefreshResponse = (accessToken: string, refreshToken: string) => + new Response( + JSON.stringify({ + data: { + attributes: { + access: accessToken, + refresh: refreshToken, + }, + }, + }), + { status: 200 }, + ); + +const mockSuccessfulRefresh = ( + accessToken: string, + refreshToken = "new-refresh-token", +) => { + const fetchMock = vi + .fn() + .mockResolvedValue(successfulRefreshResponse(accessToken, refreshToken)); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +}; + describe("authConfig JWT callback", () => { beforeEach(() => { vi.clearAllMocks(); @@ -141,6 +176,25 @@ describe("authConfig JWT callback", () => { }); }); + it("should default manage_registry to false when a sign-in user omits it", async () => { + // Given + const jwtCallback = authConfig.callbacks?.jwt; + if (!jwtCallback) throw new Error("JWT callback is not configured"); + + // When + const result = await jwtCallback({ + token: {}, + account: {} as Parameters[0]["account"], + user: { + accessToken: "access-token", + refreshToken: "refresh-token", + } as Parameters[0]["user"], + }); + + // Then + expect(result.user?.permissions.manage_registry).toBe(false); + }); + it("should report a tenant switch failure while preserving the current session", async () => { // Given vi.spyOn(console, "warn").mockImplementation(() => undefined); @@ -194,4 +248,418 @@ describe("authConfig JWT callback", () => { }); expect(result.error).toBeUndefined(); }); + + it("should replace restricted permissions after access token refresh", async () => { + // Given + const currentAccessToken = accessTokenFor("stale-tenant", 1); + const newAccessToken = accessTokenFor("tenant-1", 4_102_444_800); + mockSuccessfulRefresh(newAccessToken); + getUserByMeMock.mockResolvedValue({ + name: "Tenant User", + email: "tenant@example.com", + company: "Tenant Company", + dateJoined: "2026-01-01", + permissions: ELEVATED_PERMISSIONS, + }); + const jwtCallback = authConfig.callbacks?.jwt; + if (!jwtCallback) throw new Error("JWT callback is not configured"); + + // When + const result = await jwtCallback({ + token: { + accessToken: currentAccessToken, + refreshToken: "current-refresh-token", + user: { + name: "Tenant User", + email: "tenant@example.com", + dateJoined: "2026-01-01", + permissions: RESTRICTED_PERMISSIONS, + }, + }, + user: {} as Parameters[0]["user"], + }); + + // Then + expect(getUserByMeMock).toHaveBeenCalledWith( + newAccessToken, + expect.any(AbortSignal), + ); + expect(result).toMatchObject({ + accessToken: newAccessToken, + refreshToken: "new-refresh-token", + tenant_id: "tenant-1", + user: { + permissions: ELEVATED_PERMISSIONS, + }, + }); + expect(result.error).toBeUndefined(); + }); + + it("should replace elevated permissions when access is revoked", async () => { + // Given + const currentAccessToken = accessTokenFor("tenant-1", 1); + const newAccessToken = accessTokenFor("tenant-1", 4_102_444_800); + mockSuccessfulRefresh(newAccessToken); + getUserByMeMock.mockResolvedValue({ + name: "Tenant User", + email: "tenant@example.com", + company: "Tenant Company", + dateJoined: "2026-01-01", + permissions: RESTRICTED_PERMISSIONS, + }); + const jwtCallback = authConfig.callbacks?.jwt; + if (!jwtCallback) throw new Error("JWT callback is not configured"); + + // When + const result = await jwtCallback({ + token: { + accessToken: currentAccessToken, + refreshToken: "current-refresh-token", + user: { + name: "Tenant User", + email: "tenant@example.com", + dateJoined: "2026-01-01", + permissions: ELEVATED_PERMISSIONS, + }, + }, + user: {} as Parameters[0]["user"], + }); + + // Then + expect(getUserByMeMock).toHaveBeenCalledWith( + newAccessToken, + expect.any(AbortSignal), + ); + expect(result.user?.permissions).toEqual(RESTRICTED_PERMISSIONS); + expect(result.error).toBeUndefined(); + }); + + it("should keep the refreshed tokens and cached user when reloading the user fails", async () => { + // Given + const currentAccessToken = accessTokenFor("tenant-1", 1); + const newAccessToken = accessTokenFor("tenant-1", 4_102_444_800); + const warnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => undefined); + mockSuccessfulRefresh(newAccessToken); + getUserByMeMock.mockRejectedValue( + new UserMeError("Sensitive backend detail", 500), + ); + const jwtCallback = authConfig.callbacks?.jwt; + if (!jwtCallback) throw new Error("JWT callback is not configured"); + const sessionCallback = authConfig.callbacks?.session; + if (!sessionCallback) throw new Error("Session callback is not configured"); + const cachedUser = { + name: "Tenant User", + email: "tenant@example.com", + dateJoined: "2026-01-01", + permissions: ELEVATED_PERMISSIONS, + }; + + // When + const result = await jwtCallback({ + token: { + accessToken: currentAccessToken, + refreshToken: "current-refresh-token", + user: cachedUser, + }, + user: {} as Parameters[0]["user"], + }); + const session = await sessionCallback({ + session: { + expires: "2026-12-31T23:59:59.999Z", + user: { name: "Tenant User" }, + }, + token: result, + } as Parameters[0]); + + // Then + expect(session).toMatchObject({ + accessToken: newAccessToken, + refreshToken: "new-refresh-token", + tenantId: "tenant-1", + user: cachedUser, + }); + expect(result.error).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith( + "Unable to refresh user after access token refresh", + ); + }); + + it("should bound a pending user reload and keep the refreshed session", async () => { + // Given + const currentAccessToken = accessTokenFor("tenant-1", 1); + const newAccessToken = accessTokenFor("tenant-1", 4_102_444_800); + const abortController = new AbortController(); + const timeoutSpy = vi + .spyOn(AbortSignal, "timeout") + .mockReturnValue(abortController.signal); + vi.spyOn(console, "warn").mockImplementation(() => undefined); + mockSuccessfulRefresh(newAccessToken); + getUserByMeMock.mockImplementation( + (_accessToken: string, signal?: AbortSignal) => { + if (!signal) return Promise.reject(new Error("Missing abort signal")); + + return new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { + once: true, + }); + }); + }, + ); + const jwtCallback = authConfig.callbacks?.jwt; + if (!jwtCallback) throw new Error("JWT callback is not configured"); + const cachedUser = { + name: "Tenant User", + email: "tenant@example.com", + dateJoined: "2026-01-01", + permissions: ELEVATED_PERMISSIONS, + }; + + // When + const resultPromise = jwtCallback({ + token: { + accessToken: currentAccessToken, + refreshToken: "current-refresh-token", + user: cachedUser, + }, + user: {} as Parameters[0]["user"], + }); + await vi.waitFor(() => expect(getUserByMeMock).toHaveBeenCalled()); + + // Then + expect(timeoutSpy).toHaveBeenCalledWith(5_000); + expect(getUserByMeMock).toHaveBeenCalledWith( + newAccessToken, + abortController.signal, + ); + + abortController.abort( + new DOMException("Request timed out", "TimeoutError"), + ); + await expect(resultPromise).resolves.toMatchObject({ + accessToken: newAccessToken, + refreshToken: "new-refresh-token", + user: cachedUser, + error: undefined, + }); + }); + + it.each([401, 403, 404])( + "should invalidate the session when reloading the user returns %i", + async (status) => { + // Given + const currentAccessToken = accessTokenFor("tenant-1", 1); + const newAccessToken = accessTokenFor("tenant-1", 4_102_444_800); + vi.spyOn(console, "warn").mockImplementation(() => undefined); + mockSuccessfulRefresh(newAccessToken); + getUserByMeMock.mockRejectedValue( + new UserMeError("Unable to load user", status), + ); + const jwtCallback = authConfig.callbacks?.jwt; + if (!jwtCallback) throw new Error("JWT callback is not configured"); + const sessionCallback = authConfig.callbacks?.session; + if (!sessionCallback) + throw new Error("Session callback is not configured"); + + // When + const result = await jwtCallback({ + token: { + accessToken: currentAccessToken, + refreshToken: "current-refresh-token", + user: { + name: "Tenant User", + email: "tenant@example.com", + dateJoined: "2026-01-01", + permissions: ELEVATED_PERMISSIONS, + }, + }, + user: {} as Parameters[0]["user"], + }); + const session = await sessionCallback({ + session: { + expires: "2026-12-31T23:59:59.999Z", + user: { name: "Tenant User" }, + }, + token: result, + } as Parameters[0]); + + // Then + expect(result.user).toBeUndefined(); + expect(result.accessToken).toBeUndefined(); + expect(result.refreshToken).toBeUndefined(); + expect(result.error).toBe("RefreshAccessTokenError"); + expect(session.user).toBeUndefined(); + expect(session.accessToken).toBeUndefined(); + expect(session.refreshToken).toBeUndefined(); + }, + ); + + it("should invalidate the session when access token refresh fails", async () => { + // Given + const currentAccessToken = accessTokenFor("tenant-1", 1); + vi.spyOn(console, "warn").mockImplementation(() => undefined); + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue( + new Response( + JSON.stringify({ errors: [{ detail: "Refresh token expired" }] }), + { status: 401 }, + ), + ), + ); + const jwtCallback = authConfig.callbacks?.jwt; + if (!jwtCallback) throw new Error("JWT callback is not configured"); + const sessionCallback = authConfig.callbacks?.session; + if (!sessionCallback) throw new Error("Session callback is not configured"); + + // When + const result = await jwtCallback({ + token: { + accessToken: currentAccessToken, + refreshToken: "expired-refresh-token", + user: { + name: "Tenant User", + email: "tenant@example.com", + dateJoined: "2026-01-01", + permissions: ELEVATED_PERMISSIONS, + }, + }, + user: {} as Parameters[0]["user"], + }); + const session = await sessionCallback({ + session: { + expires: "2026-12-31T23:59:59.999Z", + user: { name: "Tenant User" }, + }, + token: result, + } as Parameters[0]); + + // Then + expect(getUserByMeMock).not.toHaveBeenCalled(); + expect(result.error).toBe("RefreshAccessTokenError"); + expect(session.error).toBe("RefreshAccessTokenError"); + expect(session.user).toBeUndefined(); + expect(session.accessToken).toBeUndefined(); + expect(session.refreshToken).toBeUndefined(); + expect(session.tenantId).toBeUndefined(); + }); + + it("should deduplicate concurrent token and user refreshes", async () => { + // Given + const currentAccessToken = accessTokenFor("tenant-1", 1); + const newAccessToken = accessTokenFor("tenant-1", 4_102_444_800); + const fetchMock = mockSuccessfulRefresh(newAccessToken); + getUserByMeMock.mockResolvedValue({ + name: "Tenant User", + email: "tenant@example.com", + company: "Tenant Company", + dateJoined: "2026-01-01", + permissions: ELEVATED_PERMISSIONS, + }); + const jwtCallback = authConfig.callbacks?.jwt; + if (!jwtCallback) throw new Error("JWT callback is not configured"); + const currentToken = { + accessToken: currentAccessToken, + refreshToken: "shared-refresh-token", + user: { + name: "Tenant User", + email: "tenant@example.com", + dateJoined: "2026-01-01", + permissions: RESTRICTED_PERMISSIONS, + }, + }; + + // When + const [firstResult, secondResult] = await Promise.all([ + jwtCallback({ + token: { ...currentToken }, + user: {} as Parameters[0]["user"], + }), + jwtCallback({ + token: { ...currentToken }, + user: {} as Parameters[0]["user"], + }), + ]); + + // Then + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(getUserByMeMock).toHaveBeenCalledTimes(1); + expect(firstResult).toEqual(secondResult); + expect(firstResult.user?.permissions).toEqual(ELEVATED_PERMISSIONS); + }); + + it("should retry reloading the user on the next token rotation", async () => { + // Given + const currentAccessToken = accessTokenFor("tenant-1", 1); + const firstAccessToken = accessTokenFor("tenant-1", 1); + const secondAccessToken = accessTokenFor("tenant-1", 4_102_444_800); + vi.spyOn(console, "warn").mockImplementation(() => undefined); + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + successfulRefreshResponse( + firstAccessToken, + "first-rotated-refresh-token", + ), + ) + .mockResolvedValueOnce( + successfulRefreshResponse( + secondAccessToken, + "second-rotated-refresh-token", + ), + ); + vi.stubGlobal("fetch", fetchMock); + getUserByMeMock + .mockRejectedValueOnce(new Error("Temporary API failure")) + .mockResolvedValueOnce({ + name: "Tenant User", + email: "tenant@example.com", + company: "Tenant Company", + dateJoined: "2026-01-01", + permissions: RESTRICTED_PERMISSIONS, + }); + const jwtCallback = authConfig.callbacks?.jwt; + if (!jwtCallback) throw new Error("JWT callback is not configured"); + + // When + const firstResult = await jwtCallback({ + token: { + accessToken: currentAccessToken, + refreshToken: "current-refresh-token", + user: { + name: "Tenant User", + email: "tenant@example.com", + dateJoined: "2026-01-01", + permissions: ELEVATED_PERMISSIONS, + }, + }, + user: {} as Parameters[0]["user"], + }); + const secondResult = await jwtCallback({ + token: firstResult, + user: {} as Parameters[0]["user"], + }); + + // Then + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(getUserByMeMock).toHaveBeenNthCalledWith( + 1, + firstAccessToken, + expect.any(AbortSignal), + ); + expect(getUserByMeMock).toHaveBeenNthCalledWith( + 2, + secondAccessToken, + expect.any(AbortSignal), + ); + expect(firstResult.user?.permissions).toEqual(ELEVATED_PERMISSIONS); + expect(secondResult).toMatchObject({ + accessToken: secondAccessToken, + refreshToken: "second-rotated-refresh-token", + user: { permissions: RESTRICTED_PERMISSIONS }, + }); + }); }); diff --git a/ui/auth.config.ts b/ui/auth.config.ts index f84ff6496d..f3ba8bbe18 100644 --- a/ui/auth.config.ts +++ b/ui/auth.config.ts @@ -11,6 +11,7 @@ import { z } from "zod"; import { getToken, getUserByMe } from "./actions/auth"; import { apiBaseUrl } from "./lib"; +import { UserMeError } from "./lib/auth-errors"; import { SLACK_CALLBACK_PATH, SLACK_EXPIRED_CALLBACK_URL, @@ -55,15 +56,20 @@ const DEFAULT_PERMISSIONS: RolePermissionAttributes = { manage_account: false, manage_providers: false, manage_scans: false, + manage_ingestions: false, manage_integrations: false, manage_billing: false, manage_alerts: false, manage_lighthouse_ai_configuration: false, + manage_registry: false, unlimited_visibility: false, }; const TENANT_SWITCH_ERROR = "TenantSwitchError"; +const NON_RETRYABLE_USER_ME_STATUSES = new Set([401, 403, 404]); +const USER_REFRESH_TIMEOUT_MS = 5_000; + type TokenUserInput = Partial & { company?: string }; type JwtCallback = NonNullable["jwt"]>; @@ -91,7 +97,7 @@ const toTokenUser = (user?: TokenUserInput): TokenUser => email: user?.email ?? undefined, companyName: user?.companyName ?? user?.company, dateJoined: user?.dateJoined, - permissions: user?.permissions ?? { ...DEFAULT_PERMISSIONS }, + permissions: { ...DEFAULT_PERMISSIONS, ...user?.permissions }, }) as TokenUser; type UserMeResponse = Awaited>; @@ -206,6 +212,31 @@ const refreshAccessToken = async (token: AuthToken): Promise => { applyDecodedClaims(nextToken, newAccessToken, "refreshed access token"); + try { + const userMeResponse = await getUserByMe( + newAccessToken, + AbortSignal.timeout(USER_REFRESH_TIMEOUT_MS), + ); + nextToken.user = tokenUserFromApi(userMeResponse); + } catch (error) { + if ( + error instanceof UserMeError && + error.status !== undefined && + NON_RETRYABLE_USER_ME_STATUSES.has(error.status) + ) { + return { + ...nextToken, + accessToken: undefined, + refreshToken: undefined, + user: undefined, + error: "RefreshAccessTokenError", + }; + } + + // eslint-disable-next-line no-console + console.warn("Unable to refresh user after access token refresh"); + } + return nextToken; } catch (error) { // eslint-disable-next-line no-console diff --git a/ui/changelog.d/aws-marketplace-button.added.md b/ui/changelog.d/aws-marketplace-button.added.md new file mode 100644 index 0000000000..235112967f --- /dev/null +++ b/ui/changelog.d/aws-marketplace-button.added.md @@ -0,0 +1 @@ +AWS Marketplace button variant with outlined styling for light and dark themes diff --git a/ui/changelog.d/cloudflare-token-permissions.fixed.md b/ui/changelog.d/cloudflare-token-permissions.fixed.md new file mode 100644 index 0000000000..9a862ee88c --- /dev/null +++ b/ui/changelog.d/cloudflare-token-permissions.fixed.md @@ -0,0 +1 @@ +Cloudflare API token links in the provider wizard request the SSL and Certificates, Bot Management and Zone WAF read permissions the scan needs diff --git a/ui/changelog.d/defer-onboarding-on-billing.fixed.md b/ui/changelog.d/defer-onboarding-on-billing.fixed.md new file mode 100644 index 0000000000..5afcc36bcc --- /dev/null +++ b/ui/changelog.d/defer-onboarding-on-billing.fixed.md @@ -0,0 +1 @@ +Automatic onboarding stays hidden on billing pages and remains available after leaving billing diff --git a/ui/changelog.d/disable-self-registration.added.md b/ui/changelog.d/disable-self-registration.added.md new file mode 100644 index 0000000000..46e62eb1b3 --- /dev/null +++ b/ui/changelog.d/disable-self-registration.added.md @@ -0,0 +1 @@ +`UI_SELF_REGISTRATION_ENABLED` flag for Prowler Private Cloud deployments; when `"false"`, `/sign-up` only opens with an invitation, the sign-in page drops the "Sign up" link and the profile hides **Create organization** diff --git a/ui/changelog.d/fedramp-20x-cross-provider-breakdown.fixed.md b/ui/changelog.d/fedramp-20x-cross-provider-breakdown.fixed.md new file mode 100644 index 0000000000..dc7c71ce1b --- /dev/null +++ b/ui/changelog.d/fedramp-20x-cross-provider-breakdown.fixed.md @@ -0,0 +1 @@ +Per-provider breakdown and OCSF download for FedRAMP 20x KSI and Class C FRR in the cross-provider compliance view diff --git a/ui/changelog.d/invitation-row-actions-non-pending.fixed.md b/ui/changelog.d/invitation-row-actions-non-pending.fixed.md new file mode 100644 index 0000000000..dda0527b0d --- /dev/null +++ b/ui/changelog.d/invitation-row-actions-non-pending.fixed.md @@ -0,0 +1 @@ +Edit and Revoke actions are disabled for expired and revoked invitations diff --git a/ui/changelog.d/onboarding-invite-step.added.md b/ui/changelog.d/onboarding-invite-step.added.md new file mode 100644 index 0000000000..c2220888b5 --- /dev/null +++ b/ui/changelog.d/onboarding-invite-step.added.md @@ -0,0 +1 @@ +"Invite your team" step offered once after the first provider is connected, before the onboarding checkpoint, reusing the invitation form tagged with `source=onboarding` diff --git a/ui/changelog.d/registry-private-cloud.added.md b/ui/changelog.d/registry-private-cloud.added.md new file mode 100644 index 0000000000..0f5b9fb6d3 --- /dev/null +++ b/ui/changelog.d/registry-private-cloud.added.md @@ -0,0 +1 @@ +Registry marketplace and external provider onboarding for Private Cloud, with permission-based access independent of billing, confirmed artifact installation, schema-driven credentials, connection checks, and scan launch diff --git a/ui/components/auth/oss/auth-form.tsx b/ui/components/auth/oss/auth-form.tsx index a9f3e003d3..953bc1235b 100644 --- a/ui/components/auth/oss/auth-form.tsx +++ b/ui/components/auth/oss/auth-form.tsx @@ -9,6 +9,7 @@ export const AuthForm = ({ githubAuthUrl, isGoogleOAuthEnabled, isGithubOAuthEnabled, + isSelfRegistrationEnabled = true, }: { type: string; invitationToken?: string | null; @@ -17,6 +18,7 @@ export const AuthForm = ({ githubAuthUrl?: string; isGoogleOAuthEnabled?: boolean; isGithubOAuthEnabled?: boolean; + isSelfRegistrationEnabled?: boolean; }) => { if (type === "sign-in") { return ( @@ -25,6 +27,7 @@ export const AuthForm = ({ githubAuthUrl={githubAuthUrl} isGoogleOAuthEnabled={isGoogleOAuthEnabled} isGithubOAuthEnabled={isGithubOAuthEnabled} + isSelfRegistrationEnabled={isSelfRegistrationEnabled} /> ); } diff --git a/ui/components/auth/oss/sign-in-form.tsx b/ui/components/auth/oss/sign-in-form.tsx index 43ad982217..ed51346738 100644 --- a/ui/components/auth/oss/sign-in-form.tsx +++ b/ui/components/auth/oss/sign-in-form.tsx @@ -34,11 +34,13 @@ export const SignInForm = ({ githubAuthUrl, isGoogleOAuthEnabled, isGithubOAuthEnabled, + isSelfRegistrationEnabled = true, }: { googleAuthUrl?: string; githubAuthUrl?: string; isGoogleOAuthEnabled?: boolean; isGithubOAuthEnabled?: boolean; + isSelfRegistrationEnabled?: boolean; }) => { const router = useRouter(); const searchParams = useSearchParams(); @@ -80,6 +82,11 @@ export const SignInForm = ({ description: "There was a problem with your session. Please sign in again.", }, + SelfRegistrationDisabled: { + title: "Registration Disabled", + description: + "Self-registration is disabled. Ask an administrator for an invitation.", + }, }; const errorConfig = errorMessages[sessionError] || { @@ -161,11 +168,13 @@ export const SignInForm = ({ + isSelfRegistrationEnabled ? ( + + ) : undefined } >
diff --git a/ui/components/compliance/compliance-custom-details/fedramp-20x-details.tsx b/ui/components/compliance/compliance-custom-details/fedramp-20x-details.tsx new file mode 100644 index 0000000000..31f684a33b --- /dev/null +++ b/ui/components/compliance/compliance-custom-details/fedramp-20x-details.tsx @@ -0,0 +1,89 @@ +import { Requirement } from "@/types/compliance"; + +import { + ComplianceBadge, + ComplianceBadgeContainer, + ComplianceDetailContainer, + ComplianceDetailSection, + ComplianceDetailText, +} from "./shared-components"; + +interface FedRAMP20xDetailsProps { + requirement: Requirement; +} + +const DescriptionSection = ({ requirement }: FedRAMP20xDetailsProps) => + requirement.description ? ( + + {requirement.description} + + ) : null; + +export const FedRAMP20xKSICustomDetails = ({ + requirement, +}: FedRAMP20xDetailsProps) => { + return ( + + + + + {requirement.theme && ( + + )} + {requirement.class_applicability && ( + + )} + + + {requirement.nist_controls && ( + + + {requirement.nist_controls as string} + + + )} + + ); +}; + +export const FedRAMP20xFRRCustomDetails = ({ + requirement, +}: FedRAMP20xDetailsProps) => { + return ( + + + + + {requirement.ruleset && ( + + )} + {requirement.subset && ( + + )} + {requirement.force && ( + + )} + + + ); +}; diff --git a/ui/components/integrations/integration-connection-task-handler.test.ts b/ui/components/integrations/integration-connection-task-handler.test.ts new file mode 100644 index 0000000000..cce2174501 --- /dev/null +++ b/ui/components/integrations/integration-connection-task-handler.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { revalidateIntegrationConnectionPagesMock, toastMock } = vi.hoisted( + () => ({ + revalidateIntegrationConnectionPagesMock: vi.fn(), + toastMock: vi.fn(), + }), +); + +vi.mock("@/actions/integrations", () => ({ + revalidateIntegrationConnectionPages: + revalidateIntegrationConnectionPagesMock, +})); + +vi.mock("@/components/shadcn/toast", () => ({ + toast: toastMock, +})); + +import { integrationConnectionTaskHandler } from "./integration-connection-task-handler"; + +const task = { + taskId: "task-1", + kind: "integration-connection-test", + status: "ready" as const, + meta: { integrationId: "jira-1" }, + startedAt: 1, +}; + +describe("integration connection task handler", () => { + beforeEach(() => { + vi.clearAllMocks(); + revalidateIntegrationConnectionPagesMock.mockResolvedValue(undefined); + }); + + it("revalidates integrations and reports a resumed success", () => { + // When + integrationConnectionTaskHandler.onReady({ + ...task, + result: { connected: true, error: null }, + }); + + // Then + expect(revalidateIntegrationConnectionPagesMock).toHaveBeenCalledOnce(); + expect(toastMock).toHaveBeenCalledWith({ + title: "Connection test successful!", + description: "Connection test completed successfully.", + }); + }); + + it("reports a resumed backend failure", () => { + // When + integrationConnectionTaskHandler.onReady({ + ...task, + result: { connected: false, error: "Missing permission" }, + }); + + // Then + expect(toastMock).toHaveBeenCalledWith({ + variant: "destructive", + title: "Connection test failed", + description: "Missing permission", + }); + }); +}); diff --git a/ui/components/integrations/integration-connection-task-handler.ts b/ui/components/integrations/integration-connection-task-handler.ts new file mode 100644 index 0000000000..2b9de9c58f --- /dev/null +++ b/ui/components/integrations/integration-connection-task-handler.ts @@ -0,0 +1,42 @@ +"use client"; + +import { revalidateIntegrationConnectionPages } from "@/actions/integrations"; +import { toast } from "@/components/shadcn/toast"; +import { evaluateIntegrationConnectionTask } from "@/lib/integrations/test-connection-result"; +import type { TaskKindHandler } from "@/store/task-watcher/store"; +import type { IntegrationConnectionTaskResult } from "@/types/integrations"; + +const refreshIntegrationPages = (): void => { + void revalidateIntegrationConnectionPages().catch(() => undefined); +}; + +export const integrationConnectionTaskHandler: TaskKindHandler = { + onReady: (task) => { + refreshIntegrationPages(); + const result = evaluateIntegrationConnectionTask( + task.result as IntegrationConnectionTaskResult | undefined, + ); + + if (result.success) { + toast({ + title: "Connection test successful!", + description: result.message, + }); + return; + } + + toast({ + variant: "destructive", + title: "Connection test failed", + description: result.error, + }); + }, + onError: (task) => { + refreshIntegrationPages(); + toast({ + variant: "destructive", + title: "Connection test failed", + description: task.error || "The connection test failed unexpectedly.", + }); + }, +}; diff --git a/ui/components/integrations/jira/jira-integrations-manager.tsx b/ui/components/integrations/jira/jira-integrations-manager.tsx index e63b17dac3..6939173d9c 100644 --- a/ui/components/integrations/jira/jira-integrations-manager.tsx +++ b/ui/components/integrations/jira/jira-integrations-manager.tsx @@ -4,11 +4,7 @@ import { format } from "date-fns"; import { PlusIcon, Trash2Icon } from "lucide-react"; import { useState } from "react"; -import { - deleteIntegration, - testIntegrationConnection, - updateIntegration, -} from "@/actions/integrations"; +import { deleteIntegration, updateIntegration } from "@/actions/integrations"; import { JiraIcon } from "@/components/icons/services/IconServices"; import { IntegrationActionButtons, @@ -24,7 +20,10 @@ import { } from "@/components/shadcn"; import { Modal } from "@/components/shadcn/modal"; import { DataTablePagination } from "@/components/shadcn/table/data-table-pagination"; -import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper"; +import { + executeIntegrationConnectionTest, + triggerTestConnectionWithDelay, +} from "@/lib/integrations/test-connection-helper"; import { MetaDataProps } from "@/types"; import { IntegrationProps } from "@/types/integrations"; @@ -98,7 +97,7 @@ export const JiraIntegrationsManager = ({ const handleTestConnection = async (id: string) => { setIsTesting(id); try { - const result = await testIntegrationConnection(id); + const result = await executeIntegrationConnectionTest(id); if (result.success) { toast({ diff --git a/ui/components/integrations/s3/s3-integrations-manager.tsx b/ui/components/integrations/s3/s3-integrations-manager.tsx index 03ec525abc..bfdae3a3ca 100644 --- a/ui/components/integrations/s3/s3-integrations-manager.tsx +++ b/ui/components/integrations/s3/s3-integrations-manager.tsx @@ -4,11 +4,7 @@ import { format } from "date-fns"; import { PlusIcon, Trash2Icon } from "lucide-react"; import { useState } from "react"; -import { - deleteIntegration, - testIntegrationConnection, - updateIntegration, -} from "@/actions/integrations"; +import { deleteIntegration, updateIntegration } from "@/actions/integrations"; import { AmazonS3Icon } from "@/components/icons/services/IconServices"; import { IntegrationActionButtons, @@ -24,7 +20,10 @@ import { } from "@/components/shadcn"; import { Modal } from "@/components/shadcn/modal"; import { DataTablePagination } from "@/components/shadcn/table/data-table-pagination"; -import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper"; +import { + executeIntegrationConnectionTest, + triggerTestConnectionWithDelay, +} from "@/lib/integrations/test-connection-helper"; import { MetaDataProps } from "@/types"; import { IntegrationProps } from "@/types/integrations"; import { ProviderProps } from "@/types/providers"; @@ -112,7 +111,7 @@ export const S3IntegrationsManager = ({ const handleTestConnection = async (id: string) => { setIsTesting(id); try { - const result = await testIntegrationConnection(id); + const result = await executeIntegrationConnectionTest(id); if (result.success) { toast({ diff --git a/ui/components/integrations/saml/saml-config-form.test.tsx b/ui/components/integrations/saml/saml-config-form.test.tsx index a067d8b2d2..5f567bba4e 100644 --- a/ui/components/integrations/saml/saml-config-form.test.tsx +++ b/ui/components/integrations/saml/saml-config-form.test.tsx @@ -53,10 +53,12 @@ const runtimeConfig: RuntimePublicConfig = { apiDocsUrl: null, posthogEnabled: false, posthogKey: null, - posthogHost: null, + posthogIngestionHost: null, + posthogUiHost: null, reoDevClientId: null, cloudEnabled: false, cloudBillingEnabled: false, + selfRegistrationEnabled: true, stripePublishableKey: null, stripePublishableKeyV2: null, }; diff --git a/ui/components/integrations/security-hub/security-hub-integrations-manager.tsx b/ui/components/integrations/security-hub/security-hub-integrations-manager.tsx index c0c5f9f02e..b06b0bee90 100644 --- a/ui/components/integrations/security-hub/security-hub-integrations-manager.tsx +++ b/ui/components/integrations/security-hub/security-hub-integrations-manager.tsx @@ -4,11 +4,7 @@ import { format } from "date-fns"; import { PlusIcon, Trash2Icon } from "lucide-react"; import { useState } from "react"; -import { - deleteIntegration, - testIntegrationConnection, - updateIntegration, -} from "@/actions/integrations"; +import { deleteIntegration, updateIntegration } from "@/actions/integrations"; import { AWSSecurityHubIcon } from "@/components/icons/services/IconServices"; import { IntegrationActionButtons, @@ -25,7 +21,10 @@ import { } from "@/components/shadcn"; import { Modal } from "@/components/shadcn/modal"; import { DataTablePagination } from "@/components/shadcn/table/data-table-pagination"; -import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper"; +import { + executeIntegrationConnectionTest, + triggerTestConnectionWithDelay, +} from "@/lib/integrations/test-connection-helper"; import { MetaDataProps } from "@/types"; import { IntegrationProps } from "@/types/integrations"; import { ProviderProps } from "@/types/providers"; @@ -114,7 +113,7 @@ export const SecurityHubIntegrationsManager = ({ const handleTestConnection = async (id: string) => { setIsTesting(id); try { - const result = await testIntegrationConnection(id); + const result = await executeIntegrationConnectionTest(id); if (result.success) { toast({ diff --git a/ui/components/integrations/shared/integration-card-header.tsx b/ui/components/integrations/shared/integration-card-header.tsx index e78f12ed0d..b7f247a41b 100644 --- a/ui/components/integrations/shared/integration-card-header.tsx +++ b/ui/components/integrations/shared/integration-card-header.tsx @@ -10,17 +10,18 @@ import { cn } from "@/lib/utils"; const CONNECTION_BADGE = { connected: { label: "Connected", - className: - "bg-bg-pass-secondary text-text-success-primary border-transparent", + variant: "success", + dotClassName: "bg-bg-pass", }, disconnected: { label: "Disconnected", - className: - "bg-bg-fail-secondary text-text-error-primary border-transparent", + variant: "error", + dotClassName: "bg-bg-fail", }, unchecked: { label: "Not checked yet", - className: "border-border-tag bg-bg-tag text-text-neutral-secondary", + variant: "tag", + dotClassName: "bg-bg-data-muted", }, } as const; @@ -45,30 +46,57 @@ interface IntegrationCardHeaderProps { icon: ReactNode; title: string; subtitle?: string; + /** + * A quiet third line under the subtitle, for a fact about the integration + * rather than a claim about it — when its connection was last checked, say. + */ + meta?: ReactNode; chips?: IntegrationCardChip[]; connectionStatus?: IntegrationConnectionStatus; navigationUrl?: string; + /** The integration's own controls, pinned to the end of the row. */ + actions?: ReactNode; } export const IntegrationCardHeader = ({ icon, title, subtitle, + meta, chips = [], connectionStatus, navigationUrl, + actions, }: IntegrationCardHeaderProps) => { const badgeState = connectionStatus ? connectionBadgeState(connectionStatus.connected) : null; const badge = badgeState ? CONNECTION_BADGE[badgeState] : null; + // The end of the row belongs to the controls wherever there are any, so the + // status travels with the name it qualifies instead of across the card. + const statusBesideTitle = Boolean(actions); + + const statusBadge = + badge && badgeState ? ( + + + ) : null; + + const hasAside = + chips.length > 0 || Boolean(actions) || (statusBadge && !statusBesideTitle); + return (
{icon} -
-
+
+

{title}

{navigationUrl && ( )} + {statusBesideTitle && statusBadge}
{subtitle && ( -

- {subtitle} -

+

{subtitle}

)} + {meta}
- {(chips.length > 0 || badge) && ( + {hasAside && (
{chips.map((chip, index) => ( {chip.label} ))} - {badge && badgeState && ( - - {connectionStatus?.label || badge.label} - - )} + {!statusBesideTitle && statusBadge} + {actions}
)}
diff --git a/ui/components/integrations/slack/slack-channel-multi-select.tsx b/ui/components/integrations/slack/slack-channel-multi-select.tsx index 08d07f67b6..02d4d4f88e 100644 --- a/ui/components/integrations/slack/slack-channel-multi-select.tsx +++ b/ui/components/integrations/slack/slack-channel-multi-select.tsx @@ -1,7 +1,8 @@ "use client"; -import { Lock, RefreshCw } from "lucide-react"; +import { RefreshCw } from "lucide-react"; +import { SlackInlineCode } from "@/components/integrations/slack/slack-inline-code"; import { Alert, AlertDescription, @@ -10,6 +11,7 @@ import { Button, Label, } from "@/components/shadcn"; +import { CustomLink } from "@/components/shadcn/custom/custom-link"; import { MultiSelect, MultiSelectContent, @@ -17,10 +19,23 @@ import { MultiSelectTrigger, MultiSelectValue, } from "@/components/shadcn/select/multiselect"; +import { DOCS_URLS } from "@/lib/external-urls"; import type { SlackChannelOption } from "@/types/integrations"; -const INVITE_HINT = - "A private channel only appears here after someone invites @Prowler to it in Slack. Invite it, then refresh."; +const INVITE_HINT = ( + <> + A private channel only appears here after someone invites{" "} + @Prowler Cloud to it in Slack. Invite it, + then refresh.{" "} + + Learn more + + +); interface SlackChannelMultiSelectProps { options: SlackChannelOption[]; @@ -38,15 +53,15 @@ interface SlackChannelMultiSelectProps { describedBy?: string; } +/** Marked exactly as the listing marks it, so the chip is the row it came from. */ const chipLabel = (option: SlackChannelOption) => ( - {option.is_private && ( - <> - ); @@ -104,8 +119,10 @@ export const SlackChannelMultiSelect = ({ No channels available yet Prowler cannot see a single channel in this workspace. Create a - public channel, or invite @Prowler to a private one in Slack with - /invite @Prowler, then + public channel, or invite{" "} + @Prowler Cloud to a private one + in Slack with{" "} + /invite @Prowler Cloud, then refresh. diff --git a/ui/components/integrations/slack/slack-connection-check-status.tsx b/ui/components/integrations/slack/slack-connection-check-status.tsx new file mode 100644 index 0000000000..ea925db602 --- /dev/null +++ b/ui/components/integrations/slack/slack-connection-check-status.tsx @@ -0,0 +1,124 @@ +import { Check, CircleAlert, Loader2, type LucideIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +import { cn } from "@/lib/utils"; + +// Read by the page harness off `data-connection-check-status`. +export const CHECK_STATUS = { + /** No check has run against the channels currently on record. */ + IDLE: "idle", + RUNNING: "running", + PASSED: "passed", + FAILED: "failed", +} as const; + +export type CheckStatus = (typeof CHECK_STATUS)[keyof typeof CHECK_STATUS]; + +interface CheckStatusStyle { + /** `null` for the resting state, which shows no outcome to decorate. */ + icon: LucideIcon | null; + className: string; +} + +const CHECK_STATUS_STYLES = { + [CHECK_STATUS.IDLE]: { + icon: null, + className: "", + }, + [CHECK_STATUS.RUNNING]: { + icon: Loader2, + className: + "border-border-neutral-secondary bg-bg-neutral-tertiary text-text-neutral-secondary", + }, + // The dark pass tokens are sized for a whole card, and at this width they + // read as an alert rather than a note, so they are taken down a shade there. + [CHECK_STATUS.PASSED]: { + icon: Check, + className: + "border-bg-pass bg-bg-pass-secondary text-text-success-primary dark:border-bg-pass/40 dark:bg-bg-pass-secondary/40", + }, + [CHECK_STATUS.FAILED]: { + icon: CircleAlert, + className: + "border-border-error bg-bg-fail-secondary text-text-error-primary dark:border-border-error/50", + }, +} as const satisfies Record; + +interface SlackConnectionCheckStatusProps { + status: CheckStatus; + /** What the last check found. Absent while no check covers the record. */ + outcome?: ReactNode; + /** + * Id the control points at with `aria-describedby`. The caption alone carries + * it: an outcome read out as the control's description would say what the + * last check did, where the description has to say what pressing it does. + */ + captionId: string; + /** What the check does, or why it cannot run. */ + children: ReactNode; +} + +/** + * The connection check's standing state: what a check last found, over the + * caption explaining what running one does. + * + * The two are kept in separate registers — the outcome boxed, the caption plain + * underneath — because they are answers to different questions and can honestly + * disagree. A refused channel is still a channel the next check will try, so + * reading the caption as a continuation of the outcome ("Slack refused #ops" … + * "posts the confirmation to #ops") would turn a true pair of statements into a + * contradiction. + */ +export const SlackConnectionCheckStatus = ({ + status, + outcome, + captionId, + children, +}: SlackConnectionCheckStatusProps) => { + const { icon: Icon, className } = CHECK_STATUS_STYLES[status]; + + return ( +
+
+ {Icon && outcome && ( +
+

+ {children} +

+
+ ); +}; diff --git a/ui/components/integrations/slack/slack-inline-code.tsx b/ui/components/integrations/slack/slack-inline-code.tsx new file mode 100644 index 0000000000..a197148d47 --- /dev/null +++ b/ui/components/integrations/slack/slack-inline-code.tsx @@ -0,0 +1,15 @@ +import type { ReactNode } from "react"; + +interface SlackInlineCodeProps { + children: ReactNode; +} + +/** + * A Slack identifier — a channel name, the bot mention, a slash command — set + * apart from the prose around it. + */ +export const SlackInlineCode = ({ children }: SlackInlineCodeProps) => ( + + {children} + +); diff --git a/ui/components/integrations/slack/slack-integration-card.tsx b/ui/components/integrations/slack/slack-integration-card.tsx index a4e8397afb..619e9242c9 100644 --- a/ui/components/integrations/slack/slack-integration-card.tsx +++ b/ui/components/integrations/slack/slack-integration-card.tsx @@ -4,9 +4,7 @@ import Link from "next/link"; import { SlackIcon } from "@/components/icons/services/IconServices"; import { Button, Card, CardContent, CardHeader } from "@/components/shadcn"; import { CustomLink } from "@/components/shadcn/custom/custom-link"; - -const SLACK_DOCS_URL = - "https://docs.prowler.com/user-guide/tutorials/prowler-app-slack-integration"; +import { DOCS_URLS } from "@/lib/external-urls"; export const SlackIntegrationCard = () => { return ( @@ -24,7 +22,7 @@ export const SlackIntegrationCard = () => { Send Prowler messages to your Slack workspace.

diff --git a/ui/components/integrations/slack/slack-integration-manager.test.tsx b/ui/components/integrations/slack/slack-integration-manager.test.tsx index 57ed73973c..c792afa600 100644 --- a/ui/components/integrations/slack/slack-integration-manager.test.tsx +++ b/ui/components/integrations/slack/slack-integration-manager.test.tsx @@ -17,8 +17,8 @@ vi.mock("@/actions/integrations/slack", () => ({ setSlackAuthorizedChannels: vi.fn(), })); -vi.mock("@/actions/integrations/integrations", () => ({ - testIntegrationConnection: vi.fn(), +vi.mock("@/lib/integrations/test-connection-helper", () => ({ + executeIntegrationConnectionTest: vi.fn(), })); /** diff --git a/ui/components/integrations/slack/slack-integration-manager.tsx b/ui/components/integrations/slack/slack-integration-manager.tsx index 66c6e9c9f7..40d220a97c 100644 --- a/ui/components/integrations/slack/slack-integration-manager.tsx +++ b/ui/components/integrations/slack/slack-integration-manager.tsx @@ -2,9 +2,8 @@ import { format, isValid, parseISO } from "date-fns"; import { TestTube, Unplug } from "lucide-react"; -import { useEffect, useState } from "react"; +import { type ReactNode, useEffect, useState } from "react"; -import { testIntegrationConnection } from "@/actions/integrations/integrations"; import { disconnectSlackIntegration, getSlackAuthorizeUrl, @@ -14,6 +13,12 @@ import { import { SlackIcon } from "@/components/icons/services/IconServices"; import { IntegrationCardHeader } from "@/components/integrations/shared"; import { SlackChannelMultiSelect } from "@/components/integrations/slack/slack-channel-multi-select"; +import { + CHECK_STATUS, + type CheckStatus, + SlackConnectionCheckStatus, +} from "@/components/integrations/slack/slack-connection-check-status"; +import { SlackInlineCode } from "@/components/integrations/slack/slack-inline-code"; import { Alert, AlertDescription, @@ -31,6 +36,7 @@ import { slackErrorMessage, } from "@/lib/integrations/slack-errors"; import type { SlackTokenErrorCode } from "@/lib/integrations/slack-errors"; +import { executeIntegrationConnectionTest } from "@/lib/integrations/test-connection-helper"; import type { IntegrationProps, SlackAuthorizedChannel, @@ -76,6 +82,20 @@ interface UnconfirmedRevocation { workspace: string | null; } +/** + * What the last check found, together with what it was measured against. Both + * ride with the finding so it can never outlive them: a green line vouching for + * a channel nothing ever reached is worse than no line at all, and so is one + * standing over a notice saying the credential is dead. + */ +interface ConnectionCheckOutcome { + reachable: boolean; + summary: string; + channelIds: string[]; + /** The credential verdict the check was run under. */ + credentialFailure: SlackTokenErrorCode | null; +} + /** Order-insensitive: the mirror must not re-seed on a mere reordering. */ const sameChannelIds = (a: string[], b: string[]) => a.length === b.length && new Set([...a, ...b]).size === a.length; @@ -129,10 +149,39 @@ const recordedFromSelection = ( ]; }); +const CHANNEL_LIST_FORMAT = new Intl.ListFormat("en", { + style: "long", + type: "conjunction", +}); + +const channelTokens = (names: string[]): string[] => + names.map((name) => `#${name}`); + +/** For the copy that travels as plain text: toasts, the deauthorize warning. */ const channelList = (names: string[]): string => - new Intl.ListFormat("en", { style: "long", type: "conjunction" }).format( - names.map((name) => `#${name}`), - ); + CHANNEL_LIST_FORMAT.format(channelTokens(names)); + +interface StyledChannelListProps { + names: string[]; +} + +/** + * The same list as `channelList()`, each channel set apart. `formatToParts` + * emits exactly the literals `format` joins, so the rendered text is unchanged + * (design D3) — the copy is read as text content, here and by the tests. + */ +const StyledChannelList = ({ names }: StyledChannelListProps) => ( + <> + {CHANNEL_LIST_FORMAT.formatToParts(channelTokens(names)).map( + (part, index) => + part.type === "element" ? ( + {part.value} + ) : ( + part.value + ), + )} + +); /** * Slack's own reason, when the string is one: the connection check reports a @@ -160,6 +209,10 @@ export const SlackIntegrationManager = ({ loadError, }: SlackIntegrationManagerProps) => { const [isTesting, setIsTesting] = useState(false); + // A check's finding outlives its toast: the card keeps saying where the + // workspace stands until a change to the channels makes the finding moot. + const [checkOutcome, setCheckOutcome] = + useState(null); const [isDisconnectOpen, setIsDisconnectOpen] = useState(false); const [isDisconnecting, setIsDisconnecting] = useState(false); // The row is gone the moment the API says so; the server component's @@ -317,15 +370,55 @@ export const SlackIntegrationManager = ({ (channel) => !selectedChannelIds.includes(channel.id), ); - const checkHint = (): string => { + const authorizedChannelIds = authorizedChannels.map((channel) => channel.id); + + /** + * Derived, not stored, so a finding retires on its own the moment anything it + * was measured against moves: a channel authorized or dropped leaves a set no + * check has covered, and a credential verdict that has since changed leaves a + * finding taken under conditions that no longer hold. + */ + const coveredOutcome = + checkOutcome && + sameChannelIds(checkOutcome.channelIds, authorizedChannelIds) && + checkOutcome.credentialFailure === credentialFailure + ? checkOutcome + : null; + + const checkStatus = (): CheckStatus => { + if (isTesting) return CHECK_STATUS.RUNNING; + if (!coveredOutcome) return CHECK_STATUS.IDLE; + return coveredOutcome.reachable ? CHECK_STATUS.PASSED : CHECK_STATUS.FAILED; + }; + + // Deliberately not a second telling of the caption below it: the button + // already reads "Testing...", so this only has to say a check is under way. + const checkSummary = isTesting + ? "Checking the connection..." + : coveredOutcome?.summary; + + /** What a passing check proves, said in terms of what was actually reached. */ + const reachableSummary = (channels: SlackAuthorizedChannel[]): string => + channels.length === 1 + ? `#${channels[0].name} is reachable.` + : `All ${channels.length} channels are reachable.`; + + const checkHint = (): ReactNode => { if (authorizedChannels.length === 0) { return "Authorize at least one destination channel below to enable this check."; } - return unconfirmedChannels.length > 0 - ? `Checks every authorized channel and posts “${CONFIRMATION_MESSAGE}” once to ${channelList( - unconfirmedChannels.map((channel) => channel.name), - )}.` - : "Checks every authorized channel. Each was confirmed once already, so nothing is posted."; + return unconfirmedChannels.length > 0 ? ( + <> + Checks every authorized channel and posts “{CONFIRMATION_MESSAGE}” once + to{" "} + channel.name)} + /> + . + + ) : ( + "Checks every authorized channel. Nothing is posted." + ); }; const handleSaveChannels = async () => { @@ -395,12 +488,24 @@ export const SlackIntegrationManager = ({ // Passed in by a chained check: it runs before the save's state lands. channels: SlackAuthorizedChannel[] = authorizedChannels, ) => { + const checkedChannelIds = channels.map((channel) => channel.id); + setIsTesting(true); try { - const result = await testIntegrationConnection(id); + const result = await executeIntegrationConnectionTest(id); if (result.success) { provedCredentialAlive(); + setCheckOutcome({ + reachable: true, + // Prowler's own count, not the API's prose: the line has to keep + // meaning the same thing every time it is read. + summary: reachableSummary(channels), + channelIds: checkedChannelIds, + // An answer at all clears the verdict, so a pass is always taken + // under a live credential. + credentialFailure: null, + }); toast({ title: "Connection test successful!", description: @@ -411,8 +516,13 @@ export const SlackIntegrationManager = ({ // A dead credential named here is not a failure checking again can // fix, so the reason is recorded and not only reported. const reason = result.error?.trim() || null; + const reasonCode = asReasonCode(reason); - recordRefusal(asReasonCode(reason)); + recordRefusal(reasonCode); + + // The verdict this check leaves behind: its own reason when it named + // one, else the last answer's, which nothing here contradicted. + const codeAfterCheck = reasonCode ?? lastRefusalCode; const explanation = reason ? slackErrorMessage({ code: reason, detail: reason }) @@ -424,15 +534,32 @@ export const SlackIntegrationManager = ({ ? channels.find((channel) => channel.id === result.failedChannelId) : undefined; + const refusal = refusedChannel + ? `Slack refused #${refusedChannel.name}: ${explanation}` + : explanation; + + setCheckOutcome({ + reachable: false, + summary: refusal, + channelIds: checkedChannelIds, + credentialFailure: isSlackTokenErrorCode(codeAfterCheck) + ? codeAfterCheck + : null, + }); toast({ variant: "destructive", title: "Connection test failed", - description: refusedChannel - ? `Slack refused #${refusedChannel.name}: ${explanation}` - : explanation, + description: refusal, }); } } catch (_error) { + setCheckOutcome({ + reachable: false, + summary: "Prowler could not reach Slack to run the check.", + channelIds: checkedChannelIds, + // Never an answer from Slack, so the standing verdict is untouched. + credentialFailure, + }); toast({ variant: "destructive", title: "Error", @@ -607,11 +734,18 @@ export const SlackIntegrationManager = ({ ) : integration && !disconnected ? ( - + } title={`Connected to ${workspaceName ?? "your Slack workspace"}`} subtitle="Prowler posts to this workspace only." + meta={ + lastCheckedOn && ( +

+ Last checked {lastCheckedOn} +

+ ) + } connectionStatus={{ // A dead token outranks the state the page was loaded with. connected: @@ -619,21 +753,8 @@ export const SlackIntegrationManager = ({ ? integration.attributes.connected : false, }} - /> -
- - -
-
- {lastCheckedOn && ( -

- Last checked:{" "} - {lastCheckedOn} -

- )} -
-
-
+ actions={ + <> {/* The check reaches the authorized channels: the API answers 400 while the set is empty. */} -
-

- {checkHint()} -

-
-
+ + } + /> -
+ {/* Below the row, not beside the buttons: the width is what lets + the check say what it did without wrapping into a column. */} + + {checkHint()} + + + + +
- {authorizedChannels.length > 0 - ? `Prowler posts to ${channelList( - authorizedChannels.map((channel) => channel.name), - )}.` - : "No destination channels authorized yet."} + {authorizedChannels.length > 0 ? ( + <> + Prowler posts to{" "} + channel.name, + )} + /> + . + + ) : ( + "No destination channels authorized yet." + )}

+ ), +})); + +const TENANT_ID = "3f6c2f1e-7b0a-4d5c-9a21-0c9f4f2a7b10"; +const OTHER_TENANT_ID = "8a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"; +const MARKER_KEY = onboardingInviteMarkerKey(TENANT_ID) as string; + +const CHECKPOINT_TITLE = "Provider added — keep exploring?"; + +describe("OnboardingCheckpointWatcher invite step", () => { + beforeEach(() => { + window.localStorage.clear(); + checkpointOpenState = true; + }); + + it("offers the invite step before the checkpoint dialog and keeps the store open", async () => { + // Given + const user = userEvent.setup(); + render(); + // The step is loaded on demand, so it arrives a tick after render. + expect( + await screen.findByRole("button", { name: "Resolve invite step" }), + ).toBeInTheDocument(); + expect(screen.queryByText(CHECKPOINT_TITLE)).not.toBeInTheDocument(); + + // When + await user.click( + screen.getByRole("button", { name: "Resolve invite step" }), + ); + + // Then + expect(await screen.findByText(CHECKPOINT_TITLE)).toBeInTheDocument(); + expect(window.localStorage.getItem(MARKER_KEY)).toBe("true"); + }); + + it("is off unless a deployment opts in", () => { + // When + render(); + + // Then + expect(screen.getByText(CHECKPOINT_TITLE)).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Resolve invite step" }), + ).not.toBeInTheDocument(); + }); + + it("does not offer the step again once this browser saw it", () => { + // Given + window.localStorage.setItem(MARKER_KEY, "true"); + + // When + render(); + + // Then + expect(screen.getByText(CHECKPOINT_TITLE)).toBeInTheDocument(); + }); + + it("renders nothing for the step while the checkpoint is not requested", () => { + // Given + checkpointOpenState = false; + + // When + render(); + + // Then + expect( + screen.queryByRole("button", { name: "Resolve invite step" }), + ).not.toBeInTheDocument(); + expect(screen.queryByText(CHECKPOINT_TITLE)).not.toBeInTheDocument(); + }); + + it("offers the step again to another tenant of the same browser", async () => { + // Given — this browser already saw it for one tenant. + window.localStorage.setItem(MARKER_KEY, "true"); + + // When + render(); + + // Then + expect( + await screen.findByRole("button", { name: "Resolve invite step" }), + ).toBeInTheDocument(); + }); + + it("does not offer the step without a usable tenant", () => { + // When + render(); + + // Then + expect(screen.getByText(CHECKPOINT_TITLE)).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Resolve invite step" }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/ui/components/onboarding/__tests__/onboarding-checkpoint-watcher.test.tsx b/ui/components/onboarding/__tests__/onboarding-checkpoint-watcher.test.tsx index f30fca862d..98e80744ab 100644 --- a/ui/components/onboarding/__tests__/onboarding-checkpoint-watcher.test.tsx +++ b/ui/components/onboarding/__tests__/onboarding-checkpoint-watcher.test.tsx @@ -25,6 +25,11 @@ vi.mock("@/store/onboarding-sequence", () => ({ }, })); +// The invite step is exercised in its own test; keep this one on the dialog. +vi.mock("../onboarding-invite-step", () => ({ + OnboardingInviteStep: () => null, +})); + vi.mock("@/store/onboarding-checkpoint", () => ({ CHECKPOINT_MARKER: "prowler.onboarding.checkpoint", useOnboardingCheckpointStore: Object.assign( diff --git a/ui/components/onboarding/__tests__/onboarding-gate.test.tsx b/ui/components/onboarding/__tests__/onboarding-gate.test.tsx index 4198ba5846..9a5f7cf88e 100644 --- a/ui/components/onboarding/__tests__/onboarding-gate.test.tsx +++ b/ui/components/onboarding/__tests__/onboarding-gate.test.tsx @@ -9,9 +9,11 @@ import { OnboardingGate } from "../onboarding-gate"; const pushMock = vi.fn(); const armMock = vi.fn(); +const pathnameMock = vi.fn(); vi.mock("next/navigation", () => ({ useRouter: () => ({ push: pushMock, replace: vi.fn() }), + usePathname: () => pathnameMock(), })); vi.mock("@/store/onboarding-checkpoint", () => ({ @@ -30,12 +32,62 @@ describe("OnboardingGate", () => { window.localStorage.clear(); pushMock.mockClear(); armMock.mockClear(); + pathnameMock.mockReturnValue("/"); }); afterEach(() => { vi.restoreAllMocks(); }); + it.each(["/billing", "/billing/", "/billing/checkout"])( + "defers onboarding on %s without resolving it", + (pathname) => { + // Given + pathnameMock.mockReturnValue(pathname); + + // When + render(); + + // Then + expect( + screen.queryByRole("button", { name: /get started/i }), + ).not.toBeInTheDocument(); + expect(localStorageAdapter.get(addProviderTourId)).toBeNull(); + expect(armMock).not.toHaveBeenCalled(); + expect(pushMock).not.toHaveBeenCalled(); + }, + ); + + it("offers onboarding after leaving billing without remounting the gate", async () => { + // Given + pathnameMock.mockReturnValue("/billing"); + const { rerender } = render(); + + // When + pathnameMock.mockReturnValue("/"); + rerender(); + + // Then + expect( + await screen.findByRole("button", { name: /get started/i }), + ).toBeInTheDocument(); + expect(localStorageAdapter.get(addProviderTourId)).toBeNull(); + expect(armMock).not.toHaveBeenCalled(); + }); + + it("does not suppress onboarding on a route that only shares the billing prefix", async () => { + // Given + pathnameMock.mockReturnValue("/billing-settings"); + + // When + render(); + + // Then + expect( + await screen.findByRole("button", { name: /get started/i }), + ).toBeInTheDocument(); + }); + describe("when the user has no providers and no completion record", () => { it("shows the Welcome modal", async () => { render(); diff --git a/ui/components/onboarding/__tests__/onboarding-invite-step.test.tsx b/ui/components/onboarding/__tests__/onboarding-invite-step.test.tsx new file mode 100644 index 0000000000..403bee8e63 --- /dev/null +++ b/ui/components/onboarding/__tests__/onboarding-invite-step.test.tsx @@ -0,0 +1,223 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + ONBOARDING_INVITE_STEP_EVENT, + type OnboardingInviteStepDetail, +} from "@/lib/onboarding/onboarding-events"; + +import { OnboardingInviteStep } from "../onboarding-invite-step"; + +const { getRolesMock, sendInviteMock, toastMock } = vi.hoisted(() => ({ + getRolesMock: vi.fn(), + sendInviteMock: vi.fn(), + toastMock: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn() }), +})); + +vi.mock("@/actions/onboarding/invite", () => ({ + getOnboardingInviteRoles: getRolesMock, +})); + +vi.mock("@/actions/invitations/invitation", () => ({ + sendInvite: sendInviteMock, +})); + +vi.mock("@/components/shadcn", async (importOriginal) => ({ + ...(await importOriginal()), + useToast: () => ({ toast: toastMock }), +})); + +// Radix Select does not open in jsdom; a native select keeps the test on the +// form's behaviour rather than the dropdown's. +vi.mock("@/components/shadcn/select/select", () => ({ + Select: ({ + value, + onValueChange, + disabled, + children, + }: { + value?: string; + onValueChange: (value: string) => void; + disabled?: boolean; + children: React.ReactNode; + }) => ( + + ), + SelectTrigger: () => null, + SelectValue: () => null, + SelectContent: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), + SelectItem: ({ + value, + children, + }: { + value: string; + children: React.ReactNode; + }) => , +})); + +const ROLES = [ + { id: "11111111-1111-4111-8111-111111111111", name: "member" }, + { id: "22222222-2222-4222-8222-222222222222", name: "admin" }, +]; + +describe("OnboardingInviteStep", () => { + const outcomes: OnboardingInviteStepDetail[] = []; + const recordOutcome = (event: Event) => { + outcomes.push((event as CustomEvent).detail); + }; + + beforeEach(() => { + outcomes.length = 0; + window.addEventListener(ONBOARDING_INVITE_STEP_EVENT, recordOutcome); + getRolesMock.mockReset().mockResolvedValue(ROLES); + sendInviteMock.mockReset().mockResolvedValue({ data: { id: "inv-1" } }); + toastMock.mockReset(); + }); + + afterEach(() => { + window.removeEventListener(ONBOARDING_INVITE_STEP_EVENT, recordOutcome); + }); + + it("announces the impression and renders the invitation form once roles load", async () => { + // When + render(); + + // Then + expect(outcomes).toEqual([{ outcome: "shown" }]); + expect(await screen.findByText("Invite your team")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /send invitation/i }), + ).toBeInTheDocument(); + // The admin role is listed first so it is the natural pick. + const options = screen.getAllByRole("option").map((o) => o.textContent); + expect(options).toEqual(["Select a role", "admin", "member"]); + }); + + it("sends the invitation tagged for onboarding and resolves the step", async () => { + // Given + const user = userEvent.setup(); + const onDone = vi.fn(); + render(); + await screen.findByText("Invite your team"); + + // When + await user.type(screen.getByLabelText(/email/i), "teammate@company.com"); + await user.selectOptions(screen.getByRole("combobox"), ROLES[1].id); + await user.click(screen.getByRole("button", { name: /send invitation/i })); + + // Then + await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1)); + const formData = sendInviteMock.mock.calls[0]?.[0] as FormData; + expect(formData.get("email")).toBe("teammate@company.com"); + expect(formData.get("role")).toBe(ROLES[1].id); + expect(formData.get("source")).toBe("onboarding"); + expect(outcomes).toEqual([{ outcome: "shown" }, { outcome: "submitted" }]); + }); + + it("keeps the step open when the API rejects the invitation", async () => { + // Given + const user = userEvent.setup(); + const onDone = vi.fn(); + sendInviteMock.mockResolvedValue({ + errors: [ + { + detail: "This email has already been invited.", + source: { pointer: "/data/attributes/email" }, + }, + ], + }); + render(); + await screen.findByText("Invite your team"); + + // When + await user.type(screen.getByLabelText(/email/i), "teammate@company.com"); + await user.selectOptions(screen.getByRole("combobox"), ROLES[1].id); + await user.click(screen.getByRole("button", { name: /send invitation/i })); + + // Then + expect( + await screen.findByText("This email has already been invited."), + ).toBeInTheDocument(); + expect(onDone).not.toHaveBeenCalled(); + expect(outcomes).toEqual([{ outcome: "shown" }]); + }); + + it("records a skip and resolves the step", async () => { + // Given + const user = userEvent.setup(); + const onDone = vi.fn(); + render(); + await screen.findByText("Invite your team"); + + // When + await user.click(screen.getByRole("button", { name: "Skip for now" })); + + // Then + expect(outcomes.at(-1)).toEqual({ outcome: "skipped" }); + expect(onDone).toHaveBeenCalledTimes(1); + expect(sendInviteMock).not.toHaveBeenCalled(); + }); + + it("still lets the user skip when roles cannot be loaded", async () => { + // Given + const user = userEvent.setup(); + const onDone = vi.fn(); + getRolesMock.mockRejectedValue(new Error("roles unavailable")); + render(); + await screen.findByText("Invite your team"); + + // Then + expect(screen.getByText(/Roles could not be loaded/)).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /send invitation/i }), + ).not.toBeInTheDocument(); + + // When + await user.click(screen.getByRole("button", { name: "Skip for now" })); + + // Then + expect(onDone).toHaveBeenCalledTimes(1); + }); + + it("falls back to the skip when the roles never arrive", async () => { + // Given — a request that never settles. + vi.useFakeTimers(); + try { + getRolesMock.mockReturnValue(new Promise(() => {})); + render(); + expect(screen.queryByText("Invite your team")).not.toBeInTheDocument(); + + // When — the step's own deadline passes. + await act(async () => { + await vi.advanceTimersByTimeAsync(5_000); + }); + + // Then + expect(screen.getByText("Invite your team")).toBeInTheDocument(); + expect(screen.getByText(/Roles could not be loaded/)).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /send invitation/i }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Skip for now" }), + ).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/ui/components/onboarding/onboarding-checkpoint-watcher.tsx b/ui/components/onboarding/onboarding-checkpoint-watcher.tsx index 1339b17f8c..6612b23993 100644 --- a/ui/components/onboarding/onboarding-checkpoint-watcher.tsx +++ b/ui/components/onboarding/onboarding-checkpoint-watcher.tsx @@ -1,8 +1,14 @@ "use client"; +import dynamic from "next/dynamic"; import { useRouter } from "next/navigation"; +import { useState } from "react"; import { getOrderedFlows } from "@/lib/onboarding"; +import { + isOnboardingInviteHandled, + markOnboardingInviteHandled, +} from "@/lib/onboarding/invite-marker"; import { CHECKPOINT_MARKER, useOnboardingCheckpointStore, @@ -11,6 +17,23 @@ import { useOnboardingSequenceStore } from "@/store/onboarding-sequence"; import { OnboardingCheckpointDialog } from "./onboarding-checkpoint-dialog"; +// Loaded on demand: the step pulls the invitation form and its server +// actions, which this module (re-exported by the shared barrel) must not +// carry statically. +const OnboardingInviteStep = dynamic( + () => + import("./onboarding-invite-step").then( + (module) => module.OnboardingInviteStep, + ), + { ssr: false }, +); + +interface OnboardingCheckpointWatcherProps { + // Scopes the invite step's local marker: the offer is per tenant, not per + // browser. Without it the step is not offered. + tenantId?: string | null; +} + // Sequence begins at the flow after `add-provider` (the gate). const FIRST_FLOW_ID = "add-provider"; @@ -24,9 +47,14 @@ function markCheckpointHandled(): void { } // Layout-level watcher: renders the checkpoint dialog when the store `open` flag is set. -export function OnboardingCheckpointWatcher() { +export function OnboardingCheckpointWatcher({ + tenantId = null, +}: OnboardingCheckpointWatcherProps = {}) { const router = useRouter(); const open = useOnboardingCheckpointStore((state) => state.open); + // Session flag: the marker is written on resolve, but a state change is + // what re-renders this component into the checkpoint dialog. + const [inviteResolved, setInviteResolved] = useState(false); const handleContinue = () => { markCheckpointHandled(); // before navigation to prevent re-open on re-render @@ -48,6 +76,19 @@ export function OnboardingCheckpointWatcher() { useOnboardingCheckpointStore.getState().close(); }; + // "Invite your team" goes first, once per tenant, and leaves the store + // open, so the checkpoint dialog follows unchanged once it resolves. + if (open && !inviteResolved && !isOnboardingInviteHandled(tenantId)) { + return ( + { + markOnboardingInviteHandled(tenantId); + setInviteResolved(true); + }} + /> + ); + } + return ( void; + onSkip: () => void; +} + +const DEFAULT_ROLE_NAME = "admin"; + +// Roles are listed with the admin one first so it is the natural pick for a +// first teammate; the form itself keeps the selection required. +const orderRoles = (roles: InvitationRoleOption[]) => + [...roles].sort((a, b) => + a.name.toLowerCase() === DEFAULT_ROLE_NAME + ? -1 + : b.name.toLowerCase() === DEFAULT_ROLE_NAME + ? 1 + : 0, + ); + +// "Invite your team", offered once right after the first provider is +// connected: permissions on the cloud were just granted and the value of +// sharing the first scan is fresh. Reuses the members-page form, tagged as an +// onboarding invitation. +export function OnboardingInviteDialog({ + open, + roles, + onSent, + onSkip, +}: OnboardingInviteDialogProps) { + const hasRoles = roles.length > 0; + + return ( + { + if (!next) onSkip(); + }} + > +
+ {hasRoles ? ( + + ) : ( +

+ Roles could not be loaded right now. You can invite your team later + from the Invitations page. +

+ )} + + {/* Outline matches the app's modal secondary action (e.g. Launch Scan's Cancel). */} + + +
+
+ ); +} diff --git a/ui/components/onboarding/onboarding-invite-step.tsx b/ui/components/onboarding/onboarding-invite-step.tsx new file mode 100644 index 0000000000..6f7fc2e7a9 --- /dev/null +++ b/ui/components/onboarding/onboarding-invite-step.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useState } from "react"; + +import { getOnboardingInviteRoles } from "@/actions/onboarding/invite"; +import { useMountEffect } from "@/hooks/use-mount-effect"; +import { + dispatchOnboardingInviteStep, + ONBOARDING_STEP_OUTCOME, +} from "@/lib/onboarding/onboarding-events"; +import type { InvitationRoleOption } from "@/types/onboarding-invite"; + +import { OnboardingInviteDialog } from "./onboarding-invite-dialog"; + +interface OnboardingInviteStepProps { + onDone: () => void; +} + +// Roles that have not arrived by then count as unavailable, so a request +// that never answers cannot hold the checkpoint behind an empty step. +const ROLES_TIMEOUT_MS = 5_000; + +// Mounted only while the step is showing: loads the roles once, announces +// the impression once, and resolves through a sent invitation or a skip. +export function OnboardingInviteStep({ onDone }: OnboardingInviteStepProps) { + // `null` until the roles settle: the invitation form takes its default + // role from the list at mount, so the dialog renders once the list is known. + const [roles, setRoles] = useState(null); + + useMountEffect(() => { + dispatchOnboardingInviteStep({ outcome: ONBOARDING_STEP_OUTCOME.SHOWN }); + let active = true; + let timer: ReturnType | undefined; + // First answer wins: a late response or a timer after it is ignored. + const settle = (loaded: InvitationRoleOption[]) => { + if (!active) return; + active = false; + clearTimeout(timer); + setRoles(loaded); + }; + // Without roles the dialog offers only the skip, so the checkpoint is + // never blocked: not by a failed read, not by one that never answers. + timer = setTimeout(() => settle([]), ROLES_TIMEOUT_MS); + getOnboardingInviteRoles() + .then(settle) + .catch(() => settle([])); + return () => { + active = false; + clearTimeout(timer); + }; + }); + + if (roles === null) return null; + + return ( + { + dispatchOnboardingInviteStep({ + outcome: ONBOARDING_STEP_OUTCOME.SUBMITTED, + }); + onDone(); + }} + onSkip={() => { + dispatchOnboardingInviteStep({ + outcome: ONBOARDING_STEP_OUTCOME.SKIPPED, + }); + onDone(); + }} + /> + ); +} diff --git a/ui/components/providers/radio-group-provider.test.tsx b/ui/components/providers/radio-group-provider.test.tsx new file mode 100644 index 0000000000..b8b10e2444 --- /dev/null +++ b/ui/components/providers/radio-group-provider.test.tsx @@ -0,0 +1,187 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useForm } from "react-hook-form"; +import { describe, expect, it } from "vitest"; + +import type { RegistryProviderOption } from "@/lib/registry/provider-options"; +import type { AddProviderFormValues } from "@/types/formSchemas"; + +import { RadioGroupProvider } from "./radio-group-provider"; + +function Selector({ + registryAvailable = true, + registryOptions = [{ type: "acme", label: "Acme Cloud" }], +}: { + registryAvailable?: boolean; + registryOptions?: RegistryProviderOption[]; +}) { + const form = useForm(); + return ( + + ); +} + +describe("provider selector", () => { + it("preserves selected provider across tabs and supports searching by type", async () => { + // Given + const user = userEvent.setup(); + render( + , + ); + expect(screen.getByRole("tab", { name: "All providers" })).toHaveAttribute( + "aria-selected", + "true", + ); + await user.click( + screen.getByRole("option", { name: /Amazon Web Services/ }), + ); + + // When + await user.click(screen.getByRole("tab", { name: "Registry" })); + await user.type( + screen.getByRole("textbox", { name: "Search providers" }), + " ACME_SLUG ", + ); + + // Then + expect( + screen.getByRole("option", { name: /Acme Cloud Registry/ }), + ).toBeVisible(); + + // When + await user.click(screen.getByRole("button", { name: "Clear search" })); + await user.click(screen.getByRole("tab", { name: "All providers" })); + + // Then + expect( + screen.getByRole("option", { name: /Amazon Web Services/ }), + ).toHaveAttribute("aria-selected", "true"); + }); + + it("keeps both tabs available when no Registry providers are installed", async () => { + // Given + const user = userEvent.setup(); + const { rerender } = render(); + + // When + await user.click(screen.getByRole("tab", { name: "Registry" })); + + // Then + expect(screen.getByText("No Registry providers available.")).toBeVisible(); + expect(screen.getByRole("tab", { name: "All providers" })).toBeEnabled(); + + // When / Then: discovery can refresh the installed options. + rerender(); + expect( + screen.getByRole("option", { name: /Acme Cloud Registry/ }), + ).toBeVisible(); + rerender(); + expect(screen.queryByRole("option")).not.toBeInTheDocument(); + expect(screen.getByText("No Registry providers available.")).toBeVisible(); + }); + + it("filters Registry providers and preserves search across tabs", async () => { + // Given + const user = userEvent.setup(); + render(); + + // When + await user.click(screen.getByRole("tab", { name: "Registry" })); + + // Then + expect( + screen.getByRole("option", { name: /Acme Cloud Registry/ }), + ).toBeVisible(); + expect( + screen.queryByRole("option", { name: /Amazon Web Services/ }), + ).not.toBeInTheDocument(); + + // When + await user.type( + screen.getByRole("textbox", { name: "Search providers" }), + "amazon", + ); + expect( + screen.getByText('No providers found matching "amazon"'), + ).toBeVisible(); + await user.click(screen.getByRole("tab", { name: "All providers" })); + + // Then + expect( + screen.getByRole("textbox", { name: "Search providers" }), + ).toHaveValue("amazon"); + expect( + screen.getByRole("option", { name: /Amazon Web Services/ }), + ).toBeVisible(); + expect( + screen.queryByRole("option", { name: /Acme Cloud Registry/ }), + ).not.toBeInTheDocument(); + }); + + it("adds Registry-labelled providers alongside the incorporated options", () => { + render(); + expect( + screen.getByRole("option", { name: /Acme Cloud Registry/ }), + ).toBeVisible(); + expect( + screen.getByRole("option", { name: /Amazon Web Services/ }), + ).toBeVisible(); + }); + + it("hides the source tabs when Registry is unavailable in the deployment", () => { + // Given: Local (OSS) or Registry-flag-off deployments deny discovery. + render(); + + // Then: only the built-in providers are offered, without a Registry tab. + expect(screen.queryByRole("tablist")).not.toBeInTheDocument(); + expect(screen.queryByRole("tabpanel")).not.toBeInTheDocument(); + expect( + screen.queryByRole("tab", { name: "Registry" }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("option", { name: /Amazon Web Services/ }), + ).toBeVisible(); + expect( + screen.queryByText("No Registry providers available."), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("textbox", { name: "Search providers" }), + ).toBeVisible(); + }); + + it("falls back to all providers when Registry access is revoked on the Registry tab", async () => { + // Given + const user = userEvent.setup(); + const { rerender } = render(); + await user.click(screen.getByRole("tab", { name: "Registry" })); + expect( + screen.queryByRole("option", { name: /Amazon Web Services/ }), + ).not.toBeInTheDocument(); + + // When: a discovery refresh reports the deployment no longer offers Registry. + rerender(); + + // Then + expect(screen.queryByRole("tablist")).not.toBeInTheDocument(); + expect( + screen.getByRole("option", { name: /Amazon Web Services/ }), + ).toBeVisible(); + + // When / Then: access restored keeps the previous Registry selection. + rerender(); + expect(screen.getByRole("tab", { name: "Registry" })).toHaveAttribute( + "aria-selected", + "true", + ); + expect( + screen.getByRole("option", { name: /Acme Cloud Registry/ }), + ).toBeVisible(); + }); +}); diff --git a/ui/components/providers/radio-group-provider.tsx b/ui/components/providers/radio-group-provider.tsx index de8d941f3a..f1d175da6e 100644 --- a/ui/components/providers/radio-group-provider.tsx +++ b/ui/components/providers/radio-group-provider.tsx @@ -2,117 +2,44 @@ import { FC, useState } from "react"; import { Control, Controller } from "react-hook-form"; -import { z } from "zod"; - -import { SearchInput } from "@/components/shadcn"; -import { FormMessage } from "@/components/shadcn/form"; -import { cn } from "@/lib/utils"; -import { addProviderFormSchema } from "@/types"; import { - AlibabaCloudProviderBadge, - AWSProviderBadge, - AzureProviderBadge, - CloudflareProviderBadge, - GCPProviderBadge, - GitHubProviderBadge, - GoogleWorkspaceProviderBadge, - IacProviderBadge, - ImageProviderBadge, - KS8ProviderBadge, - M365ProviderBadge, - MongoDBAtlasProviderBadge, - OktaProviderBadge, - OpenStackProviderBadge, - OracleCloudProviderBadge, - VercelProviderBadge, -} from "../icons/providers-badge"; + ProviderTypeIcon, + PROVIDER_TYPE_DATA, +} from "@/components/icons/providers-badge/provider-type-icon"; +import { Badge, SearchInput } from "@/components/shadcn"; +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@/components/shadcn/avatar"; +import { FormMessage } from "@/components/shadcn/form"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/shadcn/tabs/tabs"; +import type { RegistryProviderOption } from "@/lib/registry/provider-options"; +import { cn } from "@/lib/utils"; +import type { AddProviderFormValues } from "@/types/formSchemas"; -const PROVIDERS = [ - { - value: "aws", - label: "Amazon Web Services", - badge: AWSProviderBadge, - }, - { - value: "gcp", - label: "Google Cloud Platform", - badge: GCPProviderBadge, - }, - { - value: "azure", - label: "Microsoft Azure", - badge: AzureProviderBadge, - }, - { - value: "m365", - label: "Microsoft 365", - badge: M365ProviderBadge, - }, - { - value: "mongodbatlas", - label: "MongoDB Atlas", - badge: MongoDBAtlasProviderBadge, - }, - { - value: "kubernetes", - label: "Kubernetes", - badge: KS8ProviderBadge, - }, - { - value: "github", - label: "GitHub", - badge: GitHubProviderBadge, - }, - { - value: "googleworkspace", - label: "Google Workspace", - badge: GoogleWorkspaceProviderBadge, - }, - { - value: "iac", - label: "Infrastructure as Code", - badge: IacProviderBadge, - }, - { - value: "image", - label: "Container Registry", - badge: ImageProviderBadge, - }, - { - value: "oraclecloud", - label: "Oracle Cloud Infrastructure", - badge: OracleCloudProviderBadge, - }, - { - value: "alibabacloud", - label: "Alibaba Cloud", - badge: AlibabaCloudProviderBadge, - }, - { - value: "cloudflare", - label: "Cloudflare", - badge: CloudflareProviderBadge, - }, - { - value: "openstack", - label: "OpenStack", - badge: OpenStackProviderBadge, - }, - { - value: "vercel", - label: "Vercel", - badge: VercelProviderBadge, - }, - { - value: "okta", - label: "Okta", - badge: OktaProviderBadge, - }, -] as const; +const PROVIDERS = Object.entries(PROVIDER_TYPE_DATA).map( + ([value, { label }]) => ({ value, label }), +); + +const PROVIDER_TAB = { ALL: "all", REGISTRY: "registry" } as const; +type ProviderTab = (typeof PROVIDER_TAB)[keyof typeof PROVIDER_TAB]; interface RadioGroupProviderProps { - control: Control>; + control: Control; + /** + * Whether this deployment offers Registry providers (Cloud or Private Cloud + * with the Registry flag on and a user allowed to manage providers). Off in + * Local (OSS) and flag-off deployments, where the source tabs are hidden. + */ + registryAvailable?: boolean; + registryOptions?: RegistryProviderOption[]; isInvalid: boolean; errorMessage?: string; } @@ -121,25 +48,49 @@ export const RadioGroupProvider: FC = ({ control, isInvalid, errorMessage, + registryAvailable = false, + registryOptions = [], }) => { const [searchTerm, setSearchTerm] = useState(""); + const [selectedTab, setSelectedTab] = useState(PROVIDER_TAB.ALL); + // Fall back to the full list if Registry access is revoked while the + // Registry tab is selected, so the selector never shows an empty tab. + const activeTab = registryAvailable ? selectedTab : PROVIDER_TAB.ALL; + const options = [ + ...PROVIDERS.map((provider) => ({ + value: provider.value as string, + label: provider.label as string, + registry: false, + logoUrl: undefined as string | undefined, + })), + ...registryOptions.map((provider) => ({ + value: provider.type, + label: provider.label, + registry: true, + logoUrl: provider.logoUrl, + })), + ]; + const tabProviders = + activeTab === PROVIDER_TAB.REGISTRY + ? options.filter((provider) => provider.registry) + : options; const lowerSearch = searchTerm.trim().toLowerCase(); const filteredProviders = lowerSearch - ? PROVIDERS.filter( + ? tabProviders.filter( (provider) => provider.label.toLowerCase().includes(lowerSearch) || provider.value.toLowerCase().includes(lowerSearch), ) - : PROVIDERS; + : tabProviders; return ( ( -
-
+ render={({ field }) => { + const searchInput = ( +
= ({ onClear={() => setSearchTerm("")} />
+ ); + const providerList = ( +
+ {filteredProviders.length > 0 ? ( + filteredProviders.map((provider) => { + const isSelected = field.value === provider.value; -
-
- {filteredProviders.length > 0 ? ( - filteredProviders.map((provider) => { - const BadgeComponent = provider.badge; - const isSelected = field.value === provider.value; - - return ( - - ); - }) - ) : ( -

- No providers found matching "{searchTerm}" -

- )} -
+
+ {provider.registry ? ( + + + + + + + ) : ( + + )} + + {provider.label} + + {provider.registry && ( + Registry + )} +
+ + ); + }) + ) : ( +

+ {lowerSearch ? ( + <>No providers found matching "{searchTerm}" + ) : ( + "No Registry providers available." + )} +

+ )}
+ ); + const validationMessage = errorMessage && ( + + {errorMessage} + + ); - {errorMessage && ( - - {errorMessage} - - )} -
- )} + if (!registryAvailable) { + return ( +
+ {searchInput} +
{providerList}
+ {validationMessage} +
+ ); + } + + return ( + setSelectedTab(value as ProviderTab)} + > + + All providers + Registry + + {searchInput} + {providerList} + {validationMessage} + + ); + }} /> ); }; diff --git a/ui/components/providers/table/column-providers.tsx b/ui/components/providers/table/column-providers.tsx index 78c59ac764..548dda2e82 100644 --- a/ui/components/providers/table/column-providers.tsx +++ b/ui/components/providers/table/column-providers.tsx @@ -251,7 +251,7 @@ export function getColumnProviders( entityId={provider.attributes.uid} nameAction={ provider.attributes.is_dynamic ? ( - Custom + Registry ) : undefined } /> diff --git a/ui/components/providers/table/data-table-row-actions.test.tsx b/ui/components/providers/table/data-table-row-actions.test.tsx index 5fa6f3f409..c65863abd2 100644 --- a/ui/components/providers/table/data-table-row-actions.test.tsx +++ b/ui/components/providers/table/data-table-row-actions.test.tsx @@ -1,3 +1,17 @@ +vi.mock("@/actions/providers/registry-provider", () => ({ + addRegistryProvider: vi.fn(), +})); +vi.mock("@/actions/registry/registry", () => ({ + getInstalledRegistryProviderOptions: vi + .fn() + .mockResolvedValue({ status: "access_denied" }), +})); +vi.mock("@/actions/providers/provider-schemas", () => ({ + getProviderSchemas: vi.fn(), +})); +vi.mock("@/actions/providers/dynamic-provider-credentials", () => ({ + saveDynamicProviderCredentials: vi.fn(), +})); import { Row } from "@tanstack/react-table"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; @@ -327,7 +341,7 @@ describe("DataTableRowActions", () => { expect(screen.queryByText("Update Credentials")).not.toBeInTheDocument(); }); - it("allows rename/delete and operational actions for a dynamic provider but hides credential management", async () => { + it("allows credential editing and operational actions for a dynamic provider", async () => { // Given a dynamic provider outside the configurable set, with the advanced // schedule capability enabled (so Edit Scan Schedule can show). const user = userEvent.setup(); @@ -354,9 +368,9 @@ describe("DataTableRowActions", () => { expect(screen.getByText("Test Connection")).toBeInTheDocument(); expect(screen.getByText("View Scan Jobs")).toBeInTheDocument(); expect(screen.getByText("Edit Scan Schedule")).toBeInTheDocument(); - // ...but credential management is hidden (no bespoke wizard for dynamic types) + // Existing dynamic accounts use the same wizard with schema-based credentials. expect(screen.queryByText("Add Credentials")).not.toBeInTheDocument(); - expect(screen.queryByText("Update Credentials")).not.toBeInTheDocument(); + expect(screen.getByText("Update Credentials")).toBeInTheDocument(); }); it("navigates to the provider-filtered scan jobs from View Scan Jobs", async () => { diff --git a/ui/components/providers/table/data-table-row-actions.tsx b/ui/components/providers/table/data-table-row-actions.tsx index 1866a44f73..bcbbd8e583 100644 --- a/ui/components/providers/table/data-table-row-actions.tsx +++ b/ui/components/providers/table/data-table-row-actions.tsx @@ -52,7 +52,6 @@ import { OrgFlowType, } from "@/types/organizations"; import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard"; -import { isConfigurableProvider } from "@/types/providers"; import { isProvidersOrganizationRow, PROVIDERS_GROUP_KIND, @@ -355,8 +354,7 @@ export function DataTableRowActions({ const provider = isOrganizationRow ? null : rowData; const providerId = provider?.id ?? ""; const providerType = provider?.attributes.provider ?? ""; - // Only predefined providers can manage credentials from the UI - const canManageCredentials = isConfigurableProvider(providerType); + const canManageCredentials = Boolean(providerType); const providerUid = provider?.attributes.uid ?? ""; const providerAlias = provider?.attributes.alias ?? null; const providerSecretId = provider?.relationships.secret.data?.id ?? null; diff --git a/ui/components/providers/wizard/provider-wizard-modal.test.tsx b/ui/components/providers/wizard/provider-wizard-modal.test.tsx new file mode 100644 index 0000000000..fc913924bc --- /dev/null +++ b/ui/components/providers/wizard/provider-wizard-modal.test.tsx @@ -0,0 +1,250 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { Toaster } from "@/components/shadcn/toast/Toaster"; +import { resetToasts } from "@/components/shadcn/toast/use-toast"; +import { useProviderWizardStore } from "@/store/provider-wizard/store"; + +import { ProviderWizardModal } from "./provider-wizard-modal"; + +const { addRegistryProvider, getInstalledRegistryProviderOptions } = vi.hoisted( + () => ({ + addRegistryProvider: vi.fn(), + getInstalledRegistryProviderOptions: vi.fn(), + }), +); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }), +})); +vi.mock("@/actions/providers/providers", () => ({ addProvider: vi.fn() })); +vi.mock("@/actions/providers/registry-provider", () => ({ + addRegistryProvider, +})); +vi.mock("@/actions/registry/registry", () => ({ + getInstalledRegistryProviderOptions, +})); +vi.mock( + "@/components/providers/workflow/forms", + async () => import("../workflow/forms/connect-account-form"), +); +vi.mock("@/hooks/use-scroll-hint", () => ({ + useScrollHint: () => ({ showScrollHint: false }), +})); +vi.mock("@/lib/tours/use-driver-tour", () => ({ + advanceActiveTour: vi.fn(), + endActiveTour: vi.fn(), +})); +vi.mock("./steps/credentials-step", () => ({ + CredentialsStep: () =>

Credential details

, +})); +vi.mock("./steps/test-connection-step", () => ({ + TestConnectionStep: () => null, +})); +vi.mock("./steps/launch-step", () => ({ LaunchStep: () => null })); +vi.mock("../organizations/azure-org-setup-form", () => ({ + AzureOrgSetupForm: () => null, +})); +vi.mock("../organizations/gcp-org-setup-form", () => ({ + GcpOrgSetupForm: () => null, +})); +vi.mock("../organizations/org-setup-form", () => ({ + OrgSetupForm: () => null, +})); +vi.mock("../organizations/org-account-selection", () => ({ + OrgAccountSelection: () => null, +})); +vi.mock("../organizations/org-launch-scan", () => ({ + OrgLaunchScan: () => null, +})); + +const createdAccount = { + data: { + id: "account", + attributes: { provider: "acme", uid: "acme-account", alias: null }, + }, +}; + +async function enterAccountDetails() { + const user = userEvent.setup(); + render( + <> + + + , + ); + await user.click( + await screen.findByRole("option", { name: "Acme Cloud Registry" }), + ); + await user.type( + screen.getByRole("textbox", { name: "Provider UID" }), + "acme-account", + ); + await waitFor(() => + expect(screen.getByRole("button", { name: "Next" })).toBeEnabled(), + ); + return user; +} + +describe("provider wizard account creation", () => { + beforeEach(() => { + useProviderWizardStore.getState().reset(); + resetToasts(); + getInstalledRegistryProviderOptions.mockResolvedValue({ + status: "ready", + options: [{ type: "acme", label: "Acme Cloud" }], + }); + }); + + it("shows progress, blocks repeat clicks, and advances after creation", async () => { + // Given + let resolveCreation!: (value: typeof createdAccount) => void; + addRegistryProvider.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveCreation = resolve; + }), + ); + const user = await enterAccountDetails(); + + // When + await user.click(screen.getByRole("button", { name: "Next" })); + + // Then + const pending = await screen.findByRole("button", { + name: "Creating provider...", + }); + expect(pending).toBeDisabled(); + expect(pending).toHaveAttribute("aria-busy", "true"); + expect(screen.getByRole("button", { name: "Back" })).toBeDisabled(); + await user.dblClick(pending); + expect(addRegistryProvider).toHaveBeenCalledOnce(); + + // When / Then + await act(async () => resolveCreation(createdAccount)); + expect(await screen.findByText("Credential details")).toBeVisible(); + }); + + it("restores Next after a failed creation and retries the same account", async () => { + // Given + const failure = { errors: [{ detail: "Creation failed. Try again." }] }; + let resolveCreation!: (value: typeof failure) => void; + addRegistryProvider + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveCreation = resolve; + }), + ) + .mockResolvedValueOnce(createdAccount); + const user = await enterAccountDetails(); + + // When + await user.click(screen.getByRole("button", { name: "Next" })); + await screen.findByRole("button", { name: "Creating provider..." }); + await act(async () => resolveCreation(failure)); + + // Then + expect(await screen.findByText(failure.errors[0].detail)).toBeVisible(); + const next = screen.getByRole("button", { name: "Next" }); + await waitFor(() => expect(next).toBeEnabled()); + expect(next).not.toHaveAttribute("aria-busy", "true"); + expect(screen.getByRole("button", { name: "Back" })).toBeEnabled(); + expect(screen.getByRole("textbox", { name: "Provider UID" })).toHaveValue( + "acme-account", + ); + + // When / Then + await user.click(next); + expect(await screen.findByText("Credential details")).toBeVisible(); + expect(addRegistryProvider).toHaveBeenCalledTimes(2); + expect( + Object.fromEntries(addRegistryProvider.mock.calls[1][0]), + ).toMatchObject({ providerType: "acme", providerUid: "acme-account" }); + }); + + it("shows provider conflicts in the account step and allows retrying", async () => { + // Given + const detail = + "The artifact 'acme' is not installed on this deployment yet. Install it again and retry."; + addRegistryProvider + .mockResolvedValueOnce({ + errors: [ + { + status: "409", + detail, + source: { pointer: "/data/attributes/provider" }, + }, + ], + }) + .mockResolvedValueOnce(createdAccount); + const user = await enterAccountDetails(); + + // When + await user.click(screen.getByRole("button", { name: "Next" })); + + // Then + expect(await screen.findByRole("alert")).toHaveTextContent(detail); + expect(screen.getByRole("textbox", { name: "Provider UID" })).toHaveValue( + "acme-account", + ); + const next = await screen.findByRole("button", { name: "Next" }); + await waitFor(() => expect(next).toBeEnabled()); + expect(screen.getByRole("button", { name: "Back" })).toBeEnabled(); + + // When / Then: the provider becomes available and the same account retries. + await user.click(next); + expect(await screen.findByText("Credential details")).toBeVisible(); + expect(screen.queryByText(detail)).not.toBeInTheDocument(); + expect(addRegistryProvider).toHaveBeenCalledTimes(2); + }); + + it("keeps native providers available during a Registry discovery error and retries", async () => { + // Given + getInstalledRegistryProviderOptions.mockRejectedValueOnce( + new Error("Unavailable"), + ); + const user = userEvent.setup(); + render(); + await screen.findByText("Registry providers could not be loaded"); + expect( + screen.getByRole("option", { name: /Amazon Web Services/ }), + ).toBeVisible(); + + // Then: an unanswered discovery cannot vouch for Registry availability. + expect( + screen.queryByRole("tab", { name: "Registry" }), + ).not.toBeInTheDocument(); + + // When + await user.click( + screen.getByRole("button", { name: "Retry Registry providers" }), + ); + + // Then + expect( + await screen.findByRole("option", { name: "Acme Cloud Registry" }), + ).toBeVisible(); + expect(screen.getByRole("tab", { name: "Registry" })).toBeVisible(); + expect( + screen.queryByText("Registry providers could not be loaded"), + ).not.toBeInTheDocument(); + }); + + it("keeps the Registry tab with a retry when eligible discovery fails", async () => { + // Given: Cloud with Registry enabled, but the catalog read failed. + getInstalledRegistryProviderOptions.mockResolvedValueOnce({ + status: "error", + }); + const user = userEvent.setup(); + render(); + await screen.findByText("Registry providers could not be loaded"); + + // When + await user.click(screen.getByRole("tab", { name: "Registry" })); + + // Then + expect(screen.getByText("No Registry providers available.")).toBeVisible(); + }); +}); diff --git a/ui/components/providers/wizard/provider-wizard-modal.tsx b/ui/components/providers/wizard/provider-wizard-modal.tsx index d39a491b17..fab1a4440f 100644 --- a/ui/components/providers/wizard/provider-wizard-modal.tsx +++ b/ui/components/providers/wizard/provider-wizard-modal.tsx @@ -1,6 +1,6 @@ "use client"; -import { ExternalLink, Info } from "lucide-react"; +import { ExternalLink, Info, Loader2 } from "lucide-react"; import { AzureOrgSetupForm } from "@/components/providers/organizations/azure-org-setup-form"; import { GcpOrgSetupForm } from "@/components/providers/organizations/gcp-org-setup-form"; @@ -405,7 +405,11 @@ export function ProviderWizardModal({ : "button" } form={resolvedFooterConfig.actionFormId} - disabled={resolvedFooterConfig.actionDisabled} + disabled={ + resolvedFooterConfig.actionDisabled || + resolvedFooterConfig.actionLoading + } + aria-busy={resolvedFooterConfig.actionLoading || undefined} onClick={ resolvedFooterConfig.actionType === WIZARD_FOOTER_ACTION_TYPE.BUTTON @@ -413,6 +417,9 @@ export function ProviderWizardModal({ : undefined } > + {resolvedFooterConfig.actionLoading && ( + + )} {resolvedFooterConfig.actionLabel} )} diff --git a/ui/components/providers/wizard/steps/connect-step.tsx b/ui/components/providers/wizard/steps/connect-step.tsx index a26013aba3..649f0d1562 100644 --- a/ui/components/providers/wizard/steps/connect-step.tsx +++ b/ui/components/providers/wizard/steps/connect-step.tsx @@ -63,6 +63,7 @@ export function ConnectStep({ onBack: () => backHandlerRef.current?.(), showAction: uiState.showAction, actionLabel: uiState.actionLabel, + actionLoading: uiState.isLoading, actionDisabled: uiState.actionDisabled || uiState.isLoading, actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT, actionFormId: formId, diff --git a/ui/components/providers/wizard/steps/credentials-step.test.tsx b/ui/components/providers/wizard/steps/credentials-step.test.tsx index 7f4610b212..8fa035ea33 100644 --- a/ui/components/providers/wizard/steps/credentials-step.test.tsx +++ b/ui/components/providers/wizard/steps/credentials-step.test.tsx @@ -1,3 +1,6 @@ +vi.mock("./dynamic-credentials-step", () => ({ + DynamicCredentialsStep: () =>
dynamic-credentials-form
, +})); import { render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; diff --git a/ui/components/providers/wizard/steps/credentials-step.tsx b/ui/components/providers/wizard/steps/credentials-step.tsx index 371f356d30..75836a25e3 100644 --- a/ui/components/providers/wizard/steps/credentials-step.tsx +++ b/ui/components/providers/wizard/steps/credentials-step.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from "react"; import { getProviderFormType } from "@/lib/provider-helpers"; import { useProviderWizardStore } from "@/store/provider-wizard/store"; -import { ProviderType } from "@/types/providers"; +import { isKnownProviderType, ProviderType } from "@/types/providers"; import { AddViaCredentialsForm, @@ -23,6 +23,7 @@ import { SelectViaGitHub } from "../../workflow/forms/select-credentials-type/gi import { SelectViaM365 } from "../../workflow/forms/select-credentials-type/m365"; import { UpdateViaServiceAccountForm } from "../../workflow/forms/update-via-service-account-key-form"; +import { DynamicCredentialsStep } from "./dynamic-credentials-step"; import { WIZARD_FOOTER_ACTION_TYPE, WizardFooterConfig, @@ -34,7 +35,22 @@ interface CredentialsStepProps { onFooterChange: (config: WizardFooterConfig) => void; } -export function CredentialsStep({ +export function CredentialsStep(props: CredentialsStepProps) { + const providerId = useProviderWizardStore((state) => state.providerId); + const providerType = useProviderWizardStore((state) => state.providerType); + if (providerId && providerType && !isKnownProviderType(providerType)) { + return ( + + ); + } + return ; +} + +function BuiltinCredentialsStep({ onNext, onBack, onFooterChange, diff --git a/ui/components/providers/wizard/steps/dynamic-credentials-step.test.tsx b/ui/components/providers/wizard/steps/dynamic-credentials-step.test.tsx new file mode 100644 index 0000000000..ce28a075b1 --- /dev/null +++ b/ui/components/providers/wizard/steps/dynamic-credentials-step.test.tsx @@ -0,0 +1,365 @@ +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import openaiSchema from "@/lib/provider-credentials/fixtures/openai-credential-schema.json"; +import templateSchema from "@/lib/provider-credentials/fixtures/template-credential-schema.json"; +import { useProviderWizardStore } from "@/store/provider-wizard/store"; +import type { ProviderSchemasResult } from "@/types/provider-schema"; + +const { getProviderSchemas, saveDynamicProviderCredentials, toast } = + vi.hoisted(() => ({ + getProviderSchemas: vi.fn(), + saveDynamicProviderCredentials: vi.fn(), + toast: vi.fn(), + })); +vi.mock("@/actions/providers/provider-schemas", () => ({ getProviderSchemas })); +vi.mock("@/actions/providers/dynamic-provider-credentials", () => ({ + saveDynamicProviderCredentials, +})); +vi.mock("@/components/shadcn/toast", () => ({ useToast: () => ({ toast }) })); + +import { DynamicCredentialsStep } from "./dynamic-credentials-step"; + +beforeAll(() => { + for (const method of [ + "hasPointerCapture", + "setPointerCapture", + "releasePointerCapture", + "scrollIntoView", + ]) { + Object.defineProperty(HTMLElement.prototype, method, { + configurable: true, + value: vi.fn(() => false), + }); + } +}); + +const props = { + providerId: "account", + providerType: "acme", + onNext: vi.fn(), + onBack: vi.fn(), + onFooterChange: vi.fn(), +}; +const schema = { + type: "object", + description: openaiSchema.description, + properties: { + token: { + type: "string", + title: "API token", + format: "password", + writeOnly: true, + }, + }, + required: ["token"], +}; + +describe("dynamic credentials in the provider wizard", () => { + beforeEach(() => { + vi.clearAllMocks(); + sessionStorage.clear(); + localStorage.clear(); + useProviderWizardStore.getState().reset(); + getProviderSchemas.mockResolvedValue({ + status: "success", + providerType: "acme", + secretTypes: { api_key: schema }, + }); + saveDynamicProviderCredentials.mockResolvedValue({ + status: "saved", + secretId: "secret", + }); + }); + it("saves through the dynamic action and never persists entered secrets", async () => { + render(); + const field = await screen.findByLabelText(/API token/); + fireEvent.change(field, { target: { value: "only-in-memory" } }); + expect(JSON.stringify(sessionStorage)).not.toContain("only-in-memory"); + expect(JSON.stringify(localStorage)).not.toContain("only-in-memory"); + fireEvent.submit(field.closest("form")!); + await waitFor(() => expect(props.onNext).toHaveBeenCalledOnce()); + expect(saveDynamicProviderCredentials).toHaveBeenCalledWith({ + providerId: "account", + secretType: "api_key", + secret: { token: "only-in-memory" }, + }); + expect(useProviderWizardStore.getState().secretId).toBe("secret"); + expect(field).toHaveValue(""); + }); + it("masks the OpenAI API key and submits the original credential values", async () => { + // Given + const user = userEvent.setup(); + getProviderSchemas.mockResolvedValue({ + status: "success", + providerType: "openai", + secretTypes: { api_key: openaiSchema }, + }); + render(); + const apiKey = await screen.findByLabelText(/Platform Api Key/); + const organization = screen.getByLabelText(/Organization Id/); + const baseUrl = screen.getByLabelText(/Base Url/); + + // When + await user.type(organization, "org-fixture"); + await user.type(apiKey, "fixture-key-not-a-secret"); + + // Then + expect(apiKey).toHaveAttribute("type", "password"); + expect(apiKey).toHaveAttribute("autocomplete", "new-password"); + expect(organization).toHaveAttribute("type", "text"); + expect(baseUrl).toHaveAttribute("type", "text"); + expect( + screen.queryByRole("button", { name: /show|reveal/i }), + ).not.toBeInTheDocument(); + + // When / Then: this form submits from the wizard's external footer. + act(() => apiKey.closest("form")!.requestSubmit()); + await waitFor(() => expect(props.onNext).toHaveBeenCalledOnce()); + expect(saveDynamicProviderCredentials).toHaveBeenCalledWith({ + providerId: "account", + secretType: "api_key", + secret: { + organization_id: "org-fixture", + platform_api_key: "fixture-key-not-a-secret", + base_url: "https://api.openai.com/v1", + }, + }); + }); + it("renders and submits the installed Template credential form with typed values", async () => { + // Given + const user = userEvent.setup(); + getProviderSchemas.mockResolvedValue({ + status: "success", + providerType: "template", + secretTypes: { static: templateSchema }, + }); + render(); + const apiUrl = await screen.findByLabelText(/API URL/); + const apiKey = screen.getByLabelText(/API Key/); + const verifyTls = screen.getByRole("checkbox", { name: "Verify TLS" }); + const timeout = screen.getByRole("spinbutton", { name: "Timeout" }); + + // Then + expect(apiUrl).toHaveAttribute("placeholder", "https://api.acme.com"); + expect(apiKey).toHaveAttribute("type", "password"); + expect(screen.getByLabelText("CA Bundle").tagName).toBe("TEXTAREA"); + expect(verifyTls).toBeChecked(); + expect(timeout).toHaveValue(30); + expect(timeout).toHaveAttribute("min", "1"); + expect(timeout).toHaveAttribute("max", "300"); + expect(timeout).toHaveAttribute("step", "1"); + expect( + screen.getByRole("combobox", { name: "Authentication Scheme" }), + ).toHaveTextContent("bearer"); + expect(apiUrl).toHaveValue(""); + + // When: false must remain a boolean and numeric input must become a number. + await user.type(apiUrl, "https://api.example.test"); + await user.type(apiKey, "fixture-key-not-a-secret"); + await user.click(verifyTls); + await user.clear(timeout); + await user.type(timeout, "60"); + act(() => apiKey.closest("form")!.requestSubmit()); + + // Then + await waitFor(() => expect(props.onNext).toHaveBeenCalledOnce()); + expect(saveDynamicProviderCredentials).toHaveBeenCalledWith({ + providerId: "account", + secretType: "static", + secret: { + api_url: "https://api.example.test", + api_key: "fixture-key-not-a-secret", + verify_tls: false, + timeout_seconds: 60, + auth_scheme: "bearer", + }, + }); + }); + it.each<{ result: ProviderSchemasResult; title: string }>([ + { + result: { status: "success", providerType: "acme", secretTypes: {} }, + title: "Credential form unavailable", + }, + { + result: { + status: "success", + providerType: "acme", + secretTypes: { + api_key: { + type: "object", + properties: { nested: { type: "object" } }, + }, + }, + }, + title: "Credential form not supported", + }, + { + result: { status: "access_denied" }, + title: "Access required", + }, + { + result: { status: "unavailable" }, + title: "Provider installation unavailable", + }, + ])( + "explains $title without allowing credential submission", + async ({ result, title }) => { + getProviderSchemas.mockResolvedValue(result); + render(); + expect( + await screen.findByRole("button", { name: "Try again" }), + ).toBeVisible(); + expect(screen.getByRole("alert")).toHaveTextContent(title); + expect(screen.queryByLabelText(/API token/)).not.toBeInTheDocument(); + expect(saveDynamicProviderCredentials).not.toHaveBeenCalled(); + }, + ); + it("explains a loading failure and recovers when retried", async () => { + // Given + getProviderSchemas.mockRejectedValueOnce(new Error("Network unavailable")); + const user = userEvent.setup(); + render(); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Could not load credential form", + ); + expect(screen.getByRole("alert")).toHaveTextContent( + "Check your connection and try again.", + ); + expect(screen.getByRole("link", { name: "Open Registry" })).toHaveAttribute( + "href", + "/registry", + ); + + // When + await user.click(screen.getByRole("button", { name: "Try again" })); + + // Then + expect(await screen.findByLabelText(/API token/)).toBeVisible(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + it("clears credentials when changing providers", async () => { + const view = render(); + fireEvent.change(await screen.findByLabelText(/API token/), { + target: { value: "previous-secret" }, + }); + view.rerender( + , + ); + await waitFor(() => + expect(screen.getByLabelText(/API token/)).toHaveValue(""), + ); + }); + it("clears credentials when switching authentication methods", async () => { + getProviderSchemas.mockResolvedValue({ + status: "success", + providerType: "acme", + secretTypes: { api_key: schema, personal_token: schema }, + }); + render(); + fireEvent.change(await screen.findByLabelText(/API token/), { + target: { value: "previous-method-secret" }, + }); + const user = userEvent.setup(); + await user.click( + screen.getByRole("combobox", { name: "Authentication method" }), + ); + await user.click(screen.getByRole("option", { name: "personal token" })); + expect(screen.getByLabelText(/API token/)).toHaveValue(""); + expect(JSON.stringify(sessionStorage)).not.toContain( + "previous-method-secret", + ); + expect(JSON.stringify(localStorage)).not.toContain( + "previous-method-secret", + ); + }); + it("rejects double submission and retries a failed save for the same account", async () => { + let rejectSave!: (error: Error) => void; + saveDynamicProviderCredentials.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectSave = reject; + }), + ); + render(); + const field = await screen.findByLabelText(/API token/); + fireEvent.change(field, { target: { value: "retry-secret" } }); + fireEvent.submit(field.closest("form")!); + fireEvent.submit(field.closest("form")!); + expect(saveDynamicProviderCredentials).toHaveBeenCalledOnce(); + rejectSave(new Error("Network unavailable")); + await screen.findByText( + "Could not save the credentials. Check your connection and retry.", + ); + expect(props.onNext).not.toHaveBeenCalled(); + fireEvent.submit(field.closest("form")!); + await waitFor(() => expect(props.onNext).toHaveBeenCalledOnce()); + expect(saveDynamicProviderCredentials).toHaveBeenCalledTimes(2); + expect(saveDynamicProviderCredentials).toHaveBeenLastCalledWith({ + providerId: "account", + secretType: "api_key", + secret: { token: "retry-secret" }, + }); + }); + it("keeps other field and form errors visible while editing one credential", async () => { + // Given + const user = userEvent.setup(); + getProviderSchemas.mockResolvedValue({ + status: "success", + providerType: "acme", + secretTypes: { + api_key: { + ...schema, + properties: { + ...schema.properties, + project: { type: "string", title: "Project" }, + }, + required: ["token", "project"], + }, + }, + }); + saveDynamicProviderCredentials.mockResolvedValueOnce({ + status: "invalid", + errors: { + token: "Token was rejected", + project: "Project is unavailable", + _form: "Review the credential fields", + }, + }); + render(); + const token = await screen.findByLabelText(/API token/); + await user.type(token, "fixture-token"); + await user.type(screen.getByLabelText(/Project/), "fixture-project"); + act(() => token.closest("form")!.requestSubmit()); + expect(await screen.findByText("Token was rejected")).toBeVisible(); + + // When + await user.type(token, "-edited"); + + // Then + expect(screen.queryByText("Token was rejected")).not.toBeInTheDocument(); + expect(screen.getByText("Project is unavailable")).toBeVisible(); + expect(screen.getByText("Review the credential fields")).toBeVisible(); + + // When / Then: submitting again replaces the earlier validation errors. + act(() => token.closest("form")!.requestSubmit()); + await waitFor(() => expect(props.onNext).toHaveBeenCalledOnce()); + expect( + screen.queryByText("Project is unavailable"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("Review the credential fields"), + ).not.toBeInTheDocument(); + }); +}); diff --git a/ui/components/providers/wizard/steps/dynamic-credentials-step.tsx b/ui/components/providers/wizard/steps/dynamic-credentials-step.tsx new file mode 100644 index 0000000000..35fc5f0daa --- /dev/null +++ b/ui/components/providers/wizard/steps/dynamic-credentials-step.tsx @@ -0,0 +1,336 @@ +"use client"; + +import { RotateCcw } from "lucide-react"; +import Link from "next/link"; +import { useEffect, useRef, useState } from "react"; + +import { saveDynamicProviderCredentials } from "@/actions/providers/dynamic-provider-credentials"; +import { getProviderSchemas } from "@/actions/providers/provider-schemas"; +import { RegistryCredentialFields } from "@/components/providers/workflow/provider-credential-fields"; +import { Button } from "@/components/shadcn/button/button"; +import { Field, FieldLabel } from "@/components/shadcn/field/field"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/shadcn/select/select"; +import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; +import { useToast } from "@/components/shadcn/toast"; +import { StatusAlert } from "@/components/shared/status-alert"; +import { + parseRegistryCredentialSchema, + type RegistryCredentialSchema, +} from "@/lib/provider-credentials/provider-credential-schema"; +import { + getCredentialDefaults, + validateCredentialValues, +} from "@/lib/provider-credentials/provider-credential-values"; +import { useProviderWizardStore } from "@/store/provider-wizard/store"; +import type { ProviderSchemasResult } from "@/types/provider-schema"; + +import { + WIZARD_FOOTER_ACTION_TYPE, + type WizardFooterConfig, +} from "./footer-controls"; + +interface DynamicCredentialsStepProps { + providerId: string; + providerType: string; + onNext: () => void; + onBack: () => void; + onFooterChange: (config: WizardFooterConfig) => void; +} + +function credentialFormError(status: ProviderSchemasResult["status"]) { + switch (status) { + case "access_denied": + return { + title: "Access required", + description: + "Your session may have expired or you may not have permission. Sign in again or contact your administrator.", + }; + case "unavailable": + return { + title: "Provider installation unavailable", + description: + "Install this provider's artifact again in Registry, then try again.", + }; + case "not_found": + return { + title: "Credential form unavailable", + description: + "This provider does not provide a credential form. Contact its publisher or your administrator.", + }; + case "success": + case "malformed": + return { + title: "Credential form not supported", + description: + "We could not display this provider's credential form. Contact its publisher or your administrator.", + }; + default: + return { + title: "Could not load credential form", + description: "Check your connection and try again.", + }; + } +} + +function DynamicCredentialForm({ + providerId, + secretType, + schema, + onNext, + onBack, + onFooterChange, + onLoadingChange, +}: Omit & { + secretType: string; + schema: RegistryCredentialSchema; + onLoadingChange: (value: boolean) => void; +}) { + const { toast } = useToast(); + const setSecretId = useProviderWizardStore((state) => state.setSecretId); + // Credentials belong only to this form. A new account or authentication + // method mounts a fresh instance; no values enter the persisted wizard store. + const [values, setValues] = useState(() => getCredentialDefaults(schema)); + const [errors, setErrors] = useState>({}); + const [saving, setSaving] = useState(false); + const inFlight = useRef(false); + const mounted = useRef(true); + const formId = "provider-wizard-dynamic-credentials-form"; + const valid = validateCredentialValues(schema, values).valid; + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + useEffect(() => { + onFooterChange({ + showBack: true, + backLabel: "Back", + backDisabled: saving, + onBack, + showAction: true, + actionLabel: "Authenticate", + actionDisabled: saving || !valid, + actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT, + actionFormId: formId, + }); + }, [onBack, onFooterChange, saving, valid]); + + return ( + { + event.preventDefault(); + if (inFlight.current) return; + const validation = validateCredentialValues(schema, values); + setErrors(validation.errors); + if (!validation.valid) return; + inFlight.current = true; + setSaving(true); + onLoadingChange(true); + try { + const result = await saveDynamicProviderCredentials({ + providerId, + secretType, + secret: validation.secret, + }); + if (!mounted.current) return; + if (result.status === "saved") { + setValues({}); + setSecretId(result.secretId); + toast({ + title: "Credentials saved", + description: "Test the provider connection to continue.", + }); + onNext(); + } else if (result.status === "invalid") { + setErrors(result.errors); + } else { + const description = + result.status === "schema_unavailable" + ? "The credential schema is unavailable. Check the installed artifact in Registry and reload the form." + : result.status === "access_denied" + ? "You no longer have permission to update these credentials. Contact an administrator." + : "Check your credentials and try again. Your provider account is already created."; + setErrors({ _form: description }); + toast({ + variant: "destructive", + title: "Credentials could not be saved", + description, + }); + } + } catch { + if (mounted.current) { + const description = + "Could not save the credentials. Check your connection and retry."; + setErrors({ _form: description }); + toast({ + variant: "destructive", + title: "Credentials could not be saved", + description, + }); + } + } finally { + inFlight.current = false; + if (mounted.current) { + setSaving(false); + onLoadingChange(false); + } + } + }} + > +
+ {errors._form && ( + + {errors._form} + + )} + { + setValues((current) => ({ ...current, [name]: value })); + setErrors((current) => { + const next = { ...current }; + delete next[name]; + return next; + }); + }} + /> +
+ + ); +} + +function DynamicCredentialsContent(props: DynamicCredentialsStepProps) { + const { providerType, onBack, onFooterChange } = props; + const [schemas, setSchemas] = useState(null); + const [selectedMethod, setSelectedMethod] = useState(""); + const [attempt, setAttempt] = useState(0); + const [saving, setSaving] = useState(false); + useEffect(() => { + let active = true; + setSchemas(null); + setSelectedMethod(""); + getProviderSchemas(providerType) + .then((result) => { + if (active) setSchemas(result); + }) + .catch(() => { + if (active) setSchemas({ status: "error" }); + }); + return () => { + active = false; + }; + }, [providerType, attempt]); + + const methods = + schemas?.status === "success" ? Object.keys(schemas.secretTypes) : []; + const secretType = selectedMethod || methods[0]; + const schema = + schemas?.status === "success" && secretType + ? parseRegistryCredentialSchema(schemas.secretTypes[secretType]) + : null; + useEffect(() => { + if (!schema) + onFooterChange({ + showBack: true, + backLabel: "Back", + onBack, + showAction: false, + actionLabel: "Authenticate", + actionType: WIZARD_FOOTER_ACTION_TYPE.BUTTON, + }); + }, [schema, onBack, onFooterChange]); + + if (!schemas) + return ( +
+ + +
+ ); + + const error = credentialFormError( + schemas.status === "success" && methods.length === 0 + ? "not_found" + : schemas.status, + ); + + return ( +
+ {methods.length > 1 && ( + + + Authentication method + + + + )} + {schema ? ( + + ) : ( +
+ + {error.description} + +
+ + +
+
+ )} +
+ ); +} + +export function DynamicCredentialsStep(props: DynamicCredentialsStepProps) { + return ( + + ); +} diff --git a/ui/components/providers/wizard/steps/footer-controls.ts b/ui/components/providers/wizard/steps/footer-controls.ts index ff41ae8061..7bdd0d5d58 100644 --- a/ui/components/providers/wizard/steps/footer-controls.ts +++ b/ui/components/providers/wizard/steps/footer-controls.ts @@ -22,6 +22,7 @@ export interface WizardFooterConfig { onSecondaryAction?: () => void; showAction: boolean; actionLabel: string; + actionLoading?: boolean; actionDisabled?: boolean; actionType: WizardFooterActionType; actionFormId?: string; diff --git a/ui/components/providers/workflow/forms/connect-account-form.test.tsx b/ui/components/providers/workflow/forms/connect-account-form.test.tsx new file mode 100644 index 0000000000..2a906d431c --- /dev/null +++ b/ui/components/providers/workflow/forms/connect-account-form.test.tsx @@ -0,0 +1,240 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { addProvider, updateProvider, getInstalledRegistryProviderOptions } = + vi.hoisted(() => ({ + addProvider: vi.fn(), + updateProvider: vi.fn(), + getInstalledRegistryProviderOptions: vi.fn(), + })); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn() }), +})); +vi.mock("@/actions/providers/providers", () => ({ + addProvider, + updateProvider, +})); +vi.mock("@/actions/providers/registry-provider", () => ({ + addRegistryProvider: vi.fn(), +})); +vi.mock("@/actions/registry/registry", () => ({ + getInstalledRegistryProviderOptions, +})); + +import { ConnectAccountForm } from "./connect-account-form"; + +describe("provider account aliases", () => { + beforeEach(() => { + vi.clearAllMocks(); + getInstalledRegistryProviderOptions.mockResolvedValue({ + status: "access_denied", + }); + }); + + it("saves an alias changed after an account was already created", async () => { + // Given + const onSuccess = vi.fn(); + const user = userEvent.setup(); + const account = { + id: "existing", + attributes: { provider: "github", uid: "octocat", alias: "Original" }, + }; + addProvider.mockResolvedValue({ data: account }); + updateProvider.mockResolvedValue({ + data: { + ...account, + attributes: { ...account.attributes, alias: "Edited" }, + }, + }); + render(); + await user.click(screen.getByRole("option", { name: "GitHub" })); + await user.type( + screen.getByRole("textbox", { name: "Username/Organization" }), + "octocat", + ); + const alias = screen.getByRole("textbox", { + name: "Provider alias (optional)", + }); + await user.type(alias, "Original"); + await user.click(screen.getByRole("button", { name: "Next" })); + await waitFor(() => expect(onSuccess).toHaveBeenCalledOnce()); + + // When + await user.clear(alias); + await user.type(alias, "Edited"); + await user.click(screen.getByRole("button", { name: "Next" })); + + // Then + await waitFor(() => + expect(onSuccess).toHaveBeenLastCalledWith({ + id: "existing", + providerType: "github", + uid: "octocat", + alias: "Edited", + }), + ); + expect(addProvider).toHaveBeenCalledOnce(); + expect(Object.fromEntries(updateProvider.mock.calls[0][0])).toMatchObject({ + providerId: "existing", + providerAlias: "Edited", + }); + }); +}); + +describe("Registry provider source tabs", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("hides the Registry tab when discovery denies access (Local or flag off)", async () => { + // Given + getInstalledRegistryProviderOptions.mockResolvedValue({ + status: "access_denied", + }); + + // When + render(); + await waitFor(() => + expect(getInstalledRegistryProviderOptions).toHaveBeenCalled(), + ); + + // Then + expect( + screen.getByRole("option", { name: /Amazon Web Services/ }), + ).toBeVisible(); + expect( + screen.queryByRole("tab", { name: "Registry" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("tab", { name: "All providers" }), + ).not.toBeInTheDocument(); + }); + + it("shows the Registry tab once discovery confirms the deployment offers it", async () => { + // Given: Cloud or Private Cloud with Registry enabled and no artifacts yet. + getInstalledRegistryProviderOptions.mockResolvedValue({ + status: "ready", + options: [], + }); + + // When + render(); + + // Then + expect(await screen.findByRole("tab", { name: "Registry" })).toBeVisible(); + expect(screen.getByRole("tab", { name: "All providers" })).toHaveAttribute( + "aria-selected", + "true", + ); + }); + + it("keeps Registry hidden and offers a retry when access is unknown", async () => { + // Given + const user = userEvent.setup(); + getInstalledRegistryProviderOptions + .mockResolvedValueOnce({ status: "unknown" }) + .mockResolvedValueOnce({ status: "ready", options: [] }); + + // When + render(); + + // Then + expect( + await screen.findByText("Registry providers could not be loaded"), + ).toBeVisible(); + expect( + screen.queryByRole("tab", { name: "Registry" }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Retry Registry providers" }), + ).toBeVisible(); + + // When + await user.click( + screen.getByRole("button", { name: "Retry Registry providers" }), + ); + + // Then + expect(await screen.findByRole("tab", { name: "Registry" })).toBeVisible(); + expect( + screen.queryByText("Registry providers could not be loaded"), + ).not.toBeInTheDocument(); + expect(getInstalledRegistryProviderOptions).toHaveBeenCalledTimes(2); + }); + + it("shows a retry in flight, ignores repeat clicks and keeps focus on the button", async () => { + // Given + const user = userEvent.setup(); + let settleRetry: (result: { status: "error" }) => void = () => {}; + getInstalledRegistryProviderOptions + .mockResolvedValueOnce({ status: "error" }) + .mockReturnValueOnce( + new Promise((resolve) => { + settleRetry = resolve; + }), + ); + render(); + const retry = await screen.findByRole("button", { + name: "Retry Registry providers", + }); + + // When + await user.click(retry); + await user.click(retry); + + // Then: the warning stays mounted, so the pressed button is never lost. + expect(retry).toHaveTextContent("Retrying…"); + expect(retry).toHaveAttribute("aria-disabled", "true"); + expect(retry).toHaveFocus(); + expect(getInstalledRegistryProviderOptions).toHaveBeenCalledTimes(2); + + // When: the retry fails again + settleRetry({ status: "error" }); + + // Then + await waitFor(() => + expect(retry).toHaveTextContent("Retry Registry providers"), + ); + expect(retry).not.toHaveAttribute("aria-disabled", "true"); + }); + + it("keeps a retry in flight when an artifact change reloads discovery meanwhile", async () => { + // Given + const user = userEvent.setup(); + let settleRetry: (result: { status: "error" }) => void = () => {}; + getInstalledRegistryProviderOptions + .mockResolvedValueOnce({ status: "error" }) + .mockReturnValueOnce( + new Promise((resolve) => { + settleRetry = resolve; + }), + ) + .mockResolvedValueOnce({ status: "error" }); + render(); + const retry = await screen.findByRole("button", { + name: "Retry Registry providers", + }); + await user.click(retry); + + // When: an unrelated reload settles before the retry does + window.dispatchEvent(new CustomEvent("registry-artifacts-changed")); + await waitFor(() => + expect(getInstalledRegistryProviderOptions).toHaveBeenCalledTimes(3), + ); + await user.click(retry); + + // Then: only the retry itself may end the retry + expect(retry).toHaveTextContent("Retrying…"); + expect(getInstalledRegistryProviderOptions).toHaveBeenCalledTimes(3); + + // When + settleRetry({ status: "error" }); + + // Then + await waitFor(() => + expect(retry).toHaveTextContent("Retry Registry providers"), + ); + }); +}); diff --git a/ui/components/providers/workflow/forms/connect-account-form.tsx b/ui/components/providers/workflow/forms/connect-account-form.tsx index a46de4871d..ddc880e3e7 100644 --- a/ui/components/providers/workflow/forms/connect-account-form.tsx +++ b/ui/components/providers/workflow/forms/connect-account-form.tsx @@ -3,20 +3,28 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { ChevronLeftIcon, ChevronRightIcon, Loader2 } from "lucide-react"; import { useRouter } from "next/navigation"; -import { Dispatch, SetStateAction, useEffect, useState } from "react"; +import { Dispatch, SetStateAction, useEffect, useRef, useState } from "react"; import { useForm, UseFormReturn } from "react-hook-form"; -import { z } from "zod"; -import { addProvider } from "@/actions/providers/providers"; +import { addProvider, updateProvider } from "@/actions/providers/providers"; +import { addRegistryProvider } from "@/actions/providers/registry-provider"; +import { getInstalledRegistryProviderOptions } from "@/actions/registry/registry"; import { AwsMethodSelector } from "@/components/providers/organizations/aws-method-selector"; 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 { 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 { - addProviderFormSchema, + REGISTRY_PROVIDER_DISCOVERY, + type RegistryProviderOption, +} from "@/lib/registry/provider-options"; +import { + createAddProviderFormSchema, + AddProviderFormValues, ApiError, KnownProviderType, ProviderType, @@ -26,10 +34,11 @@ import { OrgFlowType, toOrgFlowType, } from "@/types/organizations"; +import { isKnownProviderType } from "@/types/providers"; import { RadioGroupProvider } from "../../radio-group-provider"; -export type FormValues = z.infer; +export type FormValues = AddProviderFormValues; export interface ConnectAccountSuccessData { id: string; @@ -209,7 +218,61 @@ export const ConnectAccountForm = ({ const [method, setMethod] = useState<"single" | null>(null); const router = useRouter(); - const formSchema = addProviderFormSchema; + 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 [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(() => { + 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), + ); const form = useForm({ resolver: zodResolver(formSchema), @@ -229,6 +292,22 @@ export const ConnectAccountForm = ({ const isLoading = form.formState.isSubmitting; const onSubmitClient = async (values: FormValues) => { + if (submitting.current) return; + const existingAccount = + createdAccount.current?.providerType === values.providerType && + createdAccount.current.uid === values.providerUid + ? createdAccount.current + : null; + if ( + existingAccount && + (existingAccount.alias ?? "") === (values.providerAlias?.trim() ?? "") && + onSuccess + ) { + onSuccess(existingAccount); + return; + } + submitting.current = true; + setProviderError(null); const formValues = { ...values }; const formData = new FormData(); @@ -237,7 +316,20 @@ export const ConnectAccountForm = ({ ); try { - const data = await addProvider(formData); + let data; + if (existingAccount) { + const update = new FormData(); + update.set(ProviderCredentialFields.PROVIDER_ID, existingAccount.id); + update.set( + ProviderCredentialFields.PROVIDER_ALIAS, + values.providerAlias?.trim() ?? "", + ); + data = await updateProvider(update); + } else { + data = await (isKnownProviderType(values.providerType) + ? addProvider(formData) + : addRegistryProvider(formData)); + } if (data?.errors && data.errors.length > 0) { data.errors.forEach((error: ApiError) => { @@ -246,10 +338,9 @@ export const ConnectAccountForm = ({ switch (pointer) { case "/data/attributes/provider": - form.setError("providerType", { - type: "server", - message: errorMessage, - }); + // Provider selection is hidden here; keep failures visible and + // retryable when availability changes without editing the form. + setProviderError(errorMessage); break; case "/data/attributes/uid": case "/data/attributes/__all__": @@ -280,12 +371,13 @@ export const ConnectAccountForm = ({ } = data.data; if (onSuccess) { - onSuccess({ + createdAccount.current = { id, providerType: createdProviderType, uid: uid || values.providerUid, alias: alias ?? values.providerAlias ?? null, - }); + }; + onSuccess(createdAccount.current); return; } @@ -301,10 +393,13 @@ export const ConnectAccountForm = ({ ? error.message : "Something went wrong. Please try again.", }); + } finally { + submitting.current = false; } }; const handleBackStep = () => { + setProviderError(null); applyBackStep({ prevStep, method, @@ -327,6 +422,7 @@ export const ConnectAccountForm = ({ useEffect(() => { onBackHandlerChange?.(() => { + setProviderError(null); applyBackStep({ prevStep, method, @@ -352,7 +448,7 @@ export const ConnectAccountForm = ({ onUiStateChange?.({ showBack: prevStep === 2, showAction: prevStep === 2 && showUidForm, - actionLabel: "Next", + actionLabel: isLoading ? "Creating provider..." : "Next", actionDisabled: !canSubmit || isLoading, isLoading, }); @@ -375,7 +471,33 @@ export const ConnectAccountForm = ({ {/* Step 1: Provider selection */} {prevStep === 1 && (
+ {registryError && ( + + Registry providers could not be loaded + + Built-in providers are available. Check the Registry + connection and try again. + {/* aria-disabled, not disabled: the pressed button keeps focus. */} + + + + )} + {providerError && ( + + Unable to create provider + {providerError} + + )} {isLoading ? ( - + ) : ( )} - {isLoading ? "Loading" : "Next"} + {isLoading ? "Creating provider..." : "Next"} )}
diff --git a/ui/components/providers/workflow/forms/select-credentials-type/cloudflare/credentials-type/cloudflare-api-token-credentials-form.test.tsx b/ui/components/providers/workflow/forms/select-credentials-type/cloudflare/credentials-type/cloudflare-api-token-credentials-form.test.tsx index d8278030f4..d51cf47ff1 100644 --- a/ui/components/providers/workflow/forms/select-credentials-type/cloudflare/credentials-type/cloudflare-api-token-credentials-form.test.tsx +++ b/ui/components/providers/workflow/forms/select-credentials-type/cloudflare/credentials-type/cloudflare-api-token-credentials-form.test.tsx @@ -21,7 +21,7 @@ const Harness = ({ providerUid }: { providerUid?: string }) => { }; const USER_URL = - "https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22read%22%7D%5D&accountId=%2A&zoneId=all&name=Prowler%20Security%20Scanner"; + "https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22ssl_and_certificates%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22bot_management%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22zone_waf%22%2C%22type%22%3A%22read%22%7D%5D&accountId=%2A&zoneId=all&name=Prowler%20Security%20Scanner"; describe("CloudflareApiTokenCredentialsForm", () => { it("always renders the User API Token link with the correct href and safe target attributes", () => { diff --git a/ui/components/providers/workflow/provider-credential-fields.test.tsx b/ui/components/providers/workflow/provider-credential-fields.test.tsx new file mode 100644 index 0000000000..6925925f23 --- /dev/null +++ b/ui/components/providers/workflow/provider-credential-fields.test.tsx @@ -0,0 +1,136 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import type { RegistryCredentialSchema } from "@/lib/provider-credentials/provider-credential-schema"; + +import { RegistryCredentialFields } from "./provider-credential-fields"; + +const schema: RegistryCredentialSchema = { + fields: [ + { + name: "api_key", + label: "API Key", + description: "Issued from the console.", + kind: "password", + required: true, + }, + { + name: "scheme", + label: "Scheme", + kind: "select", + options: ["bearer", "basic"], + required: false, + }, + { name: "notes", label: "Notes", kind: "textarea", required: false }, + ], +}; + +beforeAll(() => { + for (const name of [ + "hasPointerCapture", + "releasePointerCapture", + "scrollIntoView", + ]) { + Object.defineProperty(HTMLElement.prototype, name, { + configurable: true, + value: () => false, + }); + } +}); + +describe("RegistryCredentialFields", () => { + it("renders accessible controlled credential fields and emits changes", async () => { + // Given + const user = userEvent.setup(); + const onChange = vi.fn(); + + render( + , + ); + + // When + await user.type(screen.getByLabelText(/API Key/), "x"); + await user.click(screen.getByRole("combobox", { name: "Scheme" })); + await user.keyboard("{ArrowDown}{Enter}"); + + // Then + const apiKey = screen.getByLabelText(/API Key/); + const description = screen.getByText("Issued from the console."); + const error = screen.getByRole("alert"); + expect(apiKey).toHaveAttribute("type", "password"); + expect(apiKey).toHaveAttribute("autocomplete", "new-password"); + + expect(apiKey).toHaveAttribute( + "aria-describedby", + `${description.id} ${error.id}`, + ); + expect(apiKey.id).toMatch(/-0-control$/); + expect(apiKey).toHaveAttribute("aria-invalid", "true"); + expect(apiKey).toBeRequired(); + expect(description.id).toMatch(/-0-description$/); + expect(error).toHaveTextContent("A key is required."); + expect(error.id).toMatch(/-0-error$/); + expect(onChange).toHaveBeenCalledWith("api_key", "x"); + expect(onChange).toHaveBeenCalledWith("scheme", "basic"); + }); + + it("uses unique index-based IDs for hostile field names and instances", () => { + // Given + + const hostileSchema: RegistryCredentialSchema = { + fields: [ + { + name: "x-description", + label: "First", + kind: "text", + required: false, + }, + { + name: "registry-credential-x", + label: "Second", + description: "Second description.", + kind: "text", + required: false, + }, + ], + }; + + const { container } = render( + <> + + + + , + ); + + // When / Then + expect(screen.getByLabelText("First").id).toMatch(/-0-control$/); + + expect(screen.getByText("Second description.").id).toMatch( + /-1-description$/, + ); + const ids = Array.from(container.querySelectorAll("[id]"), ({ id }) => id); + expect(new Set(ids).size).toBe(ids.length); + }); +}); diff --git a/ui/components/providers/workflow/provider-credential-fields.tsx b/ui/components/providers/workflow/provider-credential-fields.tsx new file mode 100644 index 0000000000..157f4a0d7e --- /dev/null +++ b/ui/components/providers/workflow/provider-credential-fields.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { type ChangeEvent, useId } from "react"; + +import { Checkbox } from "@/components/shadcn/checkbox/checkbox"; +import { Field, FieldError, FieldLabel } from "@/components/shadcn/field/field"; +import { Input } from "@/components/shadcn/input/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/shadcn/select/select"; +import { Textarea } from "@/components/shadcn/textarea/textarea"; +import type { + RegistryCredentialSchema, + RegistryCredentialValue, +} from "@/lib/provider-credentials/provider-credential-schema"; + +interface RegistryCredentialFieldsProps { + readonly errors: Readonly>; + readonly onChange: (name: string, value: RegistryCredentialValue) => void; + readonly schema: RegistryCredentialSchema; + readonly values: Readonly< + Record + >; +} + +export function RegistryCredentialFields({ + errors, + onChange, + schema, + values, +}: RegistryCredentialFieldsProps) { + const instanceId = useId(); + + return ( +
+ {schema.fields.map((field, index) => { + const error = errors[field.name]; + const fieldId = `registry-credential-${instanceId}-${index}`; + const id = `${fieldId}-control`; + const descriptionId = field.description + ? `${fieldId}-description` + : undefined; + const errorId = error ? `${fieldId}-error` : undefined; + const describedBy = + [descriptionId, errorId].filter(Boolean).join(" ") || undefined; + const invalid = error ? true : undefined; + const value = values[field.name]; + const textControlProps = { + "aria-describedby": describedBy, + "aria-invalid": invalid, + id, + + onChange: ( + event: ChangeEvent, + ) => onChange(field.name, event.target.value), + required: field.required, + placeholder: field.placeholder, + spellCheck: false, + value: + typeof value === "string" || typeof value === "number" ? value : "", + }; + + return ( + + {field.kind === "checkbox" ? ( +
+ + onChange(field.name, checked === true) + } + /> + + {field.label} + {field.required && } + +
+ ) : ( + + {field.label} + {field.required && } + + )} + {field.kind === "checkbox" ? null : field.kind === "select" ? ( + + ) : field.kind === "textarea" ? ( +