diff --git a/.env b/.env index 65e82f8ee3..99ec6b55d2 100644 --- a/.env +++ b/.env @@ -110,11 +110,27 @@ DJANGO_OUTPUT_S3_AWS_SECRET_ACCESS_KEY="" DJANGO_OUTPUT_S3_AWS_SESSION_TOKEN="" # The AWS region where your S3 bucket is located (e.g., "us-east-1") +# Required if the bucket uses SSE-KMS: download URLs are then signed with SigV4, which +# is scoped to this region, so it must match the bucket's 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 endpoint the API and Celery workers use to upload and list scan output +# (e.g. "http://minio:9000"). Leave empty on AWS S3. Set it when scan output is stored on +# S3-compatible object storage such as MinIO instead of real S3. +# If set without DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL below, report download URLs are +# signed against this internal host, and a browser outside the container network cannot open them. +DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL="" + +# 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 +174,7 @@ SENTRY_RELEASE=local # REO_DEV_CLIENT_ID= #### Prowler release version #### -NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.39.0 +NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.45.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..a1685c0ba2 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,28 +1,28 @@ # 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 -/Makefile @prowler-cloud/platform -/kubernetes/ @prowler-cloud/platform -**/Dockerfile* @prowler-cloud/platform -**/docker-compose*.yml @prowler-cloud/platform -**/docker-compose*.yaml @prowler-cloud/platform +# CI and infrastructure +/.github/ @prowler-cloud/engineering +/Makefile @prowler-cloud/engineering +/kubernetes/ @prowler-cloud/engineering +**/Dockerfile* @prowler-cloud/engineering +**/docker-compose*.yml @prowler-cloud/engineering +**/docker-compose*.yaml @prowler-cloud/engineering 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/actions/trivy-scan/action.yml b/.github/actions/trivy-scan/action.yml index 13810cbb68..4032da469b 100644 --- a/.github/actions/trivy-scan/action.yml +++ b/.github/actions/trivy-scan/action.yml @@ -64,7 +64,7 @@ runs: scanners: 'vuln' ignore-unfixed: 'true' # A finding with no available fix is not actionable, so it must not gate timeout: '5m' - version: 'v0.72.0' + version: 'v0.74.0' # Not trivyignores: that input drops the .yaml extension Trivy parses by. env: TRIVY_IGNOREFILE: '.trivyignore.yaml' @@ -81,7 +81,7 @@ runs: scanners: 'vuln' ignore-unfixed: 'true' # A finding with no available fix is not actionable, so it must not gate timeout: '5m' - version: 'v0.72.0' + version: 'v0.74.0' # Not trivyignores: that input drops the .yaml extension Trivy parses by. env: TRIVY_IGNOREFILE: '.trivyignore.yaml' diff --git a/.github/agents/issue-triage.md b/.github/agents/issue-triage.md index 9de627e316..c383a88105 100644 --- a/.github/agents/issue-triage.md +++ b/.github/agents/issue-triage.md @@ -199,7 +199,7 @@ You MUST structure your response using this EXACT format. Do NOT include anythin ### For Check Logic Bug -``` +```markdown ### AI Assessment [Experimental]: Check Logic Bug **Component**: {component from issue template} @@ -297,7 +297,7 @@ Write tests FIRST (TDD). The skills contain all testing conventions and patterns ### For Bug (non-check) -``` +```markdown ### AI Assessment [Experimental]: Bug **Component**: {CLI/SDK | API | UI | Dashboard | MCP Server | Other} @@ -378,7 +378,7 @@ Write tests FIRST (TDD). The skills contain all testing conventions and patterns ### For Already Fixed -``` +```markdown ### AI Assessment [Experimental]: Already Fixed **Component**: {component} @@ -401,7 +401,7 @@ Upgrade to the latest version. Close the issue as resolved. ### For Feature Request -``` +```markdown ### AI Assessment [Experimental]: Feature Request **Component**: {component} @@ -419,7 +419,7 @@ Upgrade to the latest version. Close the issue as resolved. ### For Not a Bug -``` +```markdown ### AI Assessment [Experimental]: Not a Bug **Component**: {component} @@ -440,7 +440,7 @@ Upgrade to the latest version. Close the issue as resolved. ### For Needs More Information -``` +```markdown ### AI Assessment [Experimental]: Needs More Information **Component**: {component or "Unknown"} diff --git a/.github/labeler.yml b/.github/labeler.yml index b9abb1dfd0..e6888039da 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -52,6 +52,16 @@ provider/alibabacloud: - any-glob-to-any-file: "prowler/providers/alibabacloud/**" - any-glob-to-any-file: "tests/providers/alibabacloud/**" +provider/huaweicloud: + - changed-files: + - any-glob-to-any-file: "prowler/providers/huaweicloud/**" + - any-glob-to-any-file: "tests/providers/huaweicloud/**" + +provider/image: + - changed-files: + - any-glob-to-any-file: "prowler/providers/image/**" + - any-glob-to-any-file: "tests/providers/image/**" + provider/cloudflare: - changed-files: - any-glob-to-any-file: "prowler/providers/cloudflare/**" @@ -82,6 +92,11 @@ provider/linode: - any-glob-to-any-file: "prowler/providers/linode/**" - any-glob-to-any-file: "tests/providers/linode/**" +provider/stackit: + - changed-files: + - any-glob-to-any-file: "prowler/providers/stackit/**" + - any-glob-to-any-file: "tests/providers/stackit/**" + github_actions: - changed-files: - any-glob-to-any-file: ".github/workflows/*" diff --git a/.github/scripts/slack-messages/README.md b/.github/scripts/slack-messages/README.md index e7fcf00fdd..5466efc7df 100644 --- a/.github/scripts/slack-messages/README.md +++ b/.github/scripts/slack-messages/README.md @@ -8,11 +8,11 @@ These JSON templates are used with the `slackapi/slack-github-action` using the ### Available Templates -**Container Releases** +#### Container Releases - `container-release-started.json`: Simple one-line notification when container push starts - `container-release-completed.json`: Simple one-line notification when container release completes -**Deployments** +#### Deployments - `deployment-started.json`: Deployment start notification with Block Kit formatting - `deployment-completed.json`: Deployment completion notification (updates the start message) @@ -416,17 +416,17 @@ For deployments that start with one message and update it with the final status: ### Container Release (Simple One-Line) **Start message:** -``` +```text API container release 4.5.0 push started... View run ``` **Completion message (success):** -``` +```text [✓] API container release 4.5.0 push completed successfully! View run ``` **Completion message (failure):** -``` +```text [✗] API container release 4.5.0 push failed View run ``` 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/compile-changelogs.yml b/.github/workflows/compile-changelogs.yml index 308bba9553..c7b9b87079 100644 --- a/.github/workflows/compile-changelogs.yml +++ b/.github/workflows/compile-changelogs.yml @@ -61,6 +61,7 @@ jobs: api.github.com:443 github.com:443 objects.githubusercontent.com:443 + release-assets.githubusercontent.com:443 pypi.org:443 files.pythonhosted.org:443 diff --git a/.github/workflows/markdown-lint.yml b/.github/workflows/markdown-lint.yml index 7918c701e5..abd42ded07 100644 --- a/.github/workflows/markdown-lint.yml +++ b/.github/workflows/markdown-lint.yml @@ -55,6 +55,10 @@ jobs: # Pin must match .pre-commit-config.yaml so prek and CI behave identically. # pnpm dlx doesn't accept --ignore-scripts as a flag; the env var # disables postinstall scripts on transitives the same way. + # + # Files come from `git ls-files` because markdownlint doesn't traverse + # dot-directories, so `.github/**/*.md` went unlinted. + # `.markdownlintignore` still applies to the listed paths. env: pnpm_config_ignore_scripts: 'true' - run: pnpm dlx markdownlint-cli@0.45.0 '**/*.md' + run: git ls-files -z '*.md' | xargs -0 -r pnpm dlx markdownlint-cli@0.45.0 -- diff --git a/.github/workflows/mcp-container-build-push.yml b/.github/workflows/mcp-container-build-push.yml index a5116d63be..0e652c703c 100644 --- a/.github/workflows/mcp-container-build-push.yml +++ b/.github/workflows/mcp-container-build-push.yml @@ -114,6 +114,8 @@ jobs: egress-policy: block allowed-endpoints: > auth.docker.io:443 + dl-cdn.alpinelinux.org:443 + dualstack.j.sni.global.fastly.net:443 files.pythonhosted.org:443 ghcr.io:443 github.com:443 diff --git a/.github/workflows/mcp-container-checks.yml b/.github/workflows/mcp-container-checks.yml index a5b8104b88..ce6d02f17d 100644 --- a/.github/workflows/mcp-container-checks.yml +++ b/.github/workflows/mcp-container-checks.yml @@ -81,6 +81,8 @@ jobs: pkg-containers.githubusercontent.com:443 files.pythonhosted.org:443 pypi.org:443 + dl-cdn.alpinelinux.org:443 + dualstack.j.sni.global.fastly.net:443 api.github.com:443 mirror.gcr.io:443 check.trivy.dev:443 diff --git a/.github/workflows/mcp-pypi-release.yml b/.github/workflows/mcp-pypi-release.yml index a6dae75017..f6ad43e39f 100644 --- a/.github/workflows/mcp-pypi-release.yml +++ b/.github/workflows/mcp-pypi-release.yml @@ -113,7 +113,7 @@ jobs: - name: Publish prowler-mcp package to PyPI if: steps.pypi-check.outputs.skip != 'true' - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: packages-dir: ${{ env.WORKING_DIRECTORY }}/dist/ print-hash: true diff --git a/.github/workflows/pr-check-changelog.yml b/.github/workflows/pr-check-changelog.yml index 25a1d9e64a..3ef0eff222 100644 --- a/.github/workflows/pr-check-changelog.yml +++ b/.github/workflows/pr-check-changelog.yml @@ -35,6 +35,7 @@ jobs: api.github.com:443 github.com:443 objects.githubusercontent.com:443 + release-assets.githubusercontent.com:443 pypi.org:443 files.pythonhosted.org:443 diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 1cf130d8c4..65809f15f0 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -84,6 +84,8 @@ jobs: fi - name: Checkout release branch + env: + GH_TOKEN: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} run: | echo "Checking out branch $BRANCH_NAME for release $PROWLER_VERSION..." if git show-ref --verify --quiet "refs/heads/$BRANCH_NAME"; then @@ -92,8 +94,32 @@ jobs: elif git show-ref --verify --quiet "refs/remotes/origin/$BRANCH_NAME"; then echo "Branch $BRANCH_NAME exists remotely, checking out..." git checkout -b "$BRANCH_NAME" "origin/$BRANCH_NAME" + elif [ "$PATCH_VERSION" -eq 0 ]; then + # Only cut the branch once the compiled changelogs are on master + MASTER_SHA=$(git rev-parse origin/master) + VERSION_REGEX="${PROWLER_VERSION//./\\.}" + FOUND_CHANGELOG=false + for changelog in prowler/CHANGELOG.md api/CHANGELOG.md ui/CHANGELOG.md mcp_server/CHANGELOG.md; do + if git show "origin/master:$changelog" 2>/dev/null | grep -qE "^## \[.*\(Prowler v?${VERSION_REGEX}\)"; then + FOUND_CHANGELOG=true + break + fi + done + if [ "$FOUND_CHANGELOG" != "true" ]; then + echo "ERROR: No changelog entry for Prowler $PROWLER_VERSION on master. Merge the compiled changelogs PR first." + exit 1 + fi + # Check the SDK version on master before creating the remote branch + if ! git show "origin/master:pyproject.toml" | grep -qE "^version = \"${VERSION_REGEX}\"$" \ + || ! git show "origin/master:prowler/config/config.py" | grep -qE "^prowler_version = \"${VERSION_REGEX}\"$"; then + echo "ERROR: pyproject.toml or prowler/config/config.py on master is not at version $PROWLER_VERSION. Fix master before creating $BRANCH_NAME." + exit 1 + fi + echo "Branch $BRANCH_NAME does not exist, creating it from master ($MASTER_SHA)..." + gh api --method POST "repos/${GITHUB_REPOSITORY}/git/refs" -f ref="refs/heads/${BRANCH_NAME}" -f sha="${MASTER_SHA}" + git checkout -b "$BRANCH_NAME" "$MASTER_SHA" else - echo "ERROR: Branch $BRANCH_NAME does not exist. For minor releases (X.Y.0), create it manually first. For patch releases (X.Y.Z), the branch should already exist." + echo "ERROR: Branch $BRANCH_NAME does not exist. For patch releases (X.Y.Z), the branch should already exist." exit 1 fi @@ -390,4 +416,3 @@ jobs: if: always() run: | rm -f prowler_changelog.md api_changelog.md ui_changelog.md mcp_changelog.md combined_changelog.md - diff --git a/.github/workflows/release-freeze-lift.yml b/.github/workflows/release-freeze-lift.yml new file mode 100644 index 0000000000..815d9bb268 --- /dev/null +++ b/.github/workflows/release-freeze-lift.yml @@ -0,0 +1,27 @@ +name: 'Tools: Release Freeze Lift' + +run-name: 'Lift release freeze after Prowler ${{ github.event.release.tag_name }}' + +on: + release: + types: + - 'published' + +permissions: {} + +jobs: + release-freeze-lift: + if: github.repository == 'prowler-cloud/prowler' && !github.event.release.prerelease + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Disable release freeze + env: + GH_TOKEN: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} + run: | + gh variable set RELEASE_FREEZE --body false --repo "${GITHUB_REPOSITORY}" diff --git a/.github/workflows/sdk-package-checks.yml b/.github/workflows/sdk-package-checks.yml new file mode 100644 index 0000000000..8c3ab50e47 --- /dev/null +++ b/.github/workflows/sdk-package-checks.yml @@ -0,0 +1,196 @@ +name: 'SDK: Package Checks' + +# Rehearses the PyPI release on every packaging change and once a week, from the +# consumer's side. Two incidents this guards against: +# +# - 5.38.0 shipped an unsatisfiable Requires-Dist (cryptography==50.0.0 while +# alibabacloud-tea-openapi and pyopenssl cap it below 49). A [tool.uv] override hid +# the conflict inside the repo; pip could not install the wheel and silently +# resolved `pip install prowler` to 5.37.1 for a week. +# - 5.39.0 never published: an unpinned build backend started emitting core metadata +# 2.5 and the twine bundled in the publish action rejected it. +# +# Both were only detectable at release time because nothing built and installed the +# artifact earlier. The weekly run also catches releases yanked from PyPI after we +# pinned them (zstd 1.5.7.3, "buggy - not thread safe", sat in uv.lock for months). + +on: + push: + branches: + - 'master' + - 'v5.*' + pull_request: + branches: + - 'master' + - 'v5.*' + schedule: + # Monday 06:00 UTC. Yanks and upstream releases happen without a commit here. + - cron: '0 6 * * 1' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +env: + # Must equal the twine bundled in the pypa/gh-action-pypi-publish pin used by + # sdk-pypi-release.yml (requirements/runtime.txt in that repo at the pinned tag). + # A metadata check that passes here must pass there. + TWINE_VERSION: '7.0.0' + +jobs: + changes: + if: github.repository == 'prowler-cloud/prowler' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + outputs: + # Scheduled and manual runs always execute; pushes and PRs only when a packaging + # input changed. Jobs skipped this way still report success to branch protection. + run: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || steps.filter.outputs.any_changed == 'true' }} + + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + api.github.com:443 + + - name: Checkout repository + if: github.event_name == 'push' || github.event_name == 'pull_request' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # zizmor: ignore[artipacked] + persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch + + - name: Detect packaging changes + if: github.event_name == 'push' || github.event_name == 'pull_request' + id: filter + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 + with: + files: | + pyproject.toml + uv.lock + README.md + util/replicate_pypi_package.py + util/check_yanked_pins.py + api/pyproject.toml + api/uv.lock + mcp_server/pyproject.toml + mcp_server/uv.lock + .github/workflows/sdk-package-checks.yml + .github/workflows/sdk-pypi-release.yml + .github/actions/setup-python-uv/** + + install-from-wheel: + needs: changes + if: needs.changes.outputs.run == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + python-version: + - '3.10' + - '3.11' + - '3.12' + - '3.13' + package: + - 'prowler' + include: + # prowler-cloud is the same tree renamed by util/replicate_pypi_package.py; + # one Python is enough to prove the rename and its build still work. + - python-version: '3.12' + package: 'prowler-cloud' + + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + api.github.com:443 + release-assets.githubusercontent.com:443 + pypi.org:443 + files.pythonhosted.org:443 + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup Python with uv + uses: ./.github/actions/setup-python-uv + with: + python-version: ${{ matrix.python-version }} + install-dependencies: 'false' + + - name: Rename package to prowler-cloud + if: matrix.package == 'prowler-cloud' + run: | + pip install --no-cache-dir toml + python util/replicate_pypi_package.py + + - name: Build sdist and wheel + run: uv build + + - name: Check metadata with the release workflow's twine + run: uvx --from "twine==${TWINE_VERSION}" twine check --strict dist/* + + - name: Install the wheel with pip into a clean virtualenv + # Plain pip, --isolated, from outside the repo: consumers never see [tool.uv] + # override-dependencies or constraint-dependencies, so neither does this step. + run: | + python -m venv "${RUNNER_TEMP}/consumer" + "${RUNNER_TEMP}/consumer/bin/python" -m pip install --quiet --upgrade pip + cd "${RUNNER_TEMP}" + "${RUNNER_TEMP}/consumer/bin/python" -m pip install --isolated --no-cache-dir "${GITHUB_WORKSPACE}"/dist/*.whl + + - name: Smoke test the installed CLI + run: | + cd "${RUNNER_TEMP}" + "${RUNNER_TEMP}/consumer/bin/prowler" --version + # Loads every AWS check module from the installed wheel: catches files missing + # from the package. grep fails the step if the summary line never appears. + "${RUNNER_TEMP}/consumer/bin/prowler" aws --list-checks | grep 'available checks' + + pinned-releases-not-yanked: + needs: changes + if: needs.changes.outputs.run == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + api.github.com:443 + release-assets.githubusercontent.com:443 + pypi.org:443 + files.pythonhosted.org:443 + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + with: + python-version: '3.12' + + - name: Check every pinned and locked release against PyPI + run: python util/check_yanked_pins.py . api mcp_server diff --git a/.github/workflows/sdk-pypi-release.yml b/.github/workflows/sdk-pypi-release.yml index a61c9157e8..1504ce5ca1 100644 --- a/.github/workflows/sdk-pypi-release.yml +++ b/.github/workflows/sdk-pypi-release.yml @@ -84,8 +84,18 @@ jobs: - name: Build Prowler package run: uv build + - name: Verify the wheel installs with pip + # Same check as "SDK: Package Checks", repeated on the exact artifact about to be + # published. Plain pip, --isolated, from outside the repo: an unsatisfiable + # Requires-Dist fails here instead of on users' machines (5.38.0 shipped one). + run: | + python -m venv "${RUNNER_TEMP}/consumer" + "${RUNNER_TEMP}/consumer/bin/python" -m pip install --quiet --upgrade pip + cd "${RUNNER_TEMP}" + "${RUNNER_TEMP}/consumer/bin/python" -m pip install --isolated --no-cache-dir --dry-run "${GITHUB_WORKSPACE}"/dist/*.whl + - name: Publish Prowler package to PyPI - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: print-hash: true @@ -128,7 +138,17 @@ jobs: - name: Build prowler-cloud package run: uv build + - name: Verify the wheel installs with pip + # Same check as "SDK: Package Checks", repeated on the exact artifact about to be + # published. Plain pip, --isolated, from outside the repo: an unsatisfiable + # Requires-Dist fails here instead of on users' machines (5.38.0 shipped one). + run: | + python -m venv "${RUNNER_TEMP}/consumer" + "${RUNNER_TEMP}/consumer/bin/python" -m pip install --quiet --upgrade pip + cd "${RUNNER_TEMP}" + "${RUNNER_TEMP}/consumer/bin/python" -m pip install --isolated --no-cache-dir --dry-run "${GITHUB_WORKSPACE}"/dist/*.whl + - name: Publish prowler-cloud package to PyPI - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: print-hash: 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/sdk-refresh-oci-regions.yml b/.github/workflows/sdk-refresh-oci-regions.yml index 17b4205620..f0e3372ac9 100644 --- a/.github/workflows/sdk-refresh-oci-regions.yml +++ b/.github/workflows/sdk-refresh-oci-regions.yml @@ -76,7 +76,7 @@ jobs: ### Changes - This PR updates the `OCI_COMMERCIAL_REGIONS` dictionary in `prowler/providers/oraclecloud/config.py` with the latest regions fetched from the OCI Identity API (`list_regions()`). + This PR updates the `OCI_COMMERCIAL_REGIONS` dictionary in `prowler/providers/oraclecloud/config.py` and the matching list in `ui/lib/provider-credentials/oci-regions.ts` with the latest regions fetched from the OCI Identity API (`list_regions()`). - Government regions (`OCI_GOVERNMENT_REGIONS`) are preserved unchanged - DOD regions (`OCI_US_DOD_REGIONS`) are preserved unchanged diff --git a/.github/workflows/test-impact-analysis.yml b/.github/workflows/test-impact-analysis.yml index 619fa778df..779bd90284 100644 --- a/.github/workflows/test-impact-analysis.yml +++ b/.github/workflows/test-impact-analysis.yml @@ -57,6 +57,7 @@ jobs: egress-policy: block allowed-endpoints: > github.com:443 + release-assets.githubusercontent.com:443 pypi.org:443 files.pythonhosted.org:443 diff --git a/.github/workflows/ui-e2e-tests-v2.yml b/.github/workflows/ui-e2e-tests-v2.yml index 2a384597c3..8cf43f9d66 100644 --- a/.github/workflows/ui-e2e-tests-v2.yml +++ b/.github/workflows/ui-e2e-tests-v2.yml @@ -10,12 +10,13 @@ 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/workflows/test-impact-analysis.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 +41,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 +61,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 }} @@ -237,8 +238,8 @@ jobs: - name: Add AWS credentials for testing run: | - echo "AWS_ACCESS_KEY_ID=${{ secrets.E2E_AWS_PROVIDER_ACCESS_KEY }}" >> .env - echo "AWS_SECRET_ACCESS_KEY=${{ secrets.E2E_AWS_PROVIDER_SECRET_KEY }}" >> .env + echo "AWS_ACCESS_KEY_ID=${E2E_AWS_PROVIDER_ACCESS_KEY}" >> .env + echo "AWS_SECRET_ACCESS_KEY=${E2E_AWS_PROVIDER_SECRET_KEY}" >> .env - name: Build API image from current code # docker-compose.yml references prowlercloud/prowler-api:latest from the registry, @@ -292,7 +293,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 +338,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 7c36171f65..3804cd807a 100644 --- a/.grype.yaml +++ b/.grype.yaml @@ -17,6 +17,99 @@ 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 + # Go 1.27.1 and golang.org/x/net v0.59.0 in Trivy 0.75.0, the latest release. Fixed in + # Go 1.27.2 and x/net 0.60.0, which only Trivy main has. Almost every entry is an HTTP, + # HTTP/2 or TLS server path (or Windows-only), and Trivy runs here only as a client. The + # two client-side ones are a DoS from a hostile HTTP/2 peer (CVE-2026-78669) and an + # unframed CONNECT body sent through a proxy (CVE-2026-56866); Trivy's only peer is the + # TLS-authenticated database registry and it never sends CONNECT with a body. Pinned to + # the embedded versions. Remove with the Trivy exception by 2026-11-15. + - vulnerability: CVE-2026-56857 + package: + name: stdlib + version: go1.27.1 + - vulnerability: CVE-2026-56866 + package: + name: stdlib + version: go1.27.1 + - vulnerability: CVE-2026-78659 + package: + name: stdlib + version: go1.27.1 + - vulnerability: CVE-2026-78660 + package: + name: stdlib + version: go1.27.1 + - vulnerability: CVE-2026-78663 + package: + name: stdlib + version: go1.27.1 + - vulnerability: CVE-2026-78669 + package: + name: stdlib + version: go1.27.1 + - vulnerability: CVE-2026-94439 + package: + name: stdlib + version: go1.27.1 + - vulnerability: CVE-2026-94440 + package: + name: stdlib + version: go1.27.1 + - vulnerability: CVE-2026-97031 + package: + name: stdlib + version: go1.27.1 + - vulnerability: CVE-2026-78659 + package: + name: golang.org/x/net + version: v0.59.0 + - vulnerability: CVE-2026-78660 + package: + name: golang.org/x/net + version: v0.59.0 + - vulnerability: CVE-2026-78663 + package: + name: golang.org/x/net + version: v0.59.0 + - vulnerability: CVE-2026-78669 + package: + name: golang.org/x/net + version: v0.59.0 - vulnerability: CVE-2026-56852 package: name: golang.org/x/text @@ -33,6 +126,19 @@ ignore: package: name: Microsoft.Bcl.Memory + # The .NET runtime bundled inside the PowerShell tarball the Dockerfile pins. + # CVE-2026-62901 is the same temporary exception documented in .trivyignore.yaml: + # fixed in .NET 9.0.19 / 10.0.11 (2026-08-11), but no published PowerShell release + # ships a patched runtime yet (7.5.9 bundles 9.0.18; 7.6.4 bundles 10.0.x < 10.0.11). + # pwsh runs only local M365 module cmdlets; nothing listens for inbound WebSocket + # connections. Remove with the Trivy exception by 2026-09-15. + - vulnerability: CVE-2026-62901 + package: + name: Microsoft.NETCore.App.Runtime.linux-x64 + - vulnerability: CVE-2026-62901 + package: + name: Microsoft.NETCore.App.Runtime.linux-arm64 + # The CPython interpreter, compiled into the official base image. # TEMPORARY, unlike the entries above: moving to Python 3.13 clears seven of these, and @@ -68,3 +174,29 @@ ignore: - vulnerability: CVE-2026-9669 package: name: python + # CVE-2026-82049 (tarfile data/tar filter bypass via a hard link to a symlink) has no + # fixed CPython release on any branch: the fix is merged on main and 3.13 only, and the + # 3.12 backport is still open. Grype records 3.14.0b1 as the fix, so only-fixed does not + # drop it, yet python:3.12.14-slim-trixie reports it too. Prowler never extracts tar + # archives to disk: the ECR image inspection reads members in memory with extractfile(). + # Remove once the base image ships a 3.12 release that includes the backport. + # https://github.com/python/cpython/issues/157190 + # https://github.com/python/cpython/pull/157454 + - vulnerability: CVE-2026-82049 + package: + name: python + + # zlib in the Alpine base images of the UI (node:24.18.1-alpine) and MCP + # (python:3.13.14-alpine3.23) containers. CVE-2026-85091 is a heap overflow in + # gz_vacate() reached only through non-blocking gzwrite() followed by gzprintf() after + # a write stall. Node links its own bundled zlib and the MCP server never writes gzip + # files, so neither image calls that path. TEMPORARY: the 1.3.2-r1 fix is in the + # Alpine index and could be taken with `apk add --upgrade`, but we wait for the base + # images to ship it instead (the newest node:24-alpine and python:3.13-alpine3.23 still + # carry 1.3.2-r0). Pinned to that version so the rule stops matching on its own once a + # base image moves forward. Remove when the base digests are bumped, by 2026-11-07. + # https://security.alpinelinux.org/vuln/CVE-2026-85091 + - vulnerability: CVE-2026-85091 + package: + name: zlib + version: 1.3.2-r0 diff --git a/.trivyignore.yaml b/.trivyignore.yaml index f3a1b73b12..8c472361d2 100644 --- a/.trivyignore.yaml +++ b/.trivyignore.yaml @@ -68,11 +68,18 @@ vulnerabilities: expired_at: 2026-11-30 # Declared in the SPDX manifest that ships inside PowerShell's MicrosoftTeams module - # (Modules/MicrosoftTeams/7.9.0/_manifest/spdx_2.2/manifest.spdx.json). Trivy reads that + # (Modules/MicrosoftTeams/8.0.0/_manifest/spdx_2.2/manifest.spdx.json). Trivy reads that # SBOM and reports what it declares, which is not the same as what the image contains: # there is no Node runtime and no node_modules anywhere in the image, and the .NET # assemblies target net472, a Windows-only framework. Nothing here is reachable, and none # of it is a dependency we declare -- only Microsoft can change the module's contents. +# proxy-addr 2.0.7 (CVE-2026-90711) is one more such declaration: no file named proxy-addr +# exists in the image, only its purl inside that manifest. The UI lock lists proxy-addr 2.0.7 +# through express, but the Next standalone output does not ship it, so the shared entry +# hides nothing real in the UI image either (checked on prowlercloud/prowler-ui:latest). +# @modelcontextprotocol/sdk 1.27.1 (CVE-2026-104850) is another: only its purl is in that +# manifest. The entry is pinned to 1.27.1 because the UI really depends on the SDK (1.26.0 +# via @langchain/mcp-adapters), and a version-less purl would hide that finding too. - id: CVE-2020-0606 purls: - "pkg:nuget/Microsoft.WindowsDesktop.App.Ref" @@ -113,26 +120,100 @@ 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-84292 + purls: + - "pkg:npm/fast-uri" + expired_at: 2027-01-31 - id: CVE-2026-69192 purls: - "pkg:npm/ip-address" expired_at: 2027-01-31 - - # 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.72.0 contains go-git 5.19.1, and even the latest published - # Trivy release, 0.73.0, 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 + - id: CVE-2026-101898 purls: - - "pkg:golang/github.com/go-git/go-git/v5" - expired_at: 2026-09-15 + - "pkg:npm/axios" + expired_at: 2027-01-31 + - id: CVE-2026-101901 + purls: + - "pkg:npm/axios" + expired_at: 2027-01-31 + - id: CVE-2026-101903 + purls: + - "pkg:npm/axios" + expired_at: 2027-01-31 + - id: CVE-2026-101905 + purls: + - "pkg:npm/axios" + expired_at: 2027-01-31 + - id: CVE-2026-101906 + purls: + - "pkg:npm/axios" + expired_at: 2027-01-31 + - id: CVE-2026-101907 + purls: + - "pkg:npm/axios" + expired_at: 2027-01-31 + - id: CVE-2026-101909 + purls: + - "pkg:npm/axios" + expired_at: 2027-01-31 + - id: CVE-2026-90711 + purls: + - "pkg:npm/proxy-addr" + expired_at: 2027-01-31 + - id: CVE-2026-104850 + purls: + - "pkg:npm/%40modelcontextprotocol%2Fsdk@1.27.1" + expired_at: 2027-01-31 + + # CVE-2026-78667 and CVE-2026-97031 are DoS bugs in the Go standard library: net/http + # burns CPU parsing a Range header with a large number of small ranges in FileServer, + # ServeContent and ServeFile, and crypto/tls lets a client exhaust server memory with + # multiple ECH outer extension references. Both are server-side code paths. Fixed in + # Go 1.26.9 and 1.27.2 (released 2026-10-08). Trivy 0.75.0, the latest published + # release and the version the images ship, is built with go1.27.1, and Trivy main + # still declares `go 1.27.0`: + # https://github.com/aquasecurity/trivy/blob/v0.75.0/go.mod + # Prowler invokes Trivy exclusively as `trivy fs` and `trivy image` on a local path, + # never `trivy server`, so no HTTP or TLS listener ever exists in the image; its only + # TLS use is as a client pulling the vulnerability database. The purl is pinned to the + # stdlib version so the entry retires itself on the next Trivy bump. Remove this + # temporary suppression as soon as a Trivy release is built with Go >= 1.27.2. + - id: CVE-2026-78667 + purls: + - "pkg:golang/stdlib@v1.27.1" + expired_at: 2026-11-15 + - id: CVE-2026-97031 + purls: + - "pkg:golang/stdlib@v1.27.1" + expired_at: 2026-11-15 + + # CVE-2026-78669 is an HTTP/2 DoS in the same Trivy 0.75.0 binary (stdlib 1.27.1 and + # golang.org/x/net 0.59.0): a malicious peer burns CPU with many small SETTINGS frames. + # Unlike the two above it also hits clients, but Trivy's only HTTP/2 peer here is the + # TLS-authenticated registry serving the vulnerability database, and the worst case is + # a slow scan. Fixed in Go 1.27.2 and x/net 0.60.0, which Trivy main already pulls in + # but no release ships yet. Same retirement rule as above. + - id: CVE-2026-78669 + purls: + - "pkg:golang/stdlib@v1.27.1" + - "pkg:golang/golang.org/x/net@v0.59.0" + expired_at: 2026-11-15 - id: CVE-2026-56852 purls: @@ -150,7 +231,3 @@ vulnerabilities: purls: - "pkg:golang/oras.land/oras-go/v2" expired_at: 2026-12-31 - - id: CVE-2026-39822 - purls: - - "pkg:golang/stdlib" - expired_at: 2026-12-31 diff --git a/Dockerfile b/Dockerfile index 62aa3c4f1e..1a1ad42c7e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,31 +1,46 @@ -FROM python:3.12.13-slim-trixie@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de AS build +FROM python:3.12.15-slim-trixie@sha256:05cda9777409a9c3ffddd94a4c476b79f0769a0b4857f0c7ed9226b6800b0d6f AS build 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 -ARG TRIVY_VERSION=0.72.0 +ARG TRIVY_VERSION=0.75.0 ENV TRIVY_VERSION=${TRIVY_VERSION} ARG ZIZMOR_VERSION=1.24.1 ENV ZIZMOR_VERSION=${ZIZMOR_VERSION} # Pinned here, not fetched with the artefact: a compromised release ships its own checksum. -ARG TRIVY_SHA256_AMD64=bbb64b9695866ce4a7a8f5c9592002c5961cab378577fa3f8a040df362b9b2ea -ARG TRIVY_SHA256_ARM64=2ca2c023109c2db6b2b77366b6717291452d4531167377d95c79547f0c8e3467 -ARG POWERSHELL_SHA256_AMD64=492ff26bb958336bf61e597ce19e07648b4003bd2a08659e02f0e3e0446ebfe0 -ARG POWERSHELL_SHA256_ARM64=2503b71da3e83635592b092df59a0aca4c3606b4d9b068217bb00be989cb0d56 +ARG TRIVY_SHA256_AMD64=c6e65abddb348e25f10549df887045629cf28cc72453cd1c63acb717316b3f3f +ARG TRIVY_SHA256_ARM64=a1ee9f6ffb7d112b64ff726a2a0717c21175c1114361391f4a132956751a13b3 +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 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 +# 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 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 0d0fd1fdfe..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 | 621 | 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 | 111 | 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 | @@ -147,7 +147,7 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically | Huawei Cloud [Contact us](https://prowler.com/contact) | 25 | 10 | 1 | 6 | Unofficial | CLI | | E2E Networks [Contact us](https://prowler.com/contact) | 27 | 6 | 0 | 2 | Unofficial | CLI | | Scaleway [Contact us](https://prowler.com/contact) | 1 | 1 | 1 | 1 | Unofficial | CLI | -| StackIT [Contact us](https://prowler.com/contact) | 7 | 2 | 1 | 3 | Unofficial | CLI | +| StackIT [Contact us](https://prowler.com/contact) | 8 | 2 | 1 | 3 | Unofficial | CLI | | NHN | 6 | 2 | 2 | 0 | Unofficial | CLI | > [!Note] 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 b119fbaafc..afd6c2d86d 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -4,6 +4,113 @@ All notable changes to the **Prowler API** are documented in this file. +## [1.45.0] (Prowler v5.44.0) + +### 🚀 Added + +- Scan output uploads and downloads can now target S3-compatible object storage such as MinIO directly via `DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL`, instead of relying on process-wide AWS environment variables that also hijacked unrelated AWS API calls [(#12871)](https://github.com/prowler-cloud/prowler/pull/12871) + +### 🔄 Changed + +- Unify how every endpoint resolves a provider latest completed scan, so overlapping scans no longer make findings, compliance and mute rules read from different scans [(#12858)](https://github.com/prowler-cloud/prowler/pull/12858) + +### 🐞 Fixed + +- Celery loggers are now declared explicitly in `custom_logging.py` so fatal worker errors are no longer silenced by `disable_existing_loggers=True`. All long-running services in `docker-compose.yml` now have `restart: unless-stopped` so containers recover automatically after unexpected crashes. [(#12465)](https://github.com/prowler-cloud/prowler/pull/12465) +- Scan report downloads from an S3 bucket with default SSE-KMS encryption no longer fail with an `InvalidArgument` error: when `DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION` is set, presigned download URLs are signed with AWS Signature Version 4 for that region [(#12746)](https://github.com/prowler-cloud/prowler/pull/12746) +- Adds a periodic sweep that drops orphaned Attack Paths temp Neo4j scan databases left behind when a worker or Neo4j crashes mid-scan, before they accumulate unbounded [(#12832)](https://github.com/prowler-cloud/prowler/pull/12832) +- Providers whose most recent completed scan has no `completed_at` timestamp are no longer missing from every endpoint that reports a provider's latest scan, which now falls back to scan creation order instead of skipping the provider [(#12858)](https://github.com/prowler-cloud/prowler/pull/12858) +- Resources no longer keep a stale failed findings count forever when a scoped or imported scan for the same provider completes after a full scan, which used to make the full scan skip its own cleanup [(#12858)](https://github.com/prowler-cloud/prowler/pull/12858) +- `POST /api/v1/scans` again returns the new scan id in the response `task_args`, which had been empty since the scan broker publish moved to transaction commit [(#12878)](https://github.com/prowler-cloud/prowler/pull/12878) +- API key authentication no longer locks the key row on every request and now throttles `last_used_at` updates to once per 60 seconds, preventing a hot key from serializing all its requests onto a single locked row [(#12882)](https://github.com/prowler-cloud/prowler/pull/12882) +- Provider deletion no longer fails when the provider has Attack Paths scans recorded on a sink that is no longer configured, such as Neptune after moving back to Neo4j [(#12894)](https://github.com/prowler-cloud/prowler/pull/12894) + +### 🔐 Security + +- `DELETE /api/v1/tasks/{id}` requires the permission of the operation that queued the task and rejects provider deletions, and `GET /api/v1/tasks` hides tasks of providers outside the visibility of the role [(#12893)](https://github.com/prowler-cloud/prowler/pull/12893) + +--- + +## [1.44.0] (Prowler v5.43.0) + +### 🐞 Fixed + +- 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 [(#12552)](https://github.com/prowler-cloud/prowler/pull/12552) +- 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 [(#12552)](https://github.com/prowler-cloud/prowler/pull/12552) +- Lapsed pending invitations are reported as expired and no longer block a new invitation for the same email [(#12831)](https://github.com/prowler-cloud/prowler/pull/12831) + +### 🔐 Security + +- `libsqlite3-0`, `gzip`, `perl-base` and `libpcre2-8-0` upgraded in the API container image, patching high Debian CVEs [(#12804)](https://github.com/prowler-cloud/prowler/pull/12804) +- 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 [(#12811)](https://github.com/prowler-cloud/prowler/pull/12811) +- Bumped `anyio` to 4.14.2 to resolve CVE-2026-63374 [(#12848)](https://github.com/prowler-cloud/prowler/pull/12848) + +--- + +## [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 + +- `FINDINGS_TABLE_PARTITION_MAX_AGE_MONTHS` is now applied in months instead of days, and negative values are rejected [(#12580)](https://github.com/prowler-cloud/prowler/pull/12580) + +### 🔐 Security + +- `sqlparse` upgraded to 0.6.0, patching CVE-2026-54284, CVE-2026-59893, and CVE-2026-71491 [(#12509)](https://github.com/prowler-cloud/prowler/pull/12509) +- `openssl`, `libssl3t64` and `openssl-provider-legacy` upgraded to 3.5.7-1~deb13u2 in the API container image, patching ten high OpenSSL CVEs [(#12549)](https://github.com/prowler-cloud/prowler/pull/12549) + +--- + +## [1.40.1] (Prowler v5.39.1) + +### 🔄 Changed + +- Bump alibabacloud-tea-openapi to 0.4.6, oci to 2.184.1 and pyopenssl to 26.4.0 to match the SDK; the cryptography override now names its actual blockers (azure-cli-core pins msal below 1.37, workos 8.3.0 requires cryptography 48) [(#12477)](https://github.com/prowler-cloud/prowler/pull/12477) + +### 🐞 Fixed + +- Pin zstd to 1.5.7.2; 1.5.7.3 was yanked from PyPI as not thread safe [(#12477)](https://github.com/prowler-cloud/prowler/pull/12477) + +### 🔐 Security + +- Trivy from v0.72.0 to v0.73.0 in the container image, fixing HIGH CVE-2026-46600 in the bundled `golang.org/x/net` [(#12445)](https://github.com/prowler-cloud/prowler/pull/12445) +- Trivy v0.74.0 and Debian util-linux 2.41.5-0+deb13u1 in the API container image, patching Go standard library vulnerabilities and CVE-2026-53615 [(#12470)](https://github.com/prowler-cloud/prowler/pull/12470) + +--- + +## [1.40.0] (Prowler v5.39.0) + +### 🔄 Changed + +- `GET /api/v1/users/me` membership relationships identify the active tenant with `meta.active` for JWT and API key authentication [(#12388)](https://github.com/prowler-cloud/prowler/pull/12388) + +### 🐞 Fixed + +- Tenant deletion no longer leaves memberships partially removed when exclusive-user cleanup fails [(#12379)](https://github.com/prowler-cloud/prowler/pull/12379) +- `/api/v1/accounts/saml/{organization_slug}/acs/` rejects non-POST requests before SAML response processing [(#12393)](https://github.com/prowler-cloud/prowler/pull/12393) +- Social login derives a valid user name when identity providers omit the profile name [(#12413)](https://github.com/prowler-cloud/prowler/pull/12413) + +--- + ## [1.39.0] (Prowler v5.38.0) ### 🚀 Added diff --git a/api/Dockerfile b/api/Dockerfile index af7b07e16c..d19c3c9474 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,26 +1,38 @@ -FROM python:3.12.13-slim-trixie@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de AS build +FROM python:3.12.15-slim-trixie@sha256:05cda9777409a9c3ffddd94a4c476b79f0769a0b4857f0c7ed9226b6800b0d6f AS build 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 -ARG TRIVY_VERSION=0.72.0 +ARG TRIVY_VERSION=0.75.0 ENV TRIVY_VERSION=${TRIVY_VERSION} ARG ZIZMOR_VERSION=1.24.1 ENV ZIZMOR_VERSION=${ZIZMOR_VERSION} # Pinned here, not fetched with the artefact: a compromised release ships its own checksum. -ARG TRIVY_SHA256_AMD64=bbb64b9695866ce4a7a8f5c9592002c5961cab378577fa3f8a040df362b9b2ea -ARG TRIVY_SHA256_ARM64=2ca2c023109c2db6b2b77366b6717291452d4531167377d95c79547f0c8e3467 -ARG POWERSHELL_SHA256_AMD64=492ff26bb958336bf61e597ce19e07648b4003bd2a08659e02f0e3e0446ebfe0 -ARG POWERSHELL_SHA256_ARM64=2503b71da3e83635592b092df59a0aca4c3606b4d9b068217bb00be989cb0d56 +ARG TRIVY_SHA256_AMD64=c6e65abddb348e25f10549df887045629cf28cc72453cd1c63acb717316b3f3f +ARG TRIVY_SHA256_ARM64=a1ee9f6ffb7d112b64ff726a2a0717c21175c1114361391f4a132956751a13b3 +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 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 +# 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 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 \ @@ -36,6 +48,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libtool \ libxslt1-dev \ 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-trivy-0-75-0.security.md b/api/changelog.d/api-image-trivy-0-75-0.security.md new file mode 100644 index 0000000000..4d5759d29c --- /dev/null +++ b/api/changelog.d/api-image-trivy-0-75-0.security.md @@ -0,0 +1 @@ +Python base image from 3.12.13 to 3.12.15 and Trivy from 0.74.0 to 0.75.0 in the API container image, patching CVE-2026-19445, CVE-2026-19553 and the grpc-go and x/crypto CVEs bundled in Trivy diff --git a/api/changelog.d/django-5-2-17.security.md b/api/changelog.d/django-5-2-17.security.md new file mode 100644 index 0000000000..ec1e6a7450 --- /dev/null +++ b/api/changelog.d/django-5-2-17.security.md @@ -0,0 +1 @@ +`django` from 5.1.15 to 5.2.17, patching CVE-2026-15307 diff --git a/api/changelog.d/dulwich-1-2-9.security.md b/api/changelog.d/dulwich-1-2-9.security.md new file mode 100644 index 0000000000..8f1dd24253 --- /dev/null +++ b/api/changelog.d/dulwich-1-2-9.security.md @@ -0,0 +1 @@ +`dulwich` from 1.2.5 to 1.2.9, patching GHSA-5fqc-mrg8-w798, GHSA-8mcx-5rqc-vhmf, GHSA-8w8g-wq8h-fq33 and GHSA-cm62-gvxx-vmxx diff --git a/api/changelog.d/kubeconfig-inline-credentials.security.md b/api/changelog.d/kubeconfig-inline-credentials.security.md new file mode 100644 index 0000000000..8d6115d4c3 --- /dev/null +++ b/api/changelog.d/kubeconfig-inline-credentials.security.md @@ -0,0 +1 @@ +Kubernetes kubeconfig validation accepts only inline credentials and known cluster fields, rejecting file-path fields (`tokenFile`, `client-certificate`, `client-key`, `certificate-authority`), command-based authentication (`exec`, `cmd-path`), `auth-provider` directives, proxy URLs and unknown user or cluster keys; stored kubeconfigs are re-validated before each scan and connection test diff --git a/api/changelog.d/oci-home-region-bootstrap.fixed.md b/api/changelog.d/oci-home-region-bootstrap.fixed.md new file mode 100644 index 0000000000..0d28492c2b --- /dev/null +++ b/api/changelog.d/oci-home-region-bootstrap.fixed.md @@ -0,0 +1 @@ +OCI provider secrets keep the region as home region for credential validation and scans, instead of always using us-ashburn-1 diff --git a/api/changelog.d/pyjwt-2-14-0.security.md b/api/changelog.d/pyjwt-2-14-0.security.md new file mode 100644 index 0000000000..fab78f2937 --- /dev/null +++ b/api/changelog.d/pyjwt-2-14-0.security.md @@ -0,0 +1 @@ +`pyjwt` from 2.13.0 to 2.14.0, patching GHSA-ffc3-869f-jxw9 diff --git a/api/changelog.d/pymongo-4-18-2.security.md b/api/changelog.d/pymongo-4-18-2.security.md new file mode 100644 index 0000000000..b621313d25 --- /dev/null +++ b/api/changelog.d/pymongo-4-18-2.security.md @@ -0,0 +1 @@ +`pymongo` from 4.15.1 to 4.18.2, patching CVE-2026-96748 and CVE-2026-96749 diff --git a/api/changelog.d/saml-acs-post-only.fixed.md b/api/changelog.d/saml-acs-post-only.fixed.md deleted file mode 100644 index 91f00e7d62..0000000000 --- a/api/changelog.d/saml-acs-post-only.fixed.md +++ /dev/null @@ -1 +0,0 @@ -`/api/v1/accounts/saml/{organization_slug}/acs/` rejects non-POST requests before SAML response processing diff --git a/api/changelog.d/sentry-no-personal-data.security.md b/api/changelog.d/sentry-no-personal-data.security.md new file mode 100644 index 0000000000..b0c7d6d5fe --- /dev/null +++ b/api/changelog.d/sentry-no-personal-data.security.md @@ -0,0 +1 @@ +Sentry error events no longer include user identity, IP addresses, cookies, headers or request bodies, which could contain personal data or provider credentials diff --git a/api/changelog.d/tenant-deletion-transaction.fixed.md b/api/changelog.d/tenant-deletion-transaction.fixed.md deleted file mode 100644 index 5679bc4564..0000000000 --- a/api/changelog.d/tenant-deletion-transaction.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Tenant deletion no longer leaves memberships partially removed when exclusive-user cleanup fails diff --git a/api/changelog.d/urllib3-2-8-0.security.md b/api/changelog.d/urllib3-2-8-0.security.md new file mode 100644 index 0000000000..4645bb5942 --- /dev/null +++ b/api/changelog.d/urllib3-2-8-0.security.md @@ -0,0 +1 @@ +`urllib3` from 2.7.0 to 2.8.0, patching CVE-2026-97687 and CVE-2026-97689 diff --git a/api/changelog.d/user-detail-cross-tenant-relations.security.md b/api/changelog.d/user-detail-cross-tenant-relations.security.md new file mode 100644 index 0000000000..2ada35d1ea --- /dev/null +++ b/api/changelog.d/user-detail-cross-tenant-relations.security.md @@ -0,0 +1 @@ +`GET /api/v1/users/{id}` no longer exposes another user's memberships, roles or tenant names from tenants other than the one in the request token diff --git a/api/changelog.d/users-me-active-membership.changed.md b/api/changelog.d/users-me-active-membership.changed.md deleted file mode 100644 index 4e02368ab4..0000000000 --- a/api/changelog.d/users-me-active-membership.changed.md +++ /dev/null @@ -1 +0,0 @@ -`GET /api/v1/users/me` membership relationships identify the active tenant with `meta.active` for JWT and API key authentication diff --git a/api/pyproject.toml b/api/pyproject.toml index 3be69d7c43..e20a4f3993 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -25,7 +25,7 @@ authors = [{name = "Prowler Engineering", email = "engineering@prowler.com"}] dependencies = [ "celery (==5.6.2)", "dj-rest-auth[with_social,jwt] (==7.0.1)", - "django (==5.1.15)", + "django (==5.2.17)", "django-allauth[saml] (==65.15.0)", "django-celery-beat (==2.9.0)", "django-celery-results (==2.6.0)", @@ -61,7 +61,7 @@ dependencies = [ "cartography (==0.138.1)", "gevent (==25.9.1)", "werkzeug (==3.1.7)", - "sqlparse (==0.5.5)", + "sqlparse (==0.6.0)", "fonttools (==4.62.1)", "uvicorn-worker (==0.4.0)" ] @@ -71,7 +71,7 @@ name = "prowler-api" package-mode = false # Needed for the SDK compatibility requires-python = ">=3.11,<3.13" -version = "1.40.0" +version = "1.46.0" # Shared ruff baseline (kept in sync with mcp_server/pyproject.toml). # target-version tracks this project's lowest supported Python. @@ -92,8 +92,7 @@ extend-select = [ [tool.uv] # Transitive pins matching master to avoid silent drift; bump deliberately. -# workos and pyopenssl run ahead of master: the versions master pins cap cryptography -# below 48, so both were bumped to versions that allow it (PROWLER-2310). +# workos is api-only; pyopenssl matches master (PROWLER-2310). constraint-dependencies = [ "about-time==4.2.1", "adal==1.2.7", @@ -130,7 +129,7 @@ constraint-dependencies = [ "alibabacloud-sls20201230==5.9.0", "alibabacloud-sts20150401==1.1.6", "alibabacloud-tea==0.4.3", - "alibabacloud-tea-openapi==0.4.5", + "alibabacloud-tea-openapi==0.4.6", "alibabacloud-tea-util==0.3.14", "alibabacloud-tea-xml==0.0.3", "alibabacloud-vpc20160428==6.13.0", @@ -138,7 +137,7 @@ constraint-dependencies = [ "aliyun-log-fastpb==0.2.0", "amqp==5.3.1", "annotated-types==0.7.0", - "anyio==4.12.1", + "anyio==4.14.2", "applicationinsights==0.11.10", "apscheduler==3.11.2", "argcomplete==3.5.3", @@ -223,7 +222,7 @@ constraint-dependencies = [ "dill==0.4.1", "distro==1.9.0", "dj-rest-auth==7.0.1", - "django==5.1.15", + "django==5.2.17", "django-allauth==65.15.0", "django-celery-beat==2.9.0", "django-celery-results==2.6.0", @@ -247,7 +246,7 @@ constraint-dependencies = [ "drf-simple-apikey==2.2.1", "drf-spectacular==0.27.2", "drf-spectacular-jsonapi==0.5.1", - "dulwich==1.2.5", + "dulwich==1.2.9", "duo-client==5.5.0", "durationpy==0.10", "email-validator==2.2.0", @@ -339,7 +338,7 @@ constraint-dependencies = [ "nltk==3.9.4", "numpy==2.2.6", "oauthlib==3.3.1", - "oci==2.183.0", + "oci==2.184.1", "openai==1.109.1", "openstacksdk==4.2.0", "opentelemetry-api==1.39.1", @@ -376,11 +375,11 @@ constraint-dependencies = [ "pydantic-core==2.41.5", "pygithub==2.8.0", "pygments==2.20.0", - "pyjwt==2.13.0", + "pyjwt==2.14.0", "pylint==3.2.5", "pymsalruntime==0.18.1", "pynacl==1.6.2", - "pyopenssl==26.2.0", + "pyopenssl==26.4.0", "pyparsing==3.3.2", "pyreadline3==3.5.4", "pysocks==1.7.1", @@ -424,7 +423,7 @@ constraint-dependencies = [ "six==1.17.0", "slack-sdk==3.39.0", "sniffio==1.3.1", - "sqlparse==0.5.5", + "sqlparse==0.6.0", "statsd==4.0.1", "std-uritemplate==2.0.8", "stevedore==5.6.0", @@ -440,7 +439,7 @@ constraint-dependencies = [ "tzdata==2025.3", "tzlocal==5.3.1", "uritemplate==4.2.0", - "urllib3==2.7.0", + "urllib3==2.8.0", "uuid6==2024.7.10", "uvicorn==0.49.0", "uvloop==0.22.1", @@ -458,7 +457,7 @@ constraint-dependencies = [ "zipp==3.23.0", "zope-event==6.1", "zope-interface==8.2", - "zstd==1.5.7.3" + "zstd==1.5.7.2" ] # prowler@master needs okta==3.4.2, but cartography 0.138.1 requires okta<1.0.0. # Attack Paths does not ingest Okta today, so override the Cartography @@ -476,16 +475,23 @@ constraint-dependencies = [ # a constraint cannot satisfy against the SDK's hard pins, so override the whole set # to 1.9.10 until the SDK bump propagates to the pinned master rev. # -# prowler@master hard-pins dulwich==0.23.0 and pyjwt==2.12.1 in [project.dependencies]. -# dulwich 1.2.5 patches GHSA-897w-fcg9-f6xj (arbitrary file write) and pyjwt 2.13.0 -# patches PYSEC-2026-179 (HMAC/JWK key-confusion); a constraint cannot satisfy these -# against the SDK's hard pins, so override them to the patched versions until the SDK -# bump propagates to the pinned master rev. pyjwt keeps the [crypto] extra because an +# The API installs prowler from a pinned master git rev whose dulwich and pyjwt pins trail +# the root pyproject.toml. dulwich 1.2.9 patches GHSA-5fqc-mrg8-w798, GHSA-8mcx-5rqc-vhmf, +# GHSA-8w8g-wq8h-fq33 and GHSA-cm62-gvxx-vmxx, and pyjwt 2.14.0 patches GHSA-ffc3-869f-jxw9, +# so override both until that rev is refreshed. pyjwt keeps the [crypto] extra because an # override replaces the whole requirement; bare pyjwt would drop it from the consumers # that request pyjwt[crypto] and leave cryptography (needed for RS256) only transitive. override-dependencies = [ "okta==3.4.2", - # alibabacloud-tea-openapi 0.4.5 caps cryptography below 49 and is the latest release. + # huaweicloudsdkcore (via prowler) pins pymongo==4.15.1 exactly; CVE-2026-96748 and + # CVE-2026-96749 are fixed in 4.18.2. Only bson types are used, so the override is safe. + "pymongo==4.18.2", + # prowler requires cryptography==50.0.0. Two api-only dependencies still cap it below + # 49 and cannot move yet: msal, pinned exactly by azure-cli-core (2.83.0 -> 1.35.0b1, + # 2.89.1 -> 1.36.0, both <49; cartography needs azure-cli-core), and workos 8.3.0 + # (~=48.0; workos 10.1.1+ needs ~=50.0 and is a separate SDK upgrade). This api is + # deployed from this lock with `uv sync --locked`, so the override applies to what runs. + # Remove when azure-cli-core pins msal>=1.37.0 and workos is on 10.x. "cryptography==50.0.0", "azure-mgmt-containerservice==34.1.0", "microsoft-kiota-abstractions==1.9.10", @@ -495,6 +501,6 @@ override-dependencies = [ "microsoft-kiota-serialization-json==1.9.10", "microsoft-kiota-serialization-multipart==1.9.10", "microsoft-kiota-serialization-text==1.9.10", - "dulwich==1.2.5", - "pyjwt[crypto]==2.13.0" + "dulwich==1.2.9", + "pyjwt[crypto]==2.14.0" ] diff --git a/api/src/backend/api/adapters.py b/api/src/backend/api/adapters.py index 55ac440f59..6806289fdb 100644 --- a/api/src/backend/api/adapters.py +++ b/api/src/backend/api/adapters.py @@ -12,11 +12,37 @@ from api.models import ( UserRoleRelationship, ) from api.utils import accept_invitation_for_user +from django.core.exceptions import ValidationError from django.db import transaction from django.http import HttpResponseForbidden class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter): + @staticmethod + def _get_social_account_name(extra_data: dict, email: str) -> str: + name_field = User._meta.get_field("name") + for value in ( + extra_data.get("name"), + extra_data.get("login"), + extra_data.get("username"), + email, + ): + if not isinstance(value, str): + continue + + candidate = value.strip()[: name_field.max_length].rstrip() + if not candidate: + continue + + try: + name_field.run_validators(candidate) + except ValidationError: + continue + + return candidate + + raise ValueError("Social account does not provide a valid user identity.") + @staticmethod def get_user_by_email(email: str): try: @@ -116,11 +142,8 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter): if provider != "saml": # Handle other providers (e.g., GitHub, Google) + user.name = self._get_social_account_name(extra, user.email) user.save(using=MainRouter.admin_db) - social_account_name = extra.get("name") - if social_account_name: - user.name = social_account_name - user.save(using=MainRouter.admin_db) invitation_token = self._get_invitation_token(request) if invitation_token: diff --git a/api/src/backend/api/attack_paths/database.py b/api/src/backend/api/attack_paths/database.py index 3ef55b7eca..1db76c4307 100644 --- a/api/src/backend/api/attack_paths/database.py +++ b/api/src/backend/api/attack_paths/database.py @@ -207,6 +207,11 @@ def drop_database(database: str) -> None: sink_module.get_backend().drop_database(database) +def list_databases() -> list[str]: + """List database names on the ingest cluster. Temp scan DBs always live here.""" + return ingest.list_databases() + + def drop_subgraph(database: str, provider_id: str) -> int: return sink_module.get_backend().drop_subgraph(database, provider_id) diff --git a/api/src/backend/api/attack_paths/ingest/__init__.py b/api/src/backend/api/attack_paths/ingest/__init__.py index 5833b8b373..d95482ed85 100644 --- a/api/src/backend/api/attack_paths/ingest/__init__.py +++ b/api/src/backend/api/attack_paths/ingest/__init__.py @@ -13,6 +13,7 @@ from api.attack_paths.ingest.driver import ( get_session, get_uri, init_driver, + list_databases, run_cypher, ) @@ -25,5 +26,6 @@ __all__ = [ "get_session", "get_uri", "init_driver", + "list_databases", "run_cypher", ] diff --git a/api/src/backend/api/attack_paths/ingest/driver.py b/api/src/backend/api/attack_paths/ingest/driver.py index 1b05c721e7..5c8b573ad7 100644 --- a/api/src/backend/api/attack_paths/ingest/driver.py +++ b/api/src/backend/api/attack_paths/ingest/driver.py @@ -165,6 +165,14 @@ def drop_database(database: str) -> None: session.run(f"DROP DATABASE `{database}` IF EXISTS DESTROY DATA") +def list_databases() -> list[str]: + """List every database name on the Neo4j temp-database cluster.""" + # A cluster returns one row per hosting server, so dedupe on name + with get_session() as session: + result = session.run("SHOW DATABASES YIELD name RETURN DISTINCT name") + return [record["name"] for record in result] + + def clear_cache(database: str) -> None: """Best-effort cache clear for a Neo4j database.""" from api.attack_paths.database import GraphDatabaseQueryException diff --git a/api/src/backend/api/authentication.py b/api/src/backend/api/authentication.py index af1a35c7c0..2c99edfbad 100644 --- a/api/src/backend/api/authentication.py +++ b/api/src/backend/api/authentication.py @@ -1,4 +1,5 @@ import logging +from datetime import timedelta from math import isfinite from uuid import UUID @@ -6,7 +7,7 @@ from api.db_router import MainRouter from api.models import TenantAPIKey, TenantAPIKeyManager from cryptography.fernet import InvalidToken from django.core.exceptions import ObjectDoesNotExist -from django.db import transaction +from django.db.models import Q from django.utils import timezone from drf_simple_apikey.backends import APIKeyAuthentication as BaseAPIKeyAuth from drf_simple_apikey.crypto import get_crypto @@ -18,12 +19,15 @@ from rest_framework_simplejwt.authentication import JWTAuthentication logger = logging.getLogger(__name__) +# Writing on every request makes all requests of a busy key contend on one row +API_KEY_LAST_USED_AT_THROTTLE_SECONDS = 60 + class OrphanedAPIKeyError(Exception): """Raised when an API key outlived the user that owns it. - Handled by `authenticate`, which commits the revocation written while detecting it - and then rejects the request with `AuthenticationFailed`. + The revocation is written by a plain `update()` before this is raised, so it is + already persisted by the time `authenticate` catches it and rejects the request. """ @@ -37,8 +41,9 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth): """ Override to use admin connection, bypassing RLS during authentication. - Returns the validated API key row, locked with `select_for_update`, so callers - must run inside `transaction.atomic(using=MainRouter.admin_db)`. + Returns the validated API key row from a single read. `authenticate` builds + the auth claims from that same row instead of looking it up again, so a key + revoked or orphaned right after validation can't still authenticate. """ try: payload = self.key_crypto.decrypt(key) @@ -67,9 +72,11 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth): raise AuthenticationFailed("API Key has already expired.") try: + # Loading `entity` in the same query keeps a user deleted after this read + # from turning the later `api_key.entity` access into a 500 api_key = ( self.model.objects.using(MainRouter.admin_db) - .select_for_update() + .select_related("entity") .get(id=api_key_pk) ) except ObjectDoesNotExist: @@ -85,8 +92,9 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth): # Revoke it as well, so it stops showing up as active and later attempts fail # the `revoked` check above like any other revoked key. if api_key.entity_id is None: - api_key.revoked = True - api_key.save(update_fields=["revoked"], using=MainRouter.admin_db) + self.model.objects.using(MainRouter.admin_db).filter( + id=api_key.id, revoked=False + ).update(revoked=True) logger.warning( "Revoked orphaned API key: prefix=%s tenant=%s", api_key.prefix, @@ -112,34 +120,38 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth): except ValueError: raise AuthenticationFailed("Invalid API Key.") - # Validation, the `last_used_at` update and the auth claims all read the same - # row, locked until the transaction ends. Looking the key up a second time to - # build the claims used to leave a window where a key revoked or orphaned right - # after passing validation still authenticated. - with transaction.atomic(using=MainRouter.admin_db): - try: - api_key = self._authenticate_credentials(request, key) - except OrphanedAPIKeyError: - # Rejected below instead of here: leaving the block normally commits - # the revocation `_authenticate_credentials` wrote, while raising from - # inside would roll it back. - pass - else: - # The prefix used to be checked by the second lookup - if api_key.prefix != prefix: - raise AuthenticationFailed("Invalid API Key.") + try: + api_key = self._authenticate_credentials(request, key) + except OrphanedAPIKeyError: + raise AuthenticationFailed("No entity matching this api key.") - api_key.last_used_at = timezone.now() - api_key.save(update_fields=["last_used_at"], using=MainRouter.admin_db) + # The prefix used to be checked by the second lookup + if api_key.prefix != prefix: + raise AuthenticationFailed("Invalid API Key.") - entity = api_key.entity - return entity, { - "tenant_id": str(api_key.tenant_id), - "sub": str(entity.id), - "api_key_prefix": api_key.prefix, - } + self._throttled_touch_last_used_at(api_key) - raise AuthenticationFailed("No entity matching this api key.") + entity = api_key.entity + return entity, { + "tenant_id": str(api_key.tenant_id), + "sub": str(entity.id), + "api_key_prefix": api_key.prefix, + } + + @staticmethod + def _throttled_touch_last_used_at(api_key: TenantAPIKey) -> None: + """Write `last_used_at` at most once per throttle interval, without locking the row.""" + now = timezone.now() + stale_before = now - timedelta(seconds=API_KEY_LAST_USED_AT_THROTTLE_SECONDS) + + if api_key.last_used_at is not None and api_key.last_used_at >= stale_before: + return + + TenantAPIKey.objects.using(MainRouter.admin_db).filter( + id=api_key.id, revoked=False + ).filter( + Q(last_used_at__isnull=True) | Q(last_used_at__lt=stale_before) + ).update(last_used_at=now) class CombinedJWTOrAPIKeyAuthentication(BaseAuthentication): diff --git a/api/src/backend/api/db_utils.py b/api/src/backend/api/db_utils.py index b6d3fdada1..b73fe127fb 100644 --- a/api/src/backend/api/db_utils.py +++ b/api/src/backend/api/db_utils.py @@ -409,7 +409,7 @@ def batch_delete(tenant_id, queryset, batch_size=settings.DJANGO_DELETION_BATCH_ Args: tenant_id (str): Tenant ID the queryset belongs to. - queryset (QuerySet): The queryset of objects to delete. + queryset: The queryset of objects to delete. batch_size (int): The number of objects to delete in each batch. Returns: 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/migrations/0100_attack_paths_tmp_db_reap_periodic_task.py b/api/src/backend/api/migrations/0100_attack_paths_tmp_db_reap_periodic_task.py new file mode 100644 index 0000000000..ff27aff249 --- /dev/null +++ b/api/src/backend/api/migrations/0100_attack_paths_tmp_db_reap_periodic_task.py @@ -0,0 +1,48 @@ +from django.db import migrations + +TASK_NAME = "attack-paths-reap-orphaned-tmp-databases" +INTERVAL_HOURS = 6 + + +def create_periodic_task(apps, schema_editor): + IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule") + PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask") + + schedule, _ = IntervalSchedule.objects.get_or_create( + every=INTERVAL_HOURS, + period="hours", + ) + + PeriodicTask.objects.update_or_create( + name=TASK_NAME, + defaults={ + "task": TASK_NAME, + "interval": schedule, + "enabled": True, + }, + ) + + +def delete_periodic_task(apps, schema_editor): + IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule") + PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask") + + PeriodicTask.objects.filter(name=TASK_NAME).delete() + + # Clean up the schedule if no other task references it + IntervalSchedule.objects.filter( + every=INTERVAL_HOURS, + period="hours", + periodictask__isnull=True, + ).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0099_delete_tenant_onboarding_profile"), + ("django_celery_beat", "0019_alter_periodictasks_options"), + ] + + operations = [ + migrations.RunPython(create_periodic_task, delete_periodic_task), + ] diff --git a/api/src/backend/api/models.py b/api/src/backend/api/models.py index a280708d53..18454654ec 100644 --- a/api/src/backend/api/models.py +++ b/api/src/backend/api/models.py @@ -617,9 +617,66 @@ class Task(RowLevelSecurityProtectedModel): resource_name = "tasks" +class ScanQuerySet(models.QuerySet): + """Shared selectors for "the latest scan of a provider". + + The queryset must already be scoped by the caller: manager, tenant, RBAC, + providers and database alias. + """ + + # How "which completed scan is the provider's current one" is ordered. + LATEST_ORDER_BY = ( + models.F("completed_at").desc(nulls_last=True), + models.F("inserted_at").desc(), + models.F("id").desc(), + ) + + def _eligible_for_latest(self) -> "ScanQuerySet": + """Restrict to the scans that may be a provider's latest. + + Returns: + ScanQuerySet: The completed scans. + """ + return self.filter(state=StateChoices.COMPLETED) + + def latest_per_provider(self) -> "ScanQuerySet": + """Pick each provider's latest scan with `DISTINCT ON (provider_id)`. + + Returns: + ScanQuerySet: One scan per provider, the latest one. + """ + return ( + self._eligible_for_latest() + .order_by("provider_id", *self.LATEST_ORDER_BY) + .distinct("provider_id") + ) + + def latest_ids_per_provider(self) -> list[UUID]: + """Evaluate `latest_per_provider` and return the scan ids. + + The ids are materialised so callers can pass them as a literal `IN` + list; as a subquery Postgres misestimates the row count and picks a + slow nested loop. + + Returns: + list[UUID]: The id of each provider's latest scan. + """ + return list(self.latest_per_provider().values_list("id", flat=True)) + + def latest_first(self) -> "ScanQuerySet": + """Order eligible scans newest first, without deduplicating per provider. + + Expects the queryset to be already filtered to a single provider. + + Returns: + ScanQuerySet: The eligible scans, latest first. + """ + return self._eligible_for_latest().order_by(*self.LATEST_ORDER_BY) + + class Scan(RowLevelSecurityProtectedModel): - objects = ActiveProviderManager() - all_objects = models.Manager() + objects = ActiveProviderManager.from_queryset(ScanQuerySet)() + all_objects = ScanQuerySet.as_manager() _SCOPING_SCANNER_ARG_KEYS_CACHE: tuple[str, ...] | None = None @@ -726,6 +783,12 @@ class Scan(RowLevelSecurityProtectedModel): name="scans_prov_state_ins_desc_idx", ), # TODO This might replace `scans_prov_state_ins_desc_idx` completely. Review usage + # Since `ScanQuerySet`, no code path reads a provider's + # completed scans by `-inserted_at`. The only query left that + # matches this index (and `scans_prov_state_ins_desc_idx` above) + # is `GET /scans?filter[provider]=…&filter[state]=completed` with + # the default sort. Both are candidates to drop in a follow-up + # once production `pg_stat_user_indexes.idx_scan` confirms it. models.Index( fields=["tenant_id", "provider_id", "-inserted_at"], condition=Q(state=StateChoices.COMPLETED), @@ -1380,6 +1443,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/partitions.py b/api/src/backend/api/partitions.py index 8903c4504b..4d60b46b91 100644 --- a/api/src/backend/api/partitions.py +++ b/api/src/backend/api/partitions.py @@ -6,6 +6,7 @@ from api.rls import RowLevelSecurityConstraint from api.uuid_utils import datetime_to_uuid7 from dateutil.relativedelta import relativedelta from django.conf import settings +from django.core.exceptions import ImproperlyConfigured from psqlextra.partitioning import ( PostgresPartitioningError, PostgresPartitioningManager, @@ -153,10 +154,17 @@ class PostgresUUIDv7PartitioningStrategy(PostgresRangePartitioningStrategy): ) -def relative_days_or_none(value): - if value is None: +def relative_months_or_none(value): + # A negative value would set the cutoff in the future and delete every + # partition, so it is rejected rather than silently ignored. + if value is not None and value < 0: + raise ImproperlyConfigured( + "FINDINGS_TABLE_PARTITION_MAX_AGE_MONTHS must not be negative; " + "leave it unset or use 0 to keep partitions indefinitely" + ) + if not value: return None - return relativedelta(days=value) + return relativedelta(months=value) # @@ -173,7 +181,7 @@ manager = PostgresPartitioningManager( months=settings.FINDINGS_TABLE_PARTITION_MONTHS ), count=settings.FINDINGS_TABLE_PARTITION_COUNT, - max_age=relative_days_or_none( + max_age=relative_months_or_none( settings.FINDINGS_TABLE_PARTITION_MAX_AGE_MONTHS ), name_format="%Y_%b", @@ -189,7 +197,7 @@ manager = PostgresPartitioningManager( months=settings.FINDINGS_TABLE_PARTITION_MONTHS ), count=settings.FINDINGS_TABLE_PARTITION_COUNT, - max_age=relative_days_or_none( + max_age=relative_months_or_none( settings.FINDINGS_TABLE_PARTITION_MAX_AGE_MONTHS ), name_format="%Y_%b", diff --git a/api/src/backend/api/rbac/permissions.py b/api/src/backend/api/rbac/permissions.py index e2c209a990..4d3eeb64f9 100644 --- a/api/src/backend/api/rbac/permissions.py +++ b/api/src/backend/api/rbac/permissions.py @@ -1,7 +1,7 @@ from enum import Enum from api.db_router import MainRouter -from api.models import Integration, Provider, Role, User +from api.models import Integration, Provider, Role, Task, User from django.db.models import Q, QuerySet from rest_framework.exceptions import PermissionDenied from rest_framework.permissions import BasePermission @@ -17,6 +17,50 @@ class Permissions(Enum): UNLIMITED_VISIBILITY = "unlimited_visibility" +# Revoking a task needs the permission of the operation that queued it. +# None and unmapped names are not revocable; a revoked provider deletion +# would leave the provider soft-deleted with nothing re-queuing the cleanup. +TASK_REVOKE_PERMISSIONS: dict[str, list[Permissions] | None] = { + "provider-connection-check": [Permissions.MANAGE_PROVIDERS], + "provider-deletion": None, + "integration-connection-check": [Permissions.MANAGE_INTEGRATIONS], + "integration-s3": [Permissions.MANAGE_INTEGRATIONS], + "integration-security-hub": [Permissions.MANAGE_INTEGRATIONS], + "integration-jira": [Permissions.MANAGE_INTEGRATIONS], + "scan-perform": [Permissions.MANAGE_SCANS], + "scan-perform-scheduled": [Permissions.MANAGE_SCANS], + "scan-compliance-overviews": [Permissions.MANAGE_SCANS], + "scan-compliance-reports": [Permissions.MANAGE_SCANS], + "scan-finding-group-summaries": [Permissions.MANAGE_SCANS], + "scan-report": [Permissions.MANAGE_SCANS], + "attack-paths-scan-perform": [Permissions.MANAGE_SCANS], + "findings-mute-latest-scans": [Permissions.MANAGE_SCANS], + "lighthouse-connection-check": [], + "lighthouse-provider-connection-check": [], + "lighthouse-provider-models-refresh": [], +} + + +def get_user_roles(user: User, tenant_id: str) -> list[Role]: + """Return every role assigned to the user in the tenant.""" + return list( + User.objects.using(MainRouter.admin_db) + .get(id=user.id) + .roles.using(MainRouter.admin_db) + .filter(tenant_id=tenant_id) + ) + + +def roles_have_permissions( + roles: list[Role], required_permissions: list[Permissions] +) -> bool: + """Return True when every required permission is granted by at least one role.""" + return all( + any(getattr(role, permission.value, False) for role in roles) + for permission in required_permissions + ) + + class HasPermissions(BasePermission): """ Custom permission to check if the user's role has the required permissions. @@ -34,19 +78,11 @@ class HasPermissions(BasePermission): if not tenant_id: return False - user_roles = list( - User.objects.using(MainRouter.admin_db) - .get(id=request.user.id) - .roles.using(MainRouter.admin_db) - .filter(tenant_id=tenant_id) - ) + user_roles = get_user_roles(request.user, tenant_id) if not user_roles: return False - return all( - any(getattr(role, permission.value, False) for role in user_roles) - for permission in required_permissions - ) + return roles_have_permissions(user_roles, required_permissions) def get_role(user: User, tenant_id: str) -> Role: @@ -85,6 +121,25 @@ def get_providers(role: Role) -> QuerySet[Provider]: ).distinct() +def get_tasks(role: Role) -> QuerySet[Task]: + """Return the tasks visible to the role: tenant-wide ones and those of its providers.""" + queryset = Task.objects.filter(tenant_id=role.tenant_id) + if role.unlimited_visibility: + return queryset + + # Task has no provider FK, so match provider ids inside the stored kwargs. + # all_objects keeps a soft-deleted provider visible to its own groups, so the + # role that queued its deletion can still follow the task. + hidden = Q() + for provider_id in ( + Provider.all_objects.filter(tenant_id=role.tenant_id) + .exclude(provider_groups__in=role.provider_groups.all()) + .values_list("id", flat=True) + ): + hidden |= Q(task_runner_task__task_kwargs__contains=str(provider_id)) + return queryset.exclude(hidden) if hidden else queryset + + def get_integrations( role: Role, providers: QuerySet[Provider] | None = None ) -> QuerySet[Integration]: diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index dce066dbed..220afcb7d8 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.40.0 + version: 1.46.0 description: |- Prowler API specification. @@ -14823,7 +14823,9 @@ paths: get: operationId: api_v1_tasks_list description: Retrieve a list of all tasks with options for filtering by name, - state, and other criteria. + state, and other criteria. Tasks that reference a provider are only returned + when the role can access it; tasks without a provider reference are returned + for every role. summary: List all tasks parameters: - in: query @@ -14922,7 +14924,8 @@ paths: /api/v1/tasks/{id}: get: operationId: api_v1_tasks_retrieve - description: Fetch detailed information about a specific task by its ID. + description: Fetch detailed information about a specific task by its ID. Tasks + tied to a provider outside the visibility of the role are not found. summary: Retrieve data from a specific task parameters: - in: query @@ -14963,7 +14966,9 @@ paths: delete: operationId: api_v1_tasks_destroy description: Try to revoke a task using its ID. Only tasks that are not yet - in progress can be revoked. + in progress can be revoked, and the caller needs the same permission as the + operation that queued the task (for example MANAGE_SCANS for a scan). Provider + deletions cannot be revoked. summary: Revoke a task parameters: - in: path diff --git a/api/src/backend/api/tests/integration/test_authentication.py b/api/src/backend/api/tests/integration/test_authentication.py index 926ba3a133..8e9040e117 100644 --- a/api/src/backend/api/tests/integration/test_authentication.py +++ b/api/src/backend/api/tests/integration/test_authentication.py @@ -1,9 +1,9 @@ import json -import time from datetime import UTC, datetime, timedelta from uuid import uuid4 import pytest +from api.authentication import API_KEY_LAST_USED_AT_THROTTLE_SECONDS from api.db_router import MainRouter from api.models import Membership, Role, TenantAPIKey, User, UserRoleRelationship from api.signals import revoke_membership_api_keys, revoke_user_api_keys @@ -11,6 +11,7 @@ from conftest import TEST_PASSWORD, get_api_tokens, get_authorization_header from django.db.utils import ConnectionDoesNotExist from django.urls import reverse from drf_simple_apikey.crypto import get_crypto +from freezegun import freeze_time from rest_framework.test import APIClient from rest_framework_simplejwt.token_blacklist.models import ( BlacklistedToken, @@ -527,7 +528,7 @@ class TestAPIKeyAuthentication: def test_last_used_at_tracking( self, create_test_user, tenants_fixture, api_keys_fixture ): - """Verify last_used_at timestamp updates on each authentication.""" + """Verify last_used_at timestamp is set on first use and throttled after that.""" client = APIClient() api_key = api_keys_fixture[0] @@ -536,7 +537,11 @@ class TestAPIKeyAuthentication: # Use API key to authenticate api_key_headers = get_api_key_header(api_key._raw_key) - first_response = client.get(reverse("provider-list"), headers=api_key_headers) + start = datetime.now(UTC) + with freeze_time(start): + first_response = client.get( + reverse("provider-list"), headers=api_key_headers + ) assert first_response.status_code == 200 # Reload from database and check last_used_at is set @@ -544,17 +549,23 @@ class TestAPIKeyAuthentication: first_used_at = api_key.last_used_at assert first_used_at is not None - # Use the same key again after a small delay - time.sleep(0.1) - + # Using the same key again within the throttle interval does not rewrite it second_response = client.get(reverse("provider-list"), headers=api_key_headers) assert second_response.status_code == 200 - # Reload and verify last_used_at was updated api_key.refresh_from_db() - second_used_at = api_key.last_used_at - assert second_used_at is not None - assert second_used_at > first_used_at + assert api_key.last_used_at == first_used_at + + # Past the throttle interval, the next use refreshes it + later = start + timedelta(seconds=API_KEY_LAST_USED_AT_THROTTLE_SECONDS + 1) + with freeze_time(later): + third_response = client.get( + reverse("provider-list"), headers=api_key_headers + ) + assert third_response.status_code == 200 + + api_key.refresh_from_db() + assert api_key.last_used_at > first_used_at @pytest.mark.django_db @@ -1441,6 +1452,7 @@ class TestAPIKeyRLSBypass: The update to last_used_at during authentication must also use the admin database since it occurs before RLS context is established. + Past the throttle interval, using the key again refreshes the timestamp. """ client = APIClient() api_key = api_keys_fixture[0] @@ -1448,7 +1460,11 @@ class TestAPIKeyRLSBypass: assert api_key.last_used_at is None api_key_headers = get_api_key_header(api_key._raw_key) - first_response = client.get(reverse("provider-list"), headers=api_key_headers) + start = datetime.now(UTC) + with freeze_time(start): + first_response = client.get( + reverse("provider-list"), headers=api_key_headers + ) assert first_response.status_code == 200 @@ -1456,9 +1472,11 @@ class TestAPIKeyRLSBypass: first_timestamp = api_key.last_used_at assert first_timestamp is not None - time.sleep(0.1) - - second_response = client.get(reverse("provider-list"), headers=api_key_headers) + later = start + timedelta(seconds=API_KEY_LAST_USED_AT_THROTTLE_SECONDS + 1) + with freeze_time(later): + second_response = client.get( + reverse("provider-list"), headers=api_key_headers + ) assert second_response.status_code == 200 api_key.refresh_from_db() diff --git a/api/src/backend/api/tests/test_adapters.py b/api/src/backend/api/tests/test_adapters.py index 7d8e06bb0e..3fd14afbf7 100644 --- a/api/src/backend/api/tests/test_adapters.py +++ b/api/src/backend/api/tests/test_adapters.py @@ -111,6 +111,110 @@ def _verify_local_email(user): ) +def test_social_account_name_falls_back_to_login_for_blank_name(): + adapter = ProwlerSocialAccountAdapter() + + name = adapter._get_social_account_name( + {"name": " ", "login": "octocat"}, + "verified@example.com", + ) + + assert name == "octocat" + + +@pytest.mark.parametrize("provider_name", [None, "", " ", 123, ["name"]]) +def test_social_account_name_ignores_unusable_provider_names(provider_name): + adapter = ProwlerSocialAccountAdapter() + + name = adapter._get_social_account_name( + {"name": provider_name, "login": "octocat"}, + "verified@example.com", + ) + + assert name == "octocat" + + +def test_social_account_name_uses_login_when_name_is_missing(): + adapter = ProwlerSocialAccountAdapter() + + name = adapter._get_social_account_name( + {"login": "octocat"}, + "verified@example.com", + ) + + assert name == "octocat" + + +def test_social_account_name_falls_back_to_username_then_email(): + adapter = ProwlerSocialAccountAdapter() + + username_name = adapter._get_social_account_name( + {"name": "ab", "login": None, "username": " monalisa "}, + "verified@example.com", + ) + email_name = adapter._get_social_account_name({}, " verified@example.com ") + + assert username_name == "monalisa" + assert email_name == "verified@example.com" + + +def test_social_account_name_trims_and_limits_provider_name(): + adapter = ProwlerSocialAccountAdapter() + max_length = User._meta.get_field("name").max_length + + trimmed_name = adapter._get_social_account_name( + {"name": " Ada Lovelace "}, + "verified@example.com", + ) + limited_name = adapter._get_social_account_name( + {"name": "a" * (max_length + 1)}, + "verified@example.com", + ) + + assert trimmed_name == "Ada Lovelace" + assert limited_name == "a" * max_length + + +def test_social_account_name_rejects_missing_identity(): + adapter = ProwlerSocialAccountAdapter() + + with pytest.raises( + ValueError, + match="Social account does not provide a valid user identity", + ): + adapter._get_social_account_name({}, "") + + +def test_save_user_applies_normalized_social_account_name(rf): + adapter = ProwlerSocialAccountAdapter() + request = rf.post("/") + request.session = {} + sociallogin = MagicMock(spec=SocialLogin) + sociallogin.provider = MagicMock() + sociallogin.provider.id = "github" + sociallogin.account = MagicMock() + sociallogin.account.extra_data = {"name": None, "login": " octocat "} + user = User(email="verified@example.com") + user.save = MagicMock() + invitation = SimpleNamespace(tenant_id="tenant-id") + + with ( + patch("api.adapters.super") as mock_super, + patch("api.adapters.transaction.atomic"), + patch("api.adapters.write_db_alias"), + patch.object(adapter, "_get_invitation_token", return_value="token"), + patch( + "api.adapters.accept_invitation_for_user", + return_value=(invitation, True), + ), + ): + mock_super.return_value.save_user.return_value = user + saved_user = adapter.save_user(request, sociallogin) + + assert saved_user.name == "octocat" + assert request.prowler_invitation_token == "token" + + @pytest.mark.django_db class TestProwlerSocialAccountAdapter: def test_get_user_by_email_returns_user(self, create_test_user): diff --git a/api/src/backend/api/tests/test_attack_paths_database.py b/api/src/backend/api/tests/test_attack_paths_database.py index c4aca45928..a563667852 100644 --- a/api/src/backend/api/tests/test_attack_paths_database.py +++ b/api/src/backend/api/tests/test_attack_paths_database.py @@ -187,6 +187,27 @@ class TestRoutingByDatabasePrefix: sink_backend_stub.drop_database.assert_called_once_with("db-tenant-abc") mock_ingest.drop_database.assert_not_called() + def test_list_databases_always_routes_to_ingest(self, sink_backend_stub): + with patch("api.attack_paths.database.ingest") as mock_ingest: + mock_ingest.list_databases.return_value = ["db-tmp-scan-uuid-1"] + + assert db_module.list_databases() == ["db-tmp-scan-uuid-1"] + + mock_ingest.list_databases.assert_called_once_with() + + def test_ingest_list_databases_dedupes_cluster_rows(self): + from api.attack_paths.ingest import driver as ingest_driver + + with patch.object(ingest_driver, "get_session") as mock_get_session: + session = mock_get_session.return_value.__enter__.return_value + session.run.return_value = [{"name": "db-tmp-scan-uuid-1"}] + + assert ingest_driver.list_databases() == ["db-tmp-scan-uuid-1"] + + session.run.assert_called_once_with( + "SHOW DATABASES YIELD name RETURN DISTINCT name" + ) + def test_clear_cache_routes_temp_to_ingest(self, sink_backend_stub): with patch("api.attack_paths.database.ingest") as mock_ingest: db_module.clear_cache("db-tmp-scan-uuid-1") diff --git a/api/src/backend/api/tests/test_authentication.py b/api/src/backend/api/tests/test_authentication.py index 782bfe670e..b01b709146 100644 --- a/api/src/backend/api/tests/test_authentication.py +++ b/api/src/backend/api/tests/test_authentication.py @@ -5,16 +5,18 @@ from uuid import uuid4 import pytest from api.authentication import ( + API_KEY_LAST_USED_AT_THROTTLE_SECONDS, OrphanedAPIKeyError, SSEAuthentication, TenantAPIKeyAuthentication, ) from api.db_router import MainRouter -from api.models import TenantAPIKey +from api.models import TenantAPIKey, User from django.db import connections from django.db.models.query import QuerySet from django.test import RequestFactory from django.test.utils import CaptureQueriesContext +from freezegun import freeze_time from rest_framework.exceptions import AuthenticationFailed @@ -286,14 +288,15 @@ class TestTenantAPIKeyAuthentication: assert str(exc_info.value.detail) == "This API Key has been revoked." - def test_authenticate_reads_the_api_key_once_under_a_row_lock( + def test_authenticate_reads_the_api_key_once_without_a_row_lock( self, auth_backend, api_keys_fixture, request_factory ): - """Test the API key is read a single time and the row is locked. + """Test the API key is read a single time and no row is locked. Validation, the `last_used_at` update and the claims must all come from the - same authoritative row: a second, unlocked lookup would reopen the window - where a key revoked in between still authenticates. + same authoritative row: a second lookup would reopen the window where a key + revoked in between still authenticates. `SELECT ... FOR UPDATE` serialized + every request for a hot key onto one locked row and is not used any more. """ api_key = api_keys_fixture[0] @@ -310,33 +313,40 @@ class TestTenantAPIKeyAuthentication: ] assert len(api_key_selects) == 1 - assert "FOR UPDATE" in api_key_selects[0] + assert "FOR UPDATE" not in api_key_selects[0] - def test_authenticate_ignores_revocation_after_the_locked_read( + def test_authenticate_ignores_revocation_after_the_single_read( self, auth_backend, api_keys_fixture, request_factory ): """Test the claims describe the row that was validated, not a later state. Regression test: the key used to be looked up again to build the auth dict, without rechecking `revoked` or `entity`. A key revoked or orphaned between - both reads still authenticated, and the claims came from that stale row. With - a single locked read the write below cannot land mid-authentication, and the - revocation only takes effect on the next request. + both reads still authenticated, and the claims came from that stale row. + There is now only a single read, so this race is closed by construction and + the revocation only takes effect on the next request. """ api_key = api_keys_fixture[0] entity_at_validation = api_key.entity - original_save = TenantAPIKey.save + original_authenticate_credentials = ( + TenantAPIKeyAuthentication._authenticate_credentials + ) - def revoke_and_orphan_before_saving(instance, *args, **kwargs): - # Runs after validation, right before the claims are built: the exact - # window a concurrent revocation or user deletion used to slip into + def revoke_and_orphan_after_reading(self, request, key): + # Runs right after the single read `authenticate` will use to build the + # claims: the exact window a concurrent revocation used to slip into + result = original_authenticate_credentials(self, request, key) TenantAPIKey.objects.filter(id=api_key.id).update(revoked=True, entity=None) - return original_save(instance, *args, **kwargs) + return result request = request_factory.get("/") request.META["HTTP_AUTHORIZATION"] = f"Api-Key {api_key._raw_key}" - with patch.object(TenantAPIKey, "save", revoke_and_orphan_before_saving): + with patch.object( + TenantAPIKeyAuthentication, + "_authenticate_credentials", + revoke_and_orphan_after_reading, + ): entity, auth_dict = auth_backend.authenticate(request) assert entity == entity_at_validation @@ -350,6 +360,43 @@ class TestTenantAPIKeyAuthentication: assert str(exc_info.value.detail) == "This API Key has been revoked." + def test_authenticate_survives_owner_deleted_after_the_single_read( + self, auth_backend, api_keys_fixture, request_factory + ): + """Test a user deleted right after the read does not turn into a 500. + + Without the row lock a concurrent user deletion can land between the read + and building the claims. `entity` is loaded by the same query, so no later + lookup can raise `DoesNotExist`. + """ + api_key = api_keys_fixture[0] + owner_id = api_key.entity_id + original_authenticate_credentials = ( + TenantAPIKeyAuthentication._authenticate_credentials + ) + + def delete_owner_after_reading(self, request, key): + result = original_authenticate_credentials(self, request, key) + User.objects.using(MainRouter.admin_db).filter(id=owner_id).delete() + return result + + request = request_factory.get("/") + request.META["HTTP_AUTHORIZATION"] = f"Api-Key {api_key._raw_key}" + + with patch.object( + TenantAPIKeyAuthentication, + "_authenticate_credentials", + delete_owner_after_reading, + ): + entity, auth_dict = auth_backend.authenticate(request) + + assert auth_dict["sub"] == str(owner_id) + assert entity.id == owner_id + + # From the next request on, the orphaned key is rejected with a 401 + with pytest.raises(AuthenticationFailed): + auth_backend.authenticate(request) + def test_authenticate_expired_api_key( self, auth_backend, create_test_user, tenants_fixture, request_factory ): @@ -421,24 +468,90 @@ class TestTenantAPIKeyAuthentication: if original_last_used: assert api_key.last_used_at > original_last_used - def test_authenticate_saves_to_admin_database( + def test_authenticate_updates_last_used_at_on_admin_database( self, auth_backend, api_keys_fixture, request_factory ): - """Test that the API key save operation uses admin database.""" + """Test that the `last_used_at` update runs against the admin database.""" api_key = api_keys_fixture[0] raw_key = api_key._raw_key request = request_factory.get("/") request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}" - # Mock the save method to verify it's called with using='admin' - with patch.object(TenantAPIKey, "save") as mock_save: + with CaptureQueriesContext(connections[MainRouter.admin_db]) as captured: auth_backend.authenticate(request) - # Verify save was called with using=admin_db - mock_save.assert_called_once_with( - update_fields=["last_used_at"], using=MainRouter.admin_db - ) + api_key_updates = [ + query["sql"] + for query in captured.captured_queries + if query["sql"].startswith("UPDATE") and '"api_keys"' in query["sql"] + ] + + assert len(api_key_updates) == 1 + assert "last_used_at" in api_key_updates[0] + + def test_authenticate_does_not_rewrite_last_used_at_within_throttle_interval( + self, auth_backend, api_keys_fixture, request_factory + ): + """Test that a second authentication within the throttle interval is a no-op write.""" + api_key = api_keys_fixture[0] + raw_key = api_key._raw_key + + request = request_factory.get("/") + request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}" + + # First call sets last_used_at + auth_backend.authenticate(request) + api_key.refresh_from_db() + first_used_at = api_key.last_used_at + assert first_used_at is not None + + # Second call, still within the throttle interval, must issue no UPDATE + with CaptureQueriesContext(connections[MainRouter.admin_db]) as captured: + auth_backend.authenticate(request) + + api_key_updates = [ + query["sql"] + for query in captured.captured_queries + if query["sql"].startswith("UPDATE") and '"api_keys"' in query["sql"] + ] + assert api_key_updates == [] + + api_key.refresh_from_db() + assert api_key.last_used_at == first_used_at + + def test_authenticate_rewrites_last_used_at_after_throttle_interval( + self, auth_backend, api_keys_fixture, request_factory + ): + """Test that `last_used_at` is refreshed once it is older than the throttle interval.""" + api_key = api_keys_fixture[0] + raw_key = api_key._raw_key + + request = request_factory.get("/") + request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}" + + start = datetime.now(UTC) + with freeze_time(start): + auth_backend.authenticate(request) + + api_key.refresh_from_db() + first_used_at = api_key.last_used_at + assert first_used_at is not None + + later = start + timedelta(seconds=API_KEY_LAST_USED_AT_THROTTLE_SECONDS + 1) + with freeze_time(later): + with CaptureQueriesContext(connections[MainRouter.admin_db]) as captured: + auth_backend.authenticate(request) + + api_key_updates = [ + query["sql"] + for query in captured.captured_queries + if query["sql"].startswith("UPDATE") and '"api_keys"' in query["sql"] + ] + assert len(api_key_updates) == 1 + + api_key.refresh_from_db() + assert api_key.last_used_at > first_used_at def test_authenticate_returns_correct_auth_dict( self, auth_backend, api_keys_fixture, request_factory diff --git a/api/src/backend/api/tests/test_models.py b/api/src/backend/api/tests/test_models.py index 5095da3a0e..1619fc6980 100644 --- a/api/src/backend/api/tests/test_models.py +++ b/api/src/backend/api/tests/test_models.py @@ -1,14 +1,16 @@ -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta import pytest from allauth.socialaccount.models import SocialApp from api.db_router import MainRouter from api.models import ( + Provider, ProviderComplianceScore, Resource, ResourceTag, SAMLConfiguration, SAMLDomainIndex, + Scan, StateChoices, StatusChoices, TenantComplianceSummary, @@ -524,3 +526,226 @@ class TestTenantComplianceSummaryModel: assert summary1.id != summary2.id assert summary1.requirements_passed != summary2.requirements_passed + + +def _latest_scan_fixture(tenant, provider, *, completed_at, inserted_at=None, **kwargs): + scan = Scan.objects.create( + tenant_id=tenant.id, + provider=provider, + trigger=kwargs.pop("trigger", Scan.TriggerChoices.MANUAL), + state=kwargs.pop("state", StateChoices.COMPLETED), + completed_at=completed_at, + **kwargs, + ) + if inserted_at is not None: + # `inserted_at` is auto_now_add, so it has to be forced after the fact. + Scan.all_objects.filter(pk=scan.pk).update(inserted_at=inserted_at) + scan.refresh_from_db() + return scan + + +@pytest.mark.django_db +class TestScanQuerySetOrdering: + def test_completed_later_wins_over_inserted_later( + self, tenants_fixture, aws_provider + ): + """The scan that FINISHED last is current, not the one that started last.""" + tenant, *_ = tenants_fixture + now = datetime.now(UTC) + + finished_last = _latest_scan_fixture( + tenant, + aws_provider, + inserted_at=now - timedelta(hours=3), + completed_at=now, + ) + _latest_scan_fixture( + tenant, + aws_provider, + inserted_at=now - timedelta(hours=1), + completed_at=now - timedelta(hours=1), + ) + + assert Scan.all_objects.filter( + tenant_id=tenant.id + ).latest_ids_per_provider() == [finished_last.id] + + def test_null_completed_at_provider_is_still_returned( + self, tenants_fixture, aws_provider + ): + """NULLS LAST, not `completed_at__isnull=False`. + + Excluding NULL `completed_at` would drop the provider from every + "latest" endpoint instead of falling back to `inserted_at`. + """ + tenant, *_ = tenants_fixture + only_scan = _latest_scan_fixture(tenant, aws_provider, completed_at=None) + + assert Scan.all_objects.filter( + tenant_id=tenant.id + ).latest_ids_per_provider() == [only_scan.id] + + def test_null_completed_at_never_outranks_a_finished_scan( + self, tenants_fixture, aws_provider + ): + """Postgres sorts NULLs first under DESC; NULLS LAST is what fixes it.""" + tenant, *_ = tenants_fixture + now = datetime.now(UTC) + + finished = _latest_scan_fixture( + tenant, + aws_provider, + inserted_at=now - timedelta(hours=2), + completed_at=now - timedelta(hours=2), + ) + _latest_scan_fixture( + tenant, + aws_provider, + inserted_at=now, + completed_at=None, + ) + + assert Scan.all_objects.filter( + tenant_id=tenant.id + ).latest_ids_per_provider() == [finished.id] + + def test_id_breaks_an_exact_timestamp_tie_deterministically( + self, tenants_fixture, aws_provider + ): + tenant, *_ = tenants_fixture + now = datetime.now(UTC) + + scans = [ + _latest_scan_fixture( + tenant, aws_provider, inserted_at=now, completed_at=now + ) + for _ in range(3) + ] + expected = max(scan.id for scan in scans) + + picks = { + Scan.all_objects.filter(tenant_id=tenant.id).latest_ids_per_provider()[0] + for _ in range(5) + } + assert picks == {expected} + + +@pytest.mark.django_db +class TestScanQuerySetEligibility: + def test_unfinished_scans_are_excluded(self, tenants_fixture, aws_provider): + tenant, *_ = tenants_fixture + _latest_scan_fixture( + tenant, + aws_provider, + completed_at=None, + state=StateChoices.EXECUTING, + ) + assert ( + Scan.all_objects.filter(tenant_id=tenant.id).latest_ids_per_provider() == [] + ) + + +@pytest.mark.django_db +class TestScanQuerySetManagerChoice: + def test_active_manager_hides_soft_deleted_providers( + self, tenants_fixture, aws_provider + ): + """`Scan.objects` drops soft-deleted providers, `all_objects` keeps them. + + The queryset must not decide this for the caller. + """ + tenant, *_ = tenants_fixture + scan = _latest_scan_fixture( + tenant, aws_provider, completed_at=datetime.now(UTC) + ) + + Provider.all_objects.filter(pk=aws_provider.pk).update(is_deleted=True) + + assert Scan.all_objects.filter( + tenant_id=tenant.id + ).latest_ids_per_provider() == [scan.id] + assert Scan.objects.filter(tenant_id=tenant.id).latest_ids_per_provider() == [] + + +@pytest.mark.django_db +class TestScanQuerySetPerProviderScoping: + def test_one_scan_per_provider(self, tenants_fixture, aws_provider_pair): + tenant, *_ = tenants_fixture + provider_one, provider_two = aws_provider_pair + now = datetime.now(UTC) + + newest_one = _latest_scan_fixture(tenant, provider_one, completed_at=now) + _latest_scan_fixture(tenant, provider_one, completed_at=now - timedelta(days=1)) + newest_two = _latest_scan_fixture(tenant, provider_two, completed_at=now) + + assert set( + Scan.all_objects.filter(tenant_id=tenant.id).latest_ids_per_provider() + ) == {newest_one.id, newest_two.id} + + def test_caller_filters_are_preserved(self, tenants_fixture, aws_provider_pair): + tenant, *_ = tenants_fixture + provider_one, provider_two = aws_provider_pair + now = datetime.now(UTC) + + scan_one = _latest_scan_fixture(tenant, provider_one, completed_at=now) + _latest_scan_fixture(tenant, provider_two, completed_at=now) + + assert Scan.all_objects.filter( + tenant_id=tenant.id, provider__in=[provider_one] + ).latest_ids_per_provider() == [scan_one.id] + + def test_latest_first_is_ordered_not_deduplicated( + self, tenants_fixture, aws_provider + ): + tenant, *_ = tenants_fixture + now = datetime.now(UTC) + + newest = _latest_scan_fixture(tenant, aws_provider, completed_at=now) + older = _latest_scan_fixture( + tenant, aws_provider, completed_at=now - timedelta(days=1) + ) + + ordered = list( + Scan.all_objects.filter( + tenant_id=tenant.id, provider_id=aws_provider.id + ).latest_first() + ) + assert [scan.id for scan in ordered] == [newest.id, older.id] + + def test_tenant_isolation(self, tenants_fixture, aws_provider): + tenant, other_tenant, *_ = tenants_fixture + _latest_scan_fixture(tenant, aws_provider, completed_at=datetime.now(UTC)) + + assert ( + Scan.all_objects.filter(tenant_id=other_tenant.id).latest_ids_per_provider() + == [] + ) + + def test_empty_queryset_returns_empty_list(self, tenants_fixture): + tenant, *_ = tenants_fixture + assert ( + Scan.all_objects.filter(tenant_id=tenant.id).latest_ids_per_provider() == [] + ) + + +@pytest.mark.django_db +class TestScanQuerySetPerProviderQuerysetShape: + def test_returns_a_queryset_not_a_list(self, tenants_fixture, aws_provider): + tenant, *_ = tenants_fixture + _latest_scan_fixture(tenant, aws_provider, completed_at=datetime.now(UTC)) + + qs = Scan.all_objects.filter(tenant_id=tenant.id).latest_per_provider() + # Callers chain .values(...) / .values_list(...) onto this. + assert qs.values_list("provider_id", flat=True).count() == 1 + + +@pytest.mark.django_db +class TestScanQuerySetRelatedManager: + def test_reverse_relation_exposes_the_methods(self, tenants_fixture, aws_provider): + tenant, *_ = tenants_fixture + now = datetime.now(UTC) + + newest = _latest_scan_fixture(tenant, aws_provider, completed_at=now) + _latest_scan_fixture(tenant, aws_provider, completed_at=now - timedelta(days=1)) + + assert aws_provider.scans.latest_first().first().id == newest.id diff --git a/api/src/backend/api/tests/test_partitions.py b/api/src/backend/api/tests/test_partitions.py new file mode 100644 index 0000000000..d8e0edbb9e --- /dev/null +++ b/api/src/backend/api/tests/test_partitions.py @@ -0,0 +1,63 @@ +from datetime import UTC, datetime +from itertools import islice + +import pytest +from api.partitions import ( + PostgresUUIDv7PartitioningStrategy, + relative_months_or_none, +) +from dateutil.relativedelta import relativedelta +from django.core.exceptions import ImproperlyConfigured +from psqlextra.partitioning import PostgresTimePartitionSize + + +def build_strategy(max_age): + return PostgresUUIDv7PartitioningStrategy( + size=PostgresTimePartitionSize(months=1), + count=1, + start_date=datetime.now(UTC), + max_age=max_age, + name_format="%Y_%b", + ) + + +class TestRelativeMonthsOrNone: + @pytest.mark.parametrize("value", [None, 0]) + def test_unset_or_zero_keeps_partitions_indefinitely(self, value): + assert relative_months_or_none(value) is None + + @pytest.mark.parametrize("months", [1, 3, 12]) + def test_value_is_interpreted_as_months(self, months): + assert relative_months_or_none(months) == relativedelta(months=months) + + def test_value_is_not_interpreted_as_days(self): + assert relative_months_or_none(12) != relativedelta(days=12) + + def test_negative_is_rejected(self): + with pytest.raises(ImproperlyConfigured): + relative_months_or_none(-12) + + +class TestToDelete: + @pytest.mark.parametrize("max_age", [None, relative_months_or_none(0)]) + def test_nothing_is_deleted_without_max_age(self, max_age): + strategy = build_strategy(max_age) + + assert list(islice(strategy.to_delete(), 5)) == [] + + def test_first_deleted_partition_is_max_age_old(self): + months = 3 + strategy = build_strategy(relative_months_or_none(months)) + + first = next(strategy.to_delete()) + + expected = strategy.get_start_datetime() - relativedelta(months=months) + assert first.name() == expected.strftime("%Y_%b").lower() + + def test_deleted_partitions_go_further_back_in_time(self): + strategy = build_strategy(relative_months_or_none(3)) + + names = [p.name() for p in islice(strategy.to_delete(), 3)] + starts = [datetime.strptime(n, "%Y_%b") for n in names] + + assert starts == sorted(starts, reverse=True) diff --git a/api/src/backend/api/tests/test_sentry.py b/api/src/backend/api/tests/test_sentry.py index 14308f0cb6..c83861a2e4 100644 --- a/api/src/backend/api/tests/test_sentry.py +++ b/api/src/backend/api/tests/test_sentry.py @@ -30,6 +30,21 @@ def test_initialize_sentry_uses_configured_dsn(): assert mock_init.call_args.kwargs["before_send"] is sentry_settings.before_send +def test_initialize_sentry_sends_no_personal_data(): + with ( + patch.object( + sentry_settings.env, + "str", + return_value="https://fake-public-key@sentry.example.invalid/1", + ), + patch.object(sentry_settings.sentry_sdk, "init") as mock_init, + ): + sentry_settings.initialize_sentry() + + assert mock_init.call_args.kwargs["send_default_pii"] is False + assert mock_init.call_args.kwargs["max_request_body_size"] == "never" + + def _make_log_record(msg, level=logging.ERROR, name="test", args=None): """Build a real LogRecord so getMessage() works like in production.""" record = logging.LogRecord( diff --git a/api/src/backend/api/tests/test_serializers.py b/api/src/backend/api/tests/test_serializers.py index 78a3e14c4f..903fb05909 100644 --- a/api/src/backend/api/tests/test_serializers.py +++ b/api/src/backend/api/tests/test_serializers.py @@ -5,6 +5,8 @@ from api.v1.serializer_utils.integrations import ( ) from api.v1.serializer_utils.providers import ProviderSecretField from api.v1.serializers import ( + KUBERNETES_KUBECONFIG_INVALID_ERROR, + KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR, ImageProviderSecret, IntegrationSerializer, IntegrationUpdateSerializer, @@ -215,36 +217,34 @@ class TestOracleCloudProviderSecret: assert serializer.is_valid(), serializer.errors assert "region" not in serializer.validated_data - def test_accepts_and_ignores_region_field(self): - secret = self.valid_secret(region="us-phoenix-1") - serializer = OracleCloudProviderSecret(data=secret) - - assert serializer.is_valid(), serializer.errors - - assert "region" not in serializer.validated_data - - @pytest.mark.parametrize( - "legacy_field, legacy_value", - [ - ("region", None), - ("region", ""), - ("region", {"name": "us-ashburn-1"}), - ], - ) - def test_accepts_and_ignores_any_legacy_region_value( - self, legacy_field, legacy_value - ): + def test_keeps_region_as_home_region(self): serializer = OracleCloudProviderSecret( - data=self.valid_secret(**{legacy_field: legacy_value}) + data=self.valid_secret(region=" me-abudhabi-1 ") ) assert serializer.is_valid(), serializer.errors + assert serializer.validated_data["region"] == "me-abudhabi-1" - assert legacy_field not in serializer.validated_data + def test_rejects_unknown_region(self): + serializer = OracleCloudProviderSecret( + data=self.valid_secret(region="mars-north-1") + ) + + assert not serializer.is_valid() + assert "region" in serializer.errors + + @pytest.mark.parametrize("legacy_value", [None, "", {"name": "us-ashburn-1"}]) + def test_drops_blank_or_non_string_region(self, legacy_value): + serializer = OracleCloudProviderSecret( + data=self.valid_secret(region=legacy_value) + ) + + assert serializer.is_valid(), serializer.errors + assert "region" not in serializer.validated_data class TestProviderSecretFieldSchema: - def test_oraclecloud_schema_includes_legacy_region_field(self): + def test_oraclecloud_schema_region_is_not_deprecated(self): schema = ProviderSecretField._spectacular_annotation["field"] oraclecloud_schema = next( credential_schema @@ -253,7 +253,7 @@ class TestProviderSecretFieldSchema: == "Oracle Cloud Infrastructure (OCI) API Key Credentials" ) - assert oraclecloud_schema["properties"]["region"]["deprecated"] is True + assert "deprecated" not in oraclecloud_schema["properties"]["region"] class TestKubernetesProviderSecret: @@ -342,6 +342,278 @@ current-context: test-context assert not serializer.is_valid() assert "kubeconfig_content" in serializer.errors + @staticmethod + def _kubeconfig(user_extra="", cluster_extra=""): + return f""" +apiVersion: v1 +kind: Config +clusters: + - name: test-cluster + cluster: + server: https://kubernetes.example.test +{cluster_extra} +users: + - name: test-user + user: + token: test-token +{user_extra} +contexts: + - name: test-context + context: + cluster: test-cluster + user: test-user +current-context: test-context +""" + + @staticmethod + def _assert_rejected_without_echo( + kubeconfig_content, leaked_value, expected_message + ): + serializer = KubernetesProviderSecret( + data={"kubeconfig_content": kubeconfig_content} + ) + + assert serializer.is_valid() is False + assert serializer.errors["kubeconfig_content"] == [expected_message] + assert leaked_value not in str(serializer.errors) + + def test_inline_token_with_certificate_authority_data_is_accepted(self): + kubeconfig_content = self._kubeconfig( + cluster_extra=" certificate-authority-data: dGVzdA==" + ) + + serializer = KubernetesProviderSecret( + data={"kubeconfig_content": kubeconfig_content} + ) + + assert serializer.is_valid() + + def test_kubeconfig_with_token_file_is_rejected(self): + self._assert_rejected_without_echo( + self._kubeconfig(user_extra=" tokenFile: /etc/passwd"), + "/etc/passwd", + KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR, + ) + + def test_kubeconfig_with_client_certificate_file_is_rejected(self): + self._assert_rejected_without_echo( + self._kubeconfig(user_extra=" client-certificate: /etc/passwd"), + "/etc/passwd", + KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR, + ) + + def test_kubeconfig_with_client_key_file_is_rejected(self): + self._assert_rejected_without_echo( + self._kubeconfig(user_extra=" client-key: /etc/passwd"), + "/etc/passwd", + KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR, + ) + + def test_kubeconfig_with_certificate_authority_file_is_rejected(self): + self._assert_rejected_without_echo( + self._kubeconfig(cluster_extra=" certificate-authority: /etc/passwd"), + "/etc/passwd", + KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR, + ) + + def test_kubeconfig_with_cluster_proxy_url_is_rejected(self): + self._assert_rejected_without_echo( + self._kubeconfig(cluster_extra=" proxy-url: http://proxy.evil.test"), + "proxy.evil.test", + KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR, + ) + + @pytest.mark.parametrize("provider_name", ["gcp", "oidc", "azure"]) + def test_kubeconfig_with_any_auth_provider_is_rejected(self, provider_name): + self._assert_rejected_without_echo( + self._kubeconfig( + user_extra=( + " auth-provider:\n" + f" name: {provider_name}\n" + " config:\n" + " idp-issuer-url: https://idp.evil.test" + ) + ), + "idp.evil.test", + KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR, + ) + + def test_kubeconfig_with_unknown_user_key_is_rejected(self): + self._assert_rejected_without_echo( + self._kubeconfig(user_extra=" bogus: /etc/passwd"), + "/etc/passwd", + KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR, + ) + + def test_kubeconfig_with_unknown_cluster_key_is_rejected(self): + self._assert_rejected_without_echo( + self._kubeconfig(cluster_extra=" bogus: /etc/passwd"), + "/etc/passwd", + KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR, + ) + + def test_kubeconfig_with_allowlisted_cluster_keys_is_accepted(self): + serializer = KubernetesProviderSecret( + data={ + "kubeconfig_content": self._kubeconfig( + cluster_extra=( + " certificate-authority-data: dGVzdA==\n" + " insecure-skip-tls-verify: false\n" + " tls-server-name: kubernetes.example.test\n" + " disable-compression: true\n" + " extensions: []" + ) + ) + } + ) + + assert serializer.is_valid(), serializer.errors + + def test_kubeconfig_with_non_list_clusters_is_rejected(self): + kubeconfig_content = """ +apiVersion: v1 +kind: Config +clusters: + name: /etc/passwd +""" + self._assert_rejected_without_echo( + kubeconfig_content, "/etc/passwd", KUBERNETES_KUBECONFIG_INVALID_ERROR + ) + + def test_kubeconfig_with_non_dict_cluster_entry_is_rejected(self): + kubeconfig_content = """ +apiVersion: v1 +kind: Config +clusters: + - /etc/passwd +""" + self._assert_rejected_without_echo( + kubeconfig_content, "/etc/passwd", KUBERNETES_KUBECONFIG_INVALID_ERROR + ) + + def test_kubeconfig_with_non_dict_cluster_value_is_rejected(self): + kubeconfig_content = """ +apiVersion: v1 +kind: Config +clusters: + - name: test-cluster + cluster: /etc/passwd +""" + self._assert_rejected_without_echo( + kubeconfig_content, "/etc/passwd", KUBERNETES_KUBECONFIG_INVALID_ERROR + ) + + @pytest.mark.parametrize( + "user_extra", + [ + " token: test-token", + " username: test-user\n password: test-password", + " client-certificate-data: dGVzdA==\n client-key-data: dGVzdA==", + " token: test-token\n as: other-user", + " token: test-token\n as-uid: 1234", + " token: test-token\n as-groups:\n - group-a", + " token: test-token\n as-user-extra:\n scopes:\n - read", + ], + ids=[ + "token", + "username-password", + "client-cert-data", + "as", + "as-uid", + "as-groups", + "as-user-extra", + ], + ) + def test_kubeconfig_with_allowlisted_user_keys_is_accepted(self, user_extra): + kubeconfig_content = f""" +apiVersion: v1 +kind: Config +clusters: + - name: test-cluster + cluster: + server: https://kubernetes.example.test + certificate-authority-data: dGVzdA== +users: + - name: test-user + user: +{user_extra} +contexts: + - name: test-context + context: + cluster: test-cluster + user: test-user +current-context: test-context +""" + + serializer = KubernetesProviderSecret( + data={"kubeconfig_content": kubeconfig_content} + ) + + assert serializer.is_valid() + + @pytest.mark.parametrize( + "kubeconfig_content", + [ + """ +apiVersion: v1 +kind: Config +clusters: + - name: test-cluster + cluster: + server: https://kubernetes.example.test +users: + - name: good-user + user: + token: test-token + - name: bad-user + user: + tokenFile: /etc/passwd +""", + """ +apiVersion: v1 +kind: Config +clusters: + - name: good-cluster + cluster: + server: https://kubernetes.example.test + - name: bad-cluster + cluster: + server: https://kubernetes.example.test + certificate-authority: /etc/passwd +users: + - name: test-user + user: + token: test-token +""", + ], + ids=["second-user", "second-cluster"], + ) + def test_kubeconfig_with_bad_second_entry_is_rejected(self, kubeconfig_content): + self._assert_rejected_without_echo( + kubeconfig_content, + "/etc/passwd", + KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR, + ) + + def test_kubeconfig_with_indented_document_is_rejected(self): + kubeconfig_content = ( + " wrapper:\n" + " users:\n" + " - name: u\n" + " user:\n" + " tokenFile: /etc/passwd\n" + " clusters:\n" + " - name: c\n" + " cluster:\n" + " server: https://example.test\n" + ) + + self._assert_rejected_without_echo( + kubeconfig_content, + "/etc/passwd", + KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR, + ) + def test_malformed_kubeconfig_is_rejected(self): serializer = KubernetesProviderSecret( data={"kubeconfig_content": "apiVersion: ["} diff --git a/api/src/backend/api/tests/test_utils.py b/api/src/backend/api/tests/test_utils.py index 4b5e8e1694..3ef59ea4a5 100644 --- a/api/src/backend/api/tests/test_utils.py +++ b/api/src/backend/api/tests/test_utils.py @@ -14,6 +14,7 @@ from api.utils import ( return_prowler_provider, validate_invitation, ) +from api.v1.serializers import KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR from prowler.providers.alibabacloud.alibabacloud_provider import AlibabacloudProvider from prowler.providers.aws.aws_provider import AwsProvider from prowler.providers.aws.lib.security_hub.security_hub import SecurityHubConnection @@ -35,6 +36,34 @@ from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvid from prowler.providers.vercel.vercel_provider import VercelProvider from rest_framework.exceptions import NotFound, ValidationError +VALID_KUBECONFIG = """\ +apiVersion: v1 +kind: Config +clusters: +- name: test-cluster + cluster: + server: https://example.invalid:6443 + certificate-authority-data: Y2E= +users: +- name: test-user + user: + token: test-token +contexts: +- name: test-context + context: + cluster: test-cluster + user: test-user +current-context: test-context +""" + +TOKEN_FILE_KUBECONFIG = VALID_KUBECONFIG.replace( + "token: test-token", "tokenFile: /etc/passwd" +) + +EXEC_KUBECONFIG = VALID_KUBECONFIG.replace( + "token: test-token", "exec:\n command: test-command" +) + class TestMergeDicts: def test_simple_merge(self): @@ -172,7 +201,7 @@ class TestInitializeProwlerProvider: ) @patch("api.utils.return_prowler_provider") - def test_initialize_oraclecloud_provider_removes_region_string( + def test_initialize_oraclecloud_provider_passes_region_as_home_region( self, mock_return_prowler_provider ): provider = MagicMock() @@ -182,7 +211,7 @@ class TestInitializeProwlerProvider: "fingerprint": "00:11:22:33:44:55:66:77", "key_content": "fake-base64-key-content", "tenancy": "ocid1.tenancy.oc1..fake", - "region": "us-ashburn-1", + "region": "me-abudhabi-1", } mock_return_prowler_provider.return_value = MagicMock() @@ -193,6 +222,7 @@ class TestInitializeProwlerProvider: fingerprint="00:11:22:33:44:55:66:77", key_content="fake-base64-key-content", tenancy="ocid1.tenancy.oc1..fake", + home_region="me-abudhabi-1", ) @patch("api.utils.return_prowler_provider") @@ -254,15 +284,73 @@ class TestProwlerProviderConnectionTest: fingerprint="00:11:22:33:44:55:66:77", key_content="fake-base64-key-content", tenancy="ocid1.tenancy.oc1..aaaaaaaexample", - region=getattr( - OraclecloudProvider, - "_bootstrap_region", - OraclecloudProvider._home_region, - ), + region=OraclecloudProvider._bootstrap_region, provider_id="ocid1.tenancy.oc1..aaaaaaaexample", raise_on_exception=False, ) + @patch("api.utils.return_prowler_provider") + def test_oraclecloud_connection_test_uses_stored_region_for_identity( + self, mock_return_prowler_provider + ): + provider = MagicMock() + provider.uid = "ocid1.tenancy.oc1..aaaaaaaexample" + provider.provider = Provider.ProviderChoices.ORACLECLOUD.value + provider.secret.secret = { + "user": "ocid1.user.oc1..aaaaaaaexample", + "fingerprint": "00:11:22:33:44:55:66:77", + "key_content": "fake-base64-key-content", + "tenancy": "ocid1.tenancy.oc1..aaaaaaaexample", + "region": "me-abudhabi-1", + } + mock_return_prowler_provider.return_value = MagicMock() + + prowler_provider_connection_test(provider) + + mock_return_prowler_provider.return_value.test_connection.assert_called_once_with( + user="ocid1.user.oc1..aaaaaaaexample", + fingerprint="00:11:22:33:44:55:66:77", + key_content="fake-base64-key-content", + tenancy="ocid1.tenancy.oc1..aaaaaaaexample", + region="me-abudhabi-1", + provider_id="ocid1.tenancy.oc1..aaaaaaaexample", + raise_on_exception=False, + ) + + @patch("api.utils.return_prowler_provider") + def test_kubernetes_connection_test_rejects_invalid_kubeconfig( + self, mock_return_prowler_provider + ): + provider = MagicMock() + provider.provider = Provider.ProviderChoices.KUBERNETES.value + provider.uid = "provider_uid" + provider.secret.secret = {"kubeconfig_content": TOKEN_FILE_KUBECONFIG} + + connection = prowler_provider_connection_test(provider) + + assert connection.is_connected is False + assert ( + str(connection.error) == KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR + ) + mock_return_prowler_provider.return_value.test_connection.assert_not_called() + + @patch("api.utils.return_prowler_provider") + def test_kubernetes_connection_test_with_valid_kubeconfig( + self, mock_return_prowler_provider + ): + provider = MagicMock() + provider.provider = Provider.ProviderChoices.KUBERNETES.value + provider.uid = "provider_uid" + provider.secret.secret = {"kubeconfig_content": VALID_KUBECONFIG} + + prowler_provider_connection_test(provider) + + mock_return_prowler_provider.return_value.test_connection.assert_called_once_with( + kubeconfig_content=VALID_KUBECONFIG, + provider_id="provider_uid", + raise_on_exception=False, + ) + @pytest.mark.django_db @patch("api.utils.return_prowler_provider") def test_prowler_provider_connection_test_without_secret( @@ -380,10 +468,6 @@ class TestGetProwlerProviderKwargs: Provider.ProviderChoices.GOOGLEWORKSPACE.value, {}, ), - ( - Provider.ProviderChoices.KUBERNETES.value, - {"context": "provider_uid"}, - ), ( Provider.ProviderChoices.M365.value, {}, @@ -434,7 +518,40 @@ class TestGetProwlerProviderKwargs: expected_result = {**secret_dict, **expected_extra_kwargs} assert result == expected_result - def test_get_prowler_provider_kwargs_oraclecloud_removes_region( + def test_get_prowler_provider_kwargs_kubernetes_valid_kubeconfig(self): + provider = MagicMock() + provider.provider = Provider.ProviderChoices.KUBERNETES.value + provider.uid = "provider_uid" + provider.secret.secret = {"kubeconfig_content": VALID_KUBECONFIG} + + assert get_prowler_provider_kwargs(provider) == { + "kubeconfig_content": VALID_KUBECONFIG, + "context": "provider_uid", + } + + @pytest.mark.parametrize( + "secret", + [ + {"kubeconfig_content": TOKEN_FILE_KUBECONFIG}, + {"kubeconfig_content": EXEC_KUBECONFIG}, + {"kubeconfig_content": "[]"}, + {"key": "value"}, + ], + ) + def test_get_prowler_provider_kwargs_kubernetes_rejects_invalid_kubeconfig( + self, secret + ): + provider = MagicMock() + provider.provider = Provider.ProviderChoices.KUBERNETES.value + provider.uid = "provider_uid" + provider.secret.secret = secret + + with pytest.raises(ValidationError) as exc_info: + get_prowler_provider_kwargs(provider) + + assert "/etc/passwd" not in str(exc_info.value) + + def test_get_prowler_provider_kwargs_oraclecloud_maps_region_to_home_region( self, ): secret_dict = { @@ -461,6 +578,7 @@ class TestGetProwlerProviderKwargs: "key_content": "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----", "tenancy": "ocid1.tenancy.oc1..fake", "pass_phrase": "fake-passphrase", + "home_region": "us-ashburn-1", } def test_get_prowler_provider_kwargs_with_mutelist(self): diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 7b153efb8f..2ea4415072 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -60,8 +60,10 @@ from api.models import ( User, UserRoleRelationship, ) +from api.rbac.permissions import TASK_REVOKE_PERMISSIONS from api.rls import Tenant from api.uuid_utils import datetime_to_uuid7 +from api.v1.serializers import MembershipIncludeSerializer from api.v1.views import ( ComplianceOverviewViewSet, CustomSAMLLoginView, @@ -82,7 +84,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 @@ -132,6 +134,145 @@ class TestUserViewSet: assert response.status_code == status.HTTP_200_OK assert response.json()["data"]["attributes"]["email"] == create_test_user.email + @staticmethod + def _shared_user_in_two_tenants(tenant_a, tenant_b): + shared_user = User.objects.create_user( + name="shared_user", + email=f"shared-user-{uuid4()}@prowler.com", + password="TmpPass123@", + ) + Membership.objects.create(user=shared_user, tenant=tenant_a) + Membership.objects.create(user=shared_user, tenant=tenant_b) + role_a = Role.objects.create(name="role-a", tenant=tenant_a) + role_b = Role.objects.create(name="role-b", tenant=tenant_b) + UserRoleRelationship.objects.create( + user=shared_user, role=role_a, tenant=tenant_a + ) + UserRoleRelationship.objects.create( + user=shared_user, role=role_b, tenant=tenant_b + ) + return shared_user, role_a, role_b + + @staticmethod + def _user_in_two_tenants_with_unique_roles(tenant_a, tenant_b): + suffix = uuid4() + user = User.objects.create_user( + name="scaled_user", + email=f"scaled-user-{suffix}@prowler.com", + password="TmpPass123@", + ) + for tenant in (tenant_a, tenant_b): + Membership.objects.create(user=user, tenant=tenant) + role = Role.objects.create(name=f"role-{tenant.id}-{suffix}", tenant=tenant) + UserRoleRelationship.objects.create(user=user, role=role, tenant=tenant) + return user + + def test_users_list_query_count_does_not_grow_with_user_count( + self, authenticated_client, tenants_fixture + ): + tenant_a, tenant_b, _ = tenants_fixture + self._user_in_two_tenants_with_unique_roles(tenant_a, tenant_b) + params = {"include": "memberships,roles"} + + with CaptureQueriesContext(connection) as baseline: + response = authenticated_client.get(reverse("user-list"), params) + assert response.status_code == status.HTTP_200_OK + baseline_users = len(response.json()["data"]) + + for _ in range(3): + self._user_in_two_tenants_with_unique_roles(tenant_a, tenant_b) + + with CaptureQueriesContext(connection) as scaled: + response = authenticated_client.get(reverse("user-list"), params) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) > baseline_users + + assert len(scaled.captured_queries) == len(baseline.captured_queries), ( + "Tenant-scoped relations are re-querying per user: " + f"{len(baseline.captured_queries)} queries for {baseline_users} users " + f"vs {len(scaled.captured_queries)} for " + f"{len(response.json()['data'])}. The prefetch is being discarded." + ) + + def test_users_retrieve_scopes_relations_to_active_tenant( + self, authenticated_client, tenants_fixture + ): + tenant_a, tenant_b, _ = tenants_fixture + shared_user, role_a, role_b = self._shared_user_in_two_tenants( + tenant_a, tenant_b + ) + + response = authenticated_client.get( + reverse("user-detail", kwargs={"pk": shared_user.id}), + {"include": "memberships,roles,memberships.tenant"}, + ) + + assert response.status_code == status.HTTP_200_OK + payload = response.json() + relationships = payload["data"]["relationships"] + assert len(relationships["memberships"]["data"]) == 1 + assert [item["id"] for item in relationships["roles"]["data"]] == [ + str(role_a.id) + ] + + included = payload["included"] + included_memberships = [i for i in included if i["type"] == "memberships"] + assert len(included_memberships) == 1 + assert included_memberships[0]["relationships"]["tenant"]["data"]["id"] == str( + tenant_a.id + ) + assert {i["id"] for i in included if i["type"] == "roles"} == {str(role_a.id)} + assert { + i["attributes"]["name"] for i in included if i["type"] == "tenants" + } == {tenant_a.name} + body = response.content.decode() + assert tenant_b.name not in body + assert str(tenant_b.id) not in body + assert str(role_b.id) not in body + + def test_users_me_keeps_own_memberships_and_scopes_roles( + self, authenticated_client, create_test_user, tenants_fixture + ): + tenant_a, tenant_b, _ = tenants_fixture + + response = authenticated_client.get( + reverse("user-me"), {"include": "memberships,roles,memberships.tenant"} + ) + + assert response.status_code == status.HTTP_200_OK + payload = response.json() + relationships = payload["data"]["relationships"] + assert len(relationships["memberships"]["data"]) == 2 + assert { + i["attributes"]["name"] + for i in payload["included"] + if i["type"] == "tenants" + } == {tenant_a.name, tenant_b.name} + active_roles = create_test_user.roles.filter(tenant_id=tenant_a.id) + assert {i["id"] for i in relationships["roles"]["data"]} == { + str(role.id) for role in active_roles + } + + def test_membership_include_serializer_hides_tenant_of_non_member( + self, create_test_user, tenants_fixture + ): + tenant_a, _, isolated_tenant = tenants_fixture + outsider = User.objects.create_user( + name="outsider", + email=f"outsider-{uuid4()}@prowler.com", + password="TmpPass123@", + ) + foreign_membership = Membership.objects.create( + user=outsider, tenant=isolated_tenant + ) + own_membership = Membership.objects.get(user=create_test_user, tenant=tenant_a) + request = SimpleNamespace(user=create_test_user, tenant_id=str(tenant_a.id)) + + serializer = MembershipIncludeSerializer(context={"request": request}) + + assert serializer.get_tenant(own_membership) == tenant_a + assert serializer.get_tenant(foreign_membership) is None + def test_users_create(self, client): valid_user_payload = { "name": "test", @@ -3362,7 +3503,7 @@ current-context: test-context provider_secret = ProviderSecret.objects.get() assert "region" not in provider_secret.secret - def test_provider_secrets_create_oraclecloud_accepts_and_ignores_region( + def test_provider_secrets_create_oraclecloud_stores_region( self, authenticated_client, oraclecloud_provider, @@ -3371,14 +3512,14 @@ current-context: test-context authenticated_client, oraclecloud_provider, self._oraclecloud_secret( - key_content=" test-key-content ", region=" us-ashburn-1 " + key_content=" test-key-content ", region=" me-abudhabi-1 " ), ) assert response.status_code == status.HTTP_201_CREATED provider_secret = ProviderSecret.objects.get() assert provider_secret.secret["key_content"] == "test-key-content" - assert "region" not in provider_secret.secret + assert provider_secret.secret["region"] == "me-abudhabi-1" def test_provider_secrets_update_oraclecloud_without_region_stores_no_region( self, @@ -3411,7 +3552,7 @@ current-context: test-context provider_secret.refresh_from_db() assert "region" not in provider_secret.secret - def test_provider_secrets_update_oraclecloud_accepts_and_ignores_region( + def test_provider_secrets_update_oraclecloud_stores_region( self, authenticated_client, oraclecloud_provider, @@ -3429,7 +3570,7 @@ current-context: test-context "type": "provider-secrets", "id": str(provider_secret.id), "attributes": { - "secret": self._oraclecloud_secret(region=" us-ashburn-1 ") + "secret": self._oraclecloud_secret(region=" me-abudhabi-1 ") }, } } @@ -3442,7 +3583,7 @@ current-context: test-context assert response.status_code == status.HTTP_200_OK provider_secret.refresh_from_db() - assert "region" not in provider_secret.secret + assert provider_secret.secret["region"] == "me-abudhabi-1" @pytest.mark.parametrize( "attributes, error_code, error_pointer", @@ -3951,6 +4092,43 @@ class TestScanViewSet: mock_enqueue_scan_execution.assert_called_once() # assert scan.scanner_args == expected_scanner_args + @patch("api.v1.views.enqueue_scan_execution_on_commit") + def test_scans_create_returns_the_scan_id_in_task_args( + self, + mock_enqueue_scan_execution, + authenticated_client, + okta_provider, + ): + """The 202 is a task, so `task_args` is the only place the scan id is. + + It is serialized before the on_commit publish that would otherwise fill + the kwargs, so the record has to carry them from the start. + """ + payload = { + "data": { + "type": "scans", + "attributes": {"name": "New Scan"}, + "relationships": { + "provider": { + "data": {"type": "providers", "id": str(okta_provider.id)} + } + }, + } + } + + response = authenticated_client.post( + reverse("scan-list"), + data=payload, + content_type=API_JSON_CONTENT_TYPE, + ) + + assert response.status_code == status.HTTP_202_ACCEPTED + scan = Scan.objects.get() + assert response.json()["data"]["attributes"]["task_args"] == { + "scan_id": str(scan.id), + "provider_id": str(okta_provider.id), + } + @patch("tasks.tasks.perform_scan_task.apply_async") def test_scans_create_queues_scan_when_provider_has_active_scan( self, @@ -4540,6 +4718,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 ): @@ -5156,6 +5380,7 @@ class TestTaskViewSet: @patch("api.v1.views.AsyncResult", return_value=Mock()) def test_tasks_revoke(self, mock_async_result, authenticated_client, tasks_fixture): _, task2 = tasks_fixture + self._set_task_name(task2, "scan-perform") response = authenticated_client.delete( reverse("task-detail", kwargs={"pk": task2.id}) ) @@ -5171,12 +5396,311 @@ class TestTaskViewSet: def test_tasks_revoke_invalid_status(self, authenticated_client, tasks_fixture): task1, _ = tasks_fixture + self._set_task_name(task1, "scan-perform") response = authenticated_client.delete( reverse("task-detail", kwargs={"pk": task1.id}) ) # Task status is SUCCESS assert response.status_code == status.HTTP_400_BAD_REQUEST + @staticmethod + def _set_task_name(task, name): + task.task_runner_task.task_name = name + task.task_runner_task.save(update_fields=["task_name"]) + + @staticmethod + def _set_task_kwargs(task, kwargs): + task.task_runner_task.task_kwargs = json.dumps(repr(kwargs)) + task.task_runner_task.save(update_fields=["task_kwargs"]) + + @staticmethod + def _client_with_role(tenant, factory, **permissions): + user = User.objects.create_user( + name=f"revoker-{uuid4()}", + email=f"revoker-{uuid4()}@prowler.com", + password=TEST_PASSWORD, + ) + Membership.objects.create( + user=user, tenant=tenant, role=Membership.RoleChoices.MEMBER + ) + flags = { + "manage_users": False, + "manage_account": False, + "manage_billing": False, + "manage_providers": False, + "manage_integrations": False, + "manage_scans": False, + "unlimited_visibility": True, + **permissions, + } + role = Role.objects.create( + name=f"revoker-{uuid4()}", tenant_id=tenant.id, **flags + ) + UserRoleRelationship.objects.create(user=user, role=role, tenant_id=tenant.id) + return factory(user, tenant) + + @patch("api.v1.views.AsyncResult") + def test_tasks_revoke_without_permission_is_forbidden( + self, mock_async_result, authenticated_client_no_permissions_rbac, tasks_fixture + ): + _, pending_task = tasks_fixture + self._set_task_name(pending_task, "provider-connection-check") + + response = authenticated_client_no_permissions_rbac.delete( + reverse("task-detail", kwargs={"pk": pending_task.id}) + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + mock_async_result.return_value.revoke.assert_not_called() + + @pytest.mark.parametrize( + "task_name, permissions, expected_status", + [ + ( + "provider-connection-check", + {"manage_providers": True}, + status.HTTP_202_ACCEPTED, + ), + ( + "provider-connection-check", + {"manage_scans": True}, + status.HTTP_403_FORBIDDEN, + ), + ("scan-perform", {"manage_scans": True}, status.HTTP_202_ACCEPTED), + ( + "scan-perform-scheduled", + {"manage_providers": True}, + status.HTTP_403_FORBIDDEN, + ), + ( + "integration-jira", + {"manage_integrations": True}, + status.HTTP_202_ACCEPTED, + ), + ("integration-jira", {"manage_providers": True}, status.HTTP_403_FORBIDDEN), + ("lighthouse-connection-check", {}, status.HTTP_202_ACCEPTED), + ], + ) + @patch("api.v1.views.AsyncResult") + def test_tasks_revoke_requires_originating_operation_permission( + self, + mock_async_result, + authenticated_client_for_tenant_factory, + tenants_fixture, + tasks_fixture, + task_name, + permissions, + expected_status, + ): + tenant, *_ = tenants_fixture + _, pending_task = tasks_fixture + self._set_task_name(pending_task, task_name) + client = self._client_with_role( + tenant, authenticated_client_for_tenant_factory, **permissions + ) + + response = client.delete(reverse("task-detail", kwargs={"pk": pending_task.id})) + + assert response.status_code == expected_status + if expected_status == status.HTTP_202_ACCEPTED: + mock_async_result.return_value.revoke.assert_called_once() + else: + mock_async_result.return_value.revoke.assert_not_called() + + @patch("api.v1.views.AsyncResult") + def test_tasks_revoke_provider_deletion_is_forbidden_even_for_admin( + self, mock_async_result, authenticated_client, tasks_fixture + ): + _, pending_task = tasks_fixture + self._set_task_name(pending_task, "provider-deletion") + + response = authenticated_client.delete( + reverse("task-detail", kwargs={"pk": pending_task.id}) + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + mock_async_result.return_value.revoke.assert_not_called() + + @patch("api.v1.views.AsyncResult") + def test_tasks_revoke_unmapped_task_is_forbidden( + self, mock_async_result, authenticated_client, tasks_fixture + ): + _, pending_task = tasks_fixture + assert pending_task.task_runner_task.task_name not in TASK_REVOKE_PERMISSIONS + + response = authenticated_client.delete( + reverse("task-detail", kwargs={"pk": pending_task.id}) + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + mock_async_result.return_value.revoke.assert_not_called() + + def test_every_rls_task_has_revoke_permissions(self): + from config.celery import RLSTask, celery_app + + rls_task_names = { + name for name, task in celery_app.tasks.items() if isinstance(task, RLSTask) + } + assert rls_task_names + assert rls_task_names <= set(TASK_REVOKE_PERMISSIONS) + + @patch("api.v1.views.AsyncResult") + def test_tasks_hidden_for_providers_outside_role_visibility( + self, + mock_async_result, + authenticated_client_no_permissions_rbac, + tasks_fixture, + aws_provider_pair, + ): + client = authenticated_client_no_permissions_rbac + limited_user = client.user + tenant = Membership.objects.filter(user=limited_user).first().tenant + allowed_provider, denied_provider = aws_provider_pair + allowed_task, denied_task = tasks_fixture + self._set_task_kwargs( + allowed_task, + {"tenant_id": str(tenant.id), "provider_id": str(allowed_provider.id)}, + ) + self._set_task_name(denied_task, "provider-deletion") + self._set_task_kwargs( + denied_task, + {"tenant_id": str(tenant.id), "provider_id": str(denied_provider.id)}, + ) + provider_group = ProviderGroup.objects.create( + name="limited-task-group", tenant_id=tenant.id + ) + ProviderGroupMembership.objects.create( + tenant_id=tenant.id, + provider_group=provider_group, + provider=allowed_provider, + ) + RoleProviderGroupRelationship.objects.create( + tenant_id=tenant.id, + role=limited_user.roles.first(), + provider_group=provider_group, + ) + + response = client.get(reverse("task-list")) + assert response.status_code == status.HTTP_200_OK + assert [item["id"] for item in response.json()["data"]] == [ + str(allowed_task.id) + ] + + response = client.get(reverse("task-detail", kwargs={"pk": denied_task.id})) + assert response.status_code == status.HTTP_404_NOT_FOUND + + response = client.delete(reverse("task-detail", kwargs={"pk": denied_task.id})) + assert response.status_code == status.HTTP_404_NOT_FOUND + mock_async_result.return_value.revoke.assert_not_called() + + @patch("api.v1.views.AsyncResult") + def test_tasks_of_soft_deleted_provider_stay_visible_to_its_groups( + self, + mock_async_result, + authenticated_client_for_tenant_factory, + tenants_fixture, + tasks_fixture, + aws_provider_pair, + ): + tenant, *_ = tenants_fixture + provider, _ = aws_provider_pair + finished_task, pending_task = tasks_fixture + client = self._client_with_role( + tenant, + authenticated_client_for_tenant_factory, + manage_providers=True, + unlimited_visibility=False, + ) + provider_group = ProviderGroup.objects.create( + name="own-group", tenant_id=tenant.id + ) + ProviderGroupMembership.objects.create( + tenant_id=tenant.id, provider_group=provider_group, provider=provider + ) + RoleProviderGroupRelationship.objects.create( + tenant_id=tenant.id, + role=client.user.roles.first(), + provider_group=provider_group, + ) + for task, name in ( + (finished_task, "provider-deletion"), + (pending_task, "provider-connection-check"), + ): + self._set_task_name(task, name) + self._set_task_kwargs( + task, {"tenant_id": str(tenant.id), "provider_id": str(provider.id)} + ) + provider.is_deleted = True + provider.save() + + response = client.get(reverse("task-detail", kwargs={"pk": finished_task.id})) + assert response.status_code == status.HTTP_200_OK + + response = client.delete(reverse("task-detail", kwargs={"pk": pending_task.id})) + assert response.status_code == status.HTTP_202_ACCEPTED + mock_async_result.return_value.revoke.assert_called_once() + + def test_tasks_without_provider_stay_visible_for_limited_roles( + self, authenticated_client_no_permissions_rbac, tasks_fixture, aws_provider_pair + ): + response = authenticated_client_no_permissions_rbac.get(reverse("task-list")) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == len(tasks_fixture) + + def test_tasks_list_without_role_is_forbidden( + self, authenticated_client_rbac_noroles, tasks_fixture + ): + response = authenticated_client_rbac_noroles.get(reverse("task-list")) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_tasks_revoke_without_permission_hides_task_status( + self, authenticated_client_no_permissions_rbac, tasks_fixture + ): + finished_task, _ = tasks_fixture + self._set_task_name(finished_task, "provider-connection-check") + + response = authenticated_client_no_permissions_rbac.delete( + reverse("task-detail", kwargs={"pk": finished_task.id}) + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + @patch("api.v1.views.AsyncResult") + def test_tasks_revoke_unauthenticated_returns_401( + self, mock_async_result, tasks_fixture + ): + from rest_framework.test import APIClient + + _, pending_task = tasks_fixture + self._set_task_name(pending_task, "scan-perform") + + response = APIClient().delete( + reverse("task-detail", kwargs={"pk": pending_task.id}) + ) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + mock_async_result.return_value.revoke.assert_not_called() + + @patch("api.v1.views.AsyncResult") + def test_tasks_revoke_foreign_tenant_task_returns_404( + self, + mock_async_result, + authenticated_client_for_tenant_factory, + tenants_fixture, + tasks_fixture, + ): + _, foreign_tenant, *_ = tenants_fixture + _, pending_task = tasks_fixture + self._set_task_name(pending_task, "scan-perform") + client = self._client_with_role( + foreign_tenant, authenticated_client_for_tenant_factory, manage_scans=True + ) + + response = client.delete(reverse("task-detail", kwargs={"pk": pending_task.id})) + + assert response.status_code == status.HTTP_404_NOT_FOUND + mock_async_result.return_value.revoke.assert_not_called() + @pytest.mark.django_db class TestAttackPathsScanViewSet: @@ -8784,6 +9308,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 +9499,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 +9757,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 +19093,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 +19127,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 +19176,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 +19243,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 +19279,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 +19335,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/utils.py b/api/src/backend/api/utils.py index 8e73b96a39..37752ce28c 100644 --- a/api/src/backend/api/utils.py +++ b/api/src/backend/api/utils.py @@ -17,7 +17,7 @@ from api.models import ( Role, UserRoleRelationship, ) -from api.v1.serializers import FindingMetadataSerializer +from api.v1.serializers import FindingMetadataSerializer, KubernetesProviderSecret from django.contrib.postgres.aggregates import ArrayAgg from django.db import transaction from django.db.models import Subquery @@ -201,6 +201,13 @@ def return_prowler_provider( return prowler_provider +def _validate_kubernetes_secret(secret: dict) -> None: + """Re-validate a stored kubeconfig at use time; errors never echo its content.""" + KubernetesProviderSecret( + data={"kubeconfig_content": secret.get("kubeconfig_content")} + ).is_valid(raise_exception=True) + + def get_prowler_provider_kwargs( provider: Provider, mutelist_processor: Processor | None = None ) -> dict: @@ -225,6 +232,7 @@ def get_prowler_provider_kwargs( "project_ids": [provider.uid], } elif provider.provider == Provider.ProviderChoices.KUBERNETES.value: + _validate_kubernetes_secret(prowler_provider_kwargs) prowler_provider_kwargs = {**prowler_provider_kwargs, "context": provider.uid} elif provider.provider == Provider.ProviderChoices.GITHUB.value: if provider.uid: @@ -302,17 +310,26 @@ def get_prowler_provider_kwargs( def _normalize_oraclecloud_provider_kwargs(secret: dict) -> dict: """Normalize external OCI secret fields into SDK provider kwargs.""" prowler_provider_kwargs = secret.copy() - prowler_provider_kwargs.pop("region", None) + home_region = _oraclecloud_home_region(prowler_provider_kwargs.pop("region", None)) + if home_region: + prowler_provider_kwargs["home_region"] = home_region return prowler_provider_kwargs +def _oraclecloud_home_region(region) -> str | None: + """Return the stored OCI region as a home region, ignoring blank or non-string legacy values.""" + if isinstance(region, str) and region.strip(): + return region.strip() + return None + + def _normalize_oraclecloud_connection_test_kwargs(secret: dict) -> dict: """Normalize external OCI secret fields into test_connection kwargs.""" from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider prowler_provider_kwargs = secret.copy() - prowler_provider_kwargs.pop("region", None) + home_region = _oraclecloud_home_region(prowler_provider_kwargs.pop("region", None)) if ( prowler_provider_kwargs.get("user") @@ -323,11 +340,9 @@ def _normalize_oraclecloud_connection_test_kwargs(secret: dict) -> dict: or prowler_provider_kwargs.get("key_file") ) ): - # Connection validation needs one OCI endpoint, but scans remain unfiltered. - prowler_provider_kwargs["region"] = getattr( - OraclecloudProvider, - "_bootstrap_region", - OraclecloudProvider._home_region, + # Identity calls only succeed in a region the tenancy is subscribed to. + prowler_provider_kwargs["region"] = ( + home_region or OraclecloudProvider._bootstrap_region ) return prowler_provider_kwargs @@ -385,6 +400,16 @@ def prowler_provider_connection_test(provider: Provider) -> Connection: except Provider.secret.RelatedObjectDoesNotExist as secret_error: return Connection(is_connected=False, error=secret_error) + if provider.provider == Provider.ProviderChoices.KUBERNETES.value: + # A rejected stored kubeconfig is a failed connection, not a task error. + try: + _validate_kubernetes_secret(prowler_provider_kwargs) + except ValidationError as validation_error: + return Connection( + is_connected=False, + error=Exception(validation_error.detail["kubeconfig_content"][0]), + ) + # For IaC provider, construct the kwargs properly for test_connection if provider.provider == Provider.ProviderChoices.IAC.value: # Don't pass repository_url from secret, use scan_repository_url with the UID diff --git a/api/src/backend/api/v1/serializer_utils/providers.py b/api/src/backend/api/v1/serializer_utils/providers.py index 0d80b47c0a..cecf3d6f00 100644 --- a/api/src/backend/api/v1/serializer_utils/providers.py +++ b/api/src/backend/api/v1/serializer_utils/providers.py @@ -214,7 +214,8 @@ from rest_framework_json_api import serializers "kubeconfig_content": { "type": "string", "description": "The content of the Kubernetes kubeconfig file, encoded as a string. " - "Kubeconfig command-based authentication is not supported in Prowler Cloud for security reasons.", + "Only inline credentials are supported: token, username/password, or client-certificate-data with client-key-data for users, and certificate-authority-data for clusters. " + "File-path and auth-provider kubeconfig fields are not supported.", } }, "required": ["kubeconfig_content"], @@ -301,8 +302,7 @@ from rest_framework_json_api import serializers }, "region": { "type": "string", - "deprecated": True, - "description": "Legacy OCI region field accepted for backwards compatibility but ignored; OCI scans all regions.", + "description": "Optional OCI home region (or any region the tenancy is subscribed to) used to validate the credentials. It does not filter the scan, which covers all subscribed regions. Defaults to us-ashburn-1.", }, }, "required": ["user", "fingerprint", "tenancy"], diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index 5e1f2fa6d8..e90a58c5ad 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -71,6 +71,7 @@ from django.db import IntegrityError, transaction from drf_spectacular.utils import extend_schema_field from jwt.exceptions import InvalidKeyError from prowler.lib.mutelist.mutelist import Mutelist +from prowler.providers.oraclecloud.config import OCI_REGIONS from rest_framework.reverse import reverse from rest_framework.validators import UniqueTogetherValidator from rest_framework_json_api import serializers @@ -376,27 +377,48 @@ class UserSerializer(BaseModelSerializerV1): def _can_view_relationships(self, instance) -> bool: """Allow self to view own relationships. Require manage_account to view others.""" role = self.context.get("role") + return self._is_self(instance) or bool(role and role.manage_account) + + def _is_self(self, instance) -> bool: request = self.context.get("request") - is_self = bool( + return bool( request and getattr(request, "user", None) and getattr(instance, "id", None) == request.user.id ) - return is_self or (role and role.manage_account) + + def _active_tenant_id(self): + return getattr(self.context.get("request"), "tenant_id", None) + + @staticmethod + def _in_tenant(related_manager, tenant_id): + """Filter a prefetched related manager in memory. + + ``.filter()`` clones the queryset without its result cache, so it would + discard the viewset's prefetch and re-query once per serialized user. + """ + return [ + item + for item in related_manager.all() + if str(item.tenant_id) == str(tenant_id) + ] def get_roles(self, instance): - return ( - instance.roles.all() - if self._can_view_relationships(instance) - else Role.objects.none() - ) + tenant_id = self._active_tenant_id() + if not tenant_id or not self._can_view_relationships(instance): + return Role.objects.none() + return self._in_tenant(instance.roles, tenant_id) def get_memberships(self, instance): - return ( - instance.memberships.all() - if self._can_view_relationships(instance) - else Membership.objects.none() - ) + if not self._can_view_relationships(instance): + return Membership.objects.none() + # Own memberships span tenants on purpose: the tenant switcher reads them. + if self._is_self(instance): + return instance.memberships.all() + tenant_id = self._active_tenant_id() + if not tenant_id: + return Membership.objects.none() + return self._in_tenant(instance.memberships, tenant_id) class UserMeSerializer(UserSerializer): @@ -730,11 +752,32 @@ class MembershipIncludeSerializer(serializers.ModelSerializer): included_serializers = {"tenant": "api.v1.serializers.TenantIncludeSerializer"} + _REQUESTER_TENANTS_ATTR = "_prowler_requester_tenants" + + def _requester_tenants(self): + """Tenants the requester belongs to, resolved once per request. + + Memoised on the request because DRF builds a serializer instance per + included object, so a per-instance cache would still query per membership. + """ + request = self.context.get("request") + user = getattr(request, "user", None) + if user is None: + return {} + tenants = getattr(request, self._REQUESTER_TENANTS_ATTR, None) + if tenants is None: + tenants = { + tenant.id: tenant + for tenant in Tenant.objects.using(MainRouter.admin_db).filter( + membership__user_id=user.id + ) + } + setattr(request, self._REQUESTER_TENANTS_ATTR, tenants) + return tenants + def get_tenant(self, instance): - try: - return Tenant.objects.using(MainRouter.admin_db).get(id=instance.tenant_id) - except Tenant.DoesNotExist: - return None + """Resolve the tenant only when the requester is a member of it.""" + return self._requester_tenants().get(instance.tenant_id) # Provider Groups @@ -1658,14 +1701,43 @@ class FindingMetadataSerializer(BaseSerializerV1): # Provider secrets -KUBERNETES_KUBECONFIG_UNSUPPORTED_COMMAND_AUTH_ERROR = ( - "Kubernetes kubeconfig command-based authentication is not supported in " - "Prowler Cloud for security reasons." +KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR = ( + "Only inline Kubernetes credentials are supported. " + "For user authentication use token, username/password, or client-certificate-data with client-key-data; " + "clusters may use certificate-authority-data. " + "File-path fields (tokenFile, client-certificate, client-key, certificate-authority), " + "command-based authentication (exec, cmd-path), auth-provider directives, and proxy URLs are not supported." ) KUBERNETES_KUBECONFIG_INVALID_ERROR = "Invalid Kubernetes kubeconfig content." -def kubeconfig_contains_unsupported_command_auth(kubeconfig: dict) -> bool: +KUBECONFIG_ALLOWED_USER_KEYS = frozenset( + { + "token", + "username", + "password", + "client-certificate-data", + "client-key-data", + "as", + "as-uid", + "as-groups", + "as-user-extra", + } +) +KUBECONFIG_ALLOWED_CLUSTER_KEYS = frozenset( + { + "server", + "certificate-authority-data", + "insecure-skip-tls-verify", + "tls-server-name", + "disable-compression", + "extensions", + } +) + + +def kubeconfig_is_inline_only_credentials(kubeconfig: dict) -> bool: + """Accept only inline credentials; reject file-reference and auth-provider fields.""" users = kubeconfig.get("users", []) if not isinstance(users, list): raise ValidationError(KUBERNETES_KUBECONFIG_INVALID_ERROR) @@ -1678,21 +1750,25 @@ def kubeconfig_contains_unsupported_command_auth(kubeconfig: dict) -> bool: if not isinstance(user, dict): raise ValidationError(KUBERNETES_KUBECONFIG_INVALID_ERROR) - if "exec" in user: - return True + if any(key not in KUBECONFIG_ALLOWED_USER_KEYS for key in user): + return False - auth_provider = user.get("auth-provider", {}) - if not isinstance(auth_provider, dict): - continue + clusters = kubeconfig.get("clusters", []) + if not isinstance(clusters, list): + raise ValidationError(KUBERNETES_KUBECONFIG_INVALID_ERROR) - auth_provider_config = auth_provider.get("config", {}) - if not isinstance(auth_provider_config, dict): - continue + for cluster_entry in clusters: + if not isinstance(cluster_entry, dict): + raise ValidationError(KUBERNETES_KUBECONFIG_INVALID_ERROR) - if "cmd-path" in auth_provider_config: - return True + cluster = cluster_entry.get("cluster", {}) + if not isinstance(cluster, dict): + raise ValidationError(KUBERNETES_KUBECONFIG_INVALID_ERROR) - return False + if any(key not in KUBECONFIG_ALLOWED_CLUSTER_KEYS for key in cluster): + return False + + return True class BaseWriteProviderSecretSerializer(BaseWriteSerializer): @@ -1875,7 +1951,7 @@ class MongoDBAtlasProviderSecret(serializers.Serializer): class KubernetesProviderSecret(serializers.Serializer): - kubeconfig_content = serializers.CharField() + kubeconfig_content = serializers.CharField(trim_whitespace=False) def validate_kubeconfig_content(self, kubeconfig_content): try: @@ -1888,9 +1964,9 @@ class KubernetesProviderSecret(serializers.Serializer): if not isinstance(kubeconfig, dict): raise serializers.ValidationError(KUBERNETES_KUBECONFIG_INVALID_ERROR) - if kubeconfig_contains_unsupported_command_auth(kubeconfig): + if not kubeconfig_is_inline_only_credentials(kubeconfig): raise serializers.ValidationError( - KUBERNETES_KUBECONFIG_UNSUPPORTED_COMMAND_AUTH_ERROR + KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR ) return kubeconfig_content @@ -1917,9 +1993,16 @@ class IacProviderSecret(serializers.Serializer): resource_name = "provider-secrets" -class LegacyOCIRegionField(serializers.Field): +class OCIHomeRegionField(serializers.Field): + """Optional OCI home region; blank or non-string legacy values are dropped.""" + def to_internal_value(self, data): - return data + if not isinstance(data, str) or not data.strip(): + return None + region = data.strip() + if region not in OCI_REGIONS: + raise serializers.ValidationError(f"Invalid OCI region: {region}") + return region def to_representation(self, value): return value @@ -1932,10 +2015,11 @@ class OracleCloudProviderSecret(serializers.Serializer): key_content = serializers.CharField(required=False) tenancy = serializers.CharField() pass_phrase = serializers.CharField(required=False) - region = LegacyOCIRegionField(required=False, allow_null=True) + region = OCIHomeRegionField(required=False, allow_null=True) def validate(self, attrs): - attrs.pop("region", None) + if not attrs.get("region"): + attrs.pop("region", None) if "key_file" not in attrs and "key_content" not in attrs: raise serializers.ValidationError( @@ -2149,6 +2233,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 +2265,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 +2273,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..5dc175dd42 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -125,10 +125,14 @@ from api.models import ( ) from api.pagination import ComplianceOverviewPagination from api.rbac.permissions import ( + TASK_REVOKE_PERMISSIONS, Permissions, get_integrations, get_providers, get_role, + get_tasks, + get_user_roles, + roles_have_permissions, ) from api.renderers import APIJSONRenderer, PlainTextRenderer from api.rls import Tenant @@ -244,7 +248,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 +330,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 +345,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 +2411,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, @@ -2823,6 +2826,15 @@ class ScanViewSet(ProviderVisibilityMixin, BaseRLSViewSet): tenant_id=self.request.tenant_id, task_id=pre_task_id, task_status=(QUEUED_SCAN_TASK_STATE if active_scan else None), + # This response is serialized before the on_commit publish, + # so without these the caller gets a task id and no scan id. + # Kept in step with what `enqueue_scan_execution_on_commit` + # publishes below. + task_kwargs={ + "tenant_id": str(self.request.tenant_id), + "scan_id": str(scan.id), + "provider_id": str(scan.provider_id), + }, ) if not active_scan: @@ -2850,17 +2862,29 @@ class ScanViewSet(ProviderVisibilityMixin, BaseRLSViewSet): list=extend_schema( tags=["Task"], summary="List all tasks", - description="Retrieve a list of all tasks with options for filtering by name, state, and other criteria.", + description=( + "Retrieve a list of all tasks with options for filtering by name, state, and other " + "criteria. Tasks that reference a provider are only returned when the role can " + "access it; tasks without a provider reference are returned for every role." + ), ), retrieve=extend_schema( tags=["Task"], summary="Retrieve data from a specific task", - description="Fetch detailed information about a specific task by its ID.", + description=( + "Fetch detailed information about a specific task by its ID. Tasks tied to a provider " + "outside the visibility of the role are not found." + ), ), destroy=extend_schema( tags=["Task"], summary="Revoke a task", - description="Try to revoke a task using its ID. Only tasks that are not yet in progress can be revoked.", + description=( + "Try to revoke a task using its ID. Only tasks that are not yet in progress can be " + "revoked, and the caller needs the same permission as the operation that queued " + "the task (for example MANAGE_SCANS for a scan). Provider deletions cannot be " + "revoked." + ), responses={202: OpenApiResponse(response=TaskSerializer)}, ), ) @@ -2876,13 +2900,26 @@ class TaskViewSet(BaseRLSViewSet): required_permissions = [] def get_queryset(self): - return Task.objects.annotate( - name=F("task_runner_task__task_name"), - state=F("task_runner_task__status"), - ).select_related("task_runner_task") + return ( + get_tasks(self.user_role) + .annotate( + name=F("task_runner_task__task_name"), + state=F("task_runner_task__status"), + ) + .select_related("task_runner_task") + ) def destroy(self, request, *args, pk=None, **kwargs): - task = get_object_or_404(Task, pk=pk) + task = self.get_object() + required_permissions = TASK_REVOKE_PERMISSIONS.get( + task.task_runner_task.task_name + ) + # Same multi-role semantics as HasPermissions. + if required_permissions is None or not roles_have_permissions( + get_user_roles(request.user, request.tenant_id), required_permissions + ): + raise PermissionDenied("You do not have permission to revoke this task.") + if task.task_runner_task.status not in ["PENDING", "RECEIVED"]: serializer = TaskSerializer(task) return Response( @@ -3479,12 +3516,8 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet): filtered_queryset = self.filter_queryset(self.get_queryset()) latest_scans = ( - Scan.all_objects.filter( - tenant_id=tenant_id, - state=StateChoices.COMPLETED, - ) - .order_by("provider_id", "-inserted_at") - .distinct("provider_id") + Scan.all_objects.filter(tenant_id=tenant_id) + .latest_per_provider() .values("provider_id") ) @@ -3616,11 +3649,9 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet): tenant_id = request.tenant_id query_params = request.query_params - latest_scans_queryset = ( - Scan.all_objects.filter(tenant_id=tenant_id, state=StateChoices.COMPLETED) - .order_by("provider_id", "-inserted_at") - .distinct("provider_id") - ) + latest_scans_queryset = Scan.all_objects.filter( + tenant_id=tenant_id + ).latest_per_provider() queryset = ResourceScanSummary.objects.filter( tenant_id=tenant_id, @@ -4207,12 +4238,9 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet): tenant_id = request.tenant_id filtered_queryset = self.filter_queryset(self.get_queryset()) - latest_scan_ids = list( - Scan.all_objects.filter(tenant_id=tenant_id, state=StateChoices.COMPLETED) - .order_by("provider_id", "-inserted_at") - .distinct("provider_id") - .values_list("id", flat=True) - ) + latest_scan_ids = Scan.all_objects.filter( + tenant_id=tenant_id + ).latest_ids_per_provider() filtered_queryset = filtered_queryset.filter( tenant_id=tenant_id, scan_id__in=latest_scan_ids ) @@ -4235,11 +4263,9 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet): tenant_id = request.tenant_id query_params = request.query_params - latest_scans_queryset = ( - Scan.all_objects.filter(tenant_id=tenant_id, state=StateChoices.COMPLETED) - .order_by("provider_id", "-inserted_at") - .distinct("provider_id") - ) + latest_scans_queryset = Scan.all_objects.filter( + tenant_id=tenant_id + ).latest_per_provider() raw_latest_scans_ids = list( latest_scans_queryset.values_list("id", "unique_resource_count") ) @@ -4473,7 +4499,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 +4513,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() @@ -4980,11 +5006,7 @@ class ComplianceOverviewViewSet( if provider_filters: scans = scans.filter(**provider_filters) - return list( - scans.order_by("provider_id", "-inserted_at") - .distinct("provider_id") - .values_list("id", flat=True) - ) + return scans.latest_ids_per_provider() def _filtered_queryset_for_latest_provider_scans(self, latest_scan_ids=None): if latest_scan_ids is None: @@ -5726,14 +5748,9 @@ class OverviewViewSet(ProviderFilterParamsMixin, BaseRLSViewSet): else {} ) - latest_scan_ids = ( - Scan.all_objects.filter( - tenant_id=tenant_id, state=StateChoices.COMPLETED, **provider_filter - ) - .order_by("provider_id", "-inserted_at") - .distinct("provider_id") - .values_list("id", flat=True) - ) + latest_scan_ids = Scan.all_objects.filter( + tenant_id=tenant_id, **provider_filter + ).latest_ids_per_provider() return filtered_queryset.filter( tenant_id=tenant_id, scan_id__in=latest_scan_ids @@ -5760,16 +5777,10 @@ class OverviewViewSet(ProviderFilterParamsMixin, BaseRLSViewSet): def _latest_scan_ids_for_allowed_providers(self, tenant_id, provider_filters=None): provider_filter = self._get_provider_filter() - queryset = Scan.all_objects.filter( - tenant_id=tenant_id, state=StateChoices.COMPLETED, **provider_filter - ) + queryset = Scan.all_objects.filter(tenant_id=tenant_id, **provider_filter) if provider_filters: queryset = queryset.filter(**provider_filters) - return ( - queryset.order_by("provider_id", "-inserted_at") - .distinct("provider_id") - .values_list("id", flat=True) - ) + return queryset.latest_ids_per_provider() @action(detail=False, methods=["get"], url_name="providers") def providers(self, request): @@ -5781,14 +5792,9 @@ class OverviewViewSet(ProviderFilterParamsMixin, BaseRLSViewSet): else {} ) - latest_scan_ids = ( - Scan.all_objects.filter( - tenant_id=tenant_id, state=StateChoices.COMPLETED, **provider_filter - ) - .order_by("provider_id", "-inserted_at") - .distinct("provider_id") - .values_list("id", flat=True) - ) + latest_scan_ids = Scan.all_objects.filter( + tenant_id=tenant_id, **provider_filter + ).latest_ids_per_provider() findings_aggregated = ( queryset.filter(scan_id__in=latest_scan_ids) @@ -7551,35 +7557,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, @@ -7942,18 +7941,13 @@ class FindingGroupViewSet(JsonApiFilterMixin, BaseRLSViewSet): def _get_latest_findings_per_provider(self, filtered_queryset): """Keep only findings from each provider's most recent completed scan.""" - # Materialize to a literal IN list. Left as a subquery, Postgres can't - # estimate the match count and picks a serial nested loop on - # resource_finding_mappings when one scan dominates findings - latest_scan_ids = list( - Scan.objects.filter( - tenant_id=self.request.tenant_id, - state=StateChoices.COMPLETED, - ) - .order_by("provider_id", "-completed_at", "-inserted_at") - .distinct("provider_id") - .values_list("id", flat=True) - ) + # `latest_ids_per_provider` materializes to a literal IN list. + # Left as a subquery, Postgres can't estimate the match count and picks + # a serial nested loop on resource_finding_mappings when one scan + # dominates findings + latest_scan_ids = Scan.objects.filter( + tenant_id=self.request.tenant_id + ).latest_ids_per_provider() return filtered_queryset.filter(scan_id__in=latest_scan_ids) def _post_process_aggregation(self, aggregated_data): @@ -8899,16 +8893,14 @@ class FindingGroupViewSet(JsonApiFilterMixin, BaseRLSViewSet): tenant_id = request.tenant_id queryset = self._get_finding_queryset() - # Order by -completed_at (matching the /latest summary path and the - # daily summary upsert keyed on midnight(completed_at)) so that - # overlapping scans do not make /resources and /latest read from - # different scans and report diverging counts. - latest_scan_ids = ( - Scan.objects.filter(tenant_id=tenant_id, state=StateChoices.COMPLETED) - .order_by("provider_id", "-completed_at", "-inserted_at") - .distinct("provider_id") - .values_list("id", flat=True) - ) + # The shared selector orders by -completed_at (matching the /latest + # summary path and the daily summary upsert keyed on + # midnight(completed_at)) so that overlapping scans do not make + # /resources and /latest read from different scans and report + # diverging counts. + latest_scan_ids = Scan.objects.filter( + tenant_id=tenant_id + ).latest_ids_per_provider() normalized_params = self._normalize_jsonapi_params(request.query_params) # Remove date filters since we're using latest diff --git a/api/src/backend/config/custom_logging.py b/api/src/backend/config/custom_logging.py index a601c8e2cd..f60651f8d4 100644 --- a/api/src/backend/config/custom_logging.py +++ b/api/src/backend/config/custom_logging.py @@ -231,6 +231,62 @@ LOGGING = { "level": LEVEL, "propagate": False, }, + # Celery loggers must be declared explicitly because + # disable_existing_loggers=True silences any logger that exists at + # dictConfig time but is not named here. Without these, fatal worker + # errors (e.g. celery.worker CRITICAL) produce no output. + # "celery" must keep propagating: get_task_logger() parents task + # loggers under celery.task, so blocking here hides them from root. + "celery": { + "level": LEVEL, + "propagate": True, + }, + "celery.worker": { + "handlers": ["tasks_console"], + "level": LEVEL, + "propagate": False, + }, + "celery.worker.consumer": { + "handlers": ["tasks_console"], + "level": LEVEL, + "propagate": False, + }, + "celery.worker.consumer.consumer": { + "handlers": ["tasks_console"], + "level": LEVEL, + "propagate": False, + }, + "kombu": { + "handlers": ["tasks_console"], + "level": LEVEL, + "propagate": False, + }, + "kombu.transport.redis": { + "handlers": ["tasks_console"], + "level": LEVEL, + "propagate": False, + }, + "billiard": { + "handlers": ["tasks_console"], + "level": LEVEL, + "propagate": False, + }, + "amqp": { + "handlers": ["tasks_console"], + "level": LEVEL, + "propagate": False, + }, + # WARNING keeps task failures but skips one "succeeded" line per task. + "celery.app.trace": { + "handlers": ["tasks_console"], + "level": "WARNING", + "propagate": False, + }, + "celery.beat": { + "handlers": ["tasks_console"], + "level": LEVEL, + "propagate": False, + }, }, # Gunicorn required configuration "root": { diff --git a/api/src/backend/config/django/base.py b/api/src/backend/config/django/base.py index a079942600..664fcd1c4a 100644 --- a/api/src/backend/config/django/base.py +++ b/api/src/backend/config/django/base.py @@ -7,6 +7,7 @@ from config.settings.eventstream import * # noqa from config.settings.partitions import * # noqa from config.settings.sentry import * # noqa from config.settings.social_login import * # noqa +from django.core.exceptions import ImproperlyConfigured SECRET_KEY = env("SECRET_KEY", default="secret") DEBUG = env.bool("DJANGO_DEBUG", default=False) @@ -295,6 +296,14 @@ 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", "") +# Storage endpoint the API and Celery workers use to talk to S3-compatible object storage +# such as MinIO. Empty means the real AWS S3 endpoint, which is unaffected. +DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL = env.str("DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL", "") +# 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 +312,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 @@ -315,6 +329,17 @@ ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES = env.int( "ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES", 960 ) # 16h +# Minimum age (of the scan row, or of the scan id itself when the row is gone) before +# the periodic reaper will drop an orphaned temp Neo4j database. Keeps a scan that is +# still legitimately in flight from ever losing its staging database mid-run. +ATTACK_PATHS_TMP_DB_REAP_SAFETY_MARGIN_HOURS = env.int( + "ATTACK_PATHS_TMP_DB_REAP_SAFETY_MARGIN_HOURS", 6 +) +if ATTACK_PATHS_TMP_DB_REAP_SAFETY_MARGIN_HOURS <= 0: + raise ImproperlyConfigured( + "ATTACK_PATHS_TMP_DB_REAP_SAFETY_MARGIN_HOURS must be a positive number of hours" + ) + # Selects where the persistent attack-paths graph is stored. The scan # temporary database is always Neo4j; only the sink is configurable. # Valid values: "neo4j" (default, OSS and local dev), "neptune" (hosted). diff --git a/api/src/backend/config/settings/sentry.py b/api/src/backend/config/settings/sentry.py index f5a593601e..1d283f9041 100644 --- a/api/src/backend/config/settings/sentry.py +++ b/api/src/backend/config/settings/sentry.py @@ -193,10 +193,10 @@ def initialize_sentry(): sentry_sdk.init( dsn=sentry_dsn, - # Add data like request headers and IP for users, - # see https://docs.sentry.io/platforms/python/data-management/data-collected/ for more info before_send=before_send, - send_default_pii=True, + # No user identity, IPs, cookies, headers or request bodies: bodies carry emails and provider details. + send_default_pii=False, + max_request_body_size="never", traces_sample_rate=env.float("DJANGO_SENTRY_TRACES_SAMPLE_RATE", default=0.02), _experiments={ # Set continuous_profiling_auto_start to True diff --git a/api/src/backend/tasks/jobs/attack_paths/tmp_db_reaper.py b/api/src/backend/tasks/jobs/attack_paths/tmp_db_reaper.py new file mode 100644 index 0000000000..3d575d41d4 --- /dev/null +++ b/api/src/backend/tasks/jobs/attack_paths/tmp_db_reaper.py @@ -0,0 +1,107 @@ +"""Periodic reaper for orphaned temp Neo4j scan databases. + +`scan.py` creates a throw-away `db-tmp-scan-` database per +scan and drops it once the scan finishes, success or failure. When the worker +or Neo4j itself dies mid-scan, that drop never runs and nothing else ever +revisits the database - it sits there forever. This sweep lists every temp +database on the ingest cluster and drops the ones whose scan is gone or has +been finished for longer than the configured safety margin. +""" + +from datetime import UTC, datetime, timedelta + +from api.attack_paths import database as graph_database +from api.db_router import MainRouter +from api.models import AttackPathsScan, StateChoices +from api.uuid_utils import datetime_from_uuid7 +from celery.utils.log import get_task_logger +from config.django.base import ATTACK_PATHS_TMP_DB_REAP_SAFETY_MARGIN_HOURS +from uuid6 import UUID as UUID7 + +logger = get_task_logger(__name__) + +TERMINAL_STATES = ( + StateChoices.COMPLETED, + StateChoices.FAILED, + StateChoices.CANCELLED, +) + + +def reap_orphaned_tmp_databases() -> dict: + """Drop temp Neo4j scan databases whose scan is gone or long finished. + + A failure listing databases aborts the whole sweep (nothing to iterate). + A failure reaping one database is logged and skipped so the rest of the + sweep still runs. + """ + now = datetime.now(tz=UTC) + safety_margin = timedelta(hours=ATTACK_PATHS_TMP_DB_REAP_SAFETY_MARGIN_HOURS) + + try: + databases = graph_database.list_databases() + except Exception: + logger.exception("Failed to list ingest Neo4j databases for temp-db reap") + return {"dropped_count": 0, "databases": []} + + tmp_databases = [ + name for name in databases if name.startswith(graph_database.TEMP_DB_PREFIX) + ] + + dropped: list[str] = [] + for database in tmp_databases: + try: + if _is_orphaned(database, now, safety_margin): + graph_database.drop_database(database) + dropped.append(database) + logger.info(f"Dropped orphaned temp Neo4j database `{database}`") + except Exception: + logger.exception(f"Failed to reap temp Neo4j database `{database}`") + + logger.info(f"Temp Neo4j database reap: {len(dropped)} dropped") + return {"dropped_count": len(dropped), "databases": dropped} + + +def _is_orphaned(database: str, now: datetime, safety_margin: timedelta) -> bool: + """Decide whether a temp database is safe to drop. + + No scan row: the row was hard-deleted (tenant/provider cleanup) or was + never created. Falls back to the scan id's own UUIDv7 timestamp so a + database created moments ago is never touched even without a row to check. + + Scan row present: only reapable once it reached a terminal state and has + been finished for longer than the safety margin, so a scan still + legitimately executing is never touched. + """ + scan_id = database[len(graph_database.TEMP_DB_PREFIX) :] + + try: + scan_uuid = UUID7(scan_id) + except ValueError: + logger.warning( + f"Temp database `{database}` has an unparseable scan id, skipping" + ) + return False + + # Global sweep with no tenant context: admin_db bypasses RLS on purpose, the same + # way cleanup_stale_attack_paths_scans finds stale scans across every tenant. + scan = ( + AttackPathsScan.all_objects.using(MainRouter.admin_db) + .filter(id=scan_uuid) + .first() + ) + + if scan is None: + if scan_uuid.version != 7: + logger.warning( + f"Temp database `{database}` has no scan row and a non-UUIDv7 id, " + "skipping" + ) + return False + return now - datetime_from_uuid7(scan_uuid) >= safety_margin + + if scan.state not in TERMINAL_STATES: + return False + + # `mark_scan_finished` does not touch `updated_at`, so prefer `completed_at` + finished_at = scan.completed_at or scan.updated_at + return now - finished_at >= safety_margin diff --git a/api/src/backend/tasks/jobs/backfill.py b/api/src/backend/tasks/jobs/backfill.py index 56cb626786..a72da95779 100644 --- a/api/src/backend/tasks/jobs/backfill.py +++ b/api/src/backend/tasks/jobs/backfill.py @@ -499,10 +499,13 @@ def backfill_provider_compliance_scores(tenant_id: str) -> dict: provider_id__in=existing_providers ) + # `completed_scans` keeps its own `completed_at__isnull=False`: this + # task writes a *dated* ProviderComplianceScore row, so unlike the read + # paths it genuinely cannot use a scan without a `completed_at`. scan_info = list( - completed_scans.order_by("provider_id", "-completed_at") - .distinct("provider_id") - .values("id", "provider_id", "completed_at") + completed_scans.latest_per_provider().values( + "id", "provider_id", "completed_at" + ) ) if not scan_info: diff --git a/api/src/backend/tasks/jobs/deletion.py b/api/src/backend/tasks/jobs/deletion.py index 91e64610f7..4fab47b4df 100644 --- a/api/src/backend/tasks/jobs/deletion.py +++ b/api/src/backend/tasks/jobs/deletion.py @@ -13,6 +13,7 @@ from api.models import ( Tenant, ) from celery.utils.log import get_task_logger +from django.conf import settings from django.db import DatabaseError from tasks.jobs.queries import ( COMPLIANCE_DELETE_EMPTY_TENANT_SUMMARY_SQL, @@ -106,9 +107,19 @@ def delete_provider(tenant_id: str, pk: str): try: if attack_paths_sink_backends: for sink_backend in attack_paths_sink_backends: - sink_module.get_backend_for_name(sink_backend).drop_subgraph( - tenant_database_name, str(pk) - ) + try: + backend = sink_module.get_backend_for_name(sink_backend) + + except RuntimeError as sink_error: + # A retired sink has no connection settings left, and no graph left to drop + if sink_backend == settings.ATTACK_PATHS_SINK_DATABASE.lower(): + raise + logger.warning( + f"Skipping graph cleanup on unconfigured sink {sink_backend}: {sink_error}" + ) + continue + + backend.drop_subgraph(tenant_database_name, str(pk)) else: graph_database.drop_subgraph(tenant_database_name, str(pk)) diff --git a/api/src/backend/tasks/jobs/export.py b/api/src/backend/tasks/jobs/export.py index e658d6018c..49b91c219f 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 @@ -206,15 +207,21 @@ def get_s3_client(): This function attempts to initialize an S3 client by reading the AWS access key, secret key, session token, and region from environment variables. It then validates the client by listing available S3 buckets. If an error occurs during this process (for example, due to missing or - invalid credentials), it falls back to creating an S3 client without explicitly provided credentials, - which may rely on other configuration sources (e.g., IAM roles). + invalid credentials), it falls back to creating an S3 client without explicitly provided + credentials, which may rely on other configuration sources (e.g., IAM roles). + + That fallback is only safe when no explicit endpoint is configured: with an endpoint set, the + explicit client already targets the intended S3-compatible storage, and the fallback client + would go to the AWS default provider chain instead, an unrelated real-AWS account reachable + from the host. So when an endpoint is configured, the original error propagates instead. Returns: boto3.client: A configured S3 client instance. Raises: - ClientError, NoCredentialsError, or ParamValidationError if both attempts to create a client fail. + ClientError, NoCredentialsError, or ParamValidationError if the client cannot be created. """ + endpoint = settings.DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL s3_client = None try: s3_client = boto3.client( @@ -222,16 +229,68 @@ 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", + endpoint_url=endpoint or None, ) s3_client.list_buckets() except (ClientError, NoCredentialsError, ParamValidationError, ValueError): + if endpoint: + raise s3_client = boto3.client("s3") s3_client.list_buckets() return s3_client +def get_s3_presign_client(): + """Return a client that signs download URLs with SigV4. + + It is used when a public or internal storage host is configured, or when the bucket's + region is: boto3 otherwise presigns S3 URLs with SigV2, which S3 rejects for SSE-KMS + objects. None means none of those is set and the caller should presign with its own + client, which leaves those deployments with the URL they get today. + + The public endpoint wins when both are set: the internal endpoint may only be reachable + from inside the cluster, and a URL signed against it would not open in a browser. + """ + endpoint = ( + settings.DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL + or settings.DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL + ) + if not endpoint and not settings.DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION: + 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=endpoint or None, + # The signature covers the host, so the addressing style has to be pinned rather + # than guessed from the endpoint: MinIO serves path-style, and on AWS it keeps the + # regional host instead of the global one, which redirects for new buckets. + 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..bc1d7f1c43 100644 --- a/api/src/backend/tasks/jobs/muting.py +++ b/api/src/backend/tasks/jobs/muting.py @@ -1,63 +1,96 @@ +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 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 + latest_scans = Scan.objects.filter( + tenant_id=tenant_id, provider_id__in=provider_ids + ).latest_ids_per_provider() - # 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}" - ) - - 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/orphan_recovery.py b/api/src/backend/tasks/jobs/orphan_recovery.py index c8cda54cd2..7290fb313f 100644 --- a/api/src/backend/tasks/jobs/orphan_recovery.py +++ b/api/src/backend/tasks/jobs/orphan_recovery.py @@ -84,6 +84,7 @@ _SKIP_RECOVERY = { "scan-perform-scheduled", "attack-paths-scan-perform", "attack-paths-cleanup-stale-scans", + "attack-paths-reap-orphaned-tmp-databases", "reconcile-orphan-tasks", } diff --git a/api/src/backend/tasks/jobs/scan.py b/api/src/backend/tasks/jobs/scan.py index 0a8db605c0..cc6cec5372 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, @@ -2092,7 +2113,9 @@ def aggregate_attack_surface(tenant_id: str, scan_id: str): .annotate( total=Count("id"), failed=Count("id", filter=Q(status="FAIL", muted=False)), - muted=Count("id", filter=Q(status="FAIL", muted=True)), + # Not `muted`: an annotation named after a model field comes + # back as `muted_new` from the psqlextra queryset. + muted_count=Count("id", filter=Q(status="FAIL", muted=True)), ) ) @@ -2103,7 +2126,7 @@ def aggregate_attack_surface(tenant_id: str, scan_id: str): aggregated_counts[attack_surface_type]["total"] += stats["total"] or 0 aggregated_counts[attack_surface_type]["failed"] += stats["failed"] or 0 - aggregated_counts[attack_surface_type]["muted"] += stats["muted"] or 0 + aggregated_counts[attack_surface_type]["muted"] += stats["muted_count"] or 0 overview_objects = [] for attack_surface_type, counts in aggregated_counts.items(): @@ -2679,20 +2702,37 @@ def reset_ephemeral_resource_findings_count(tenant_id: str, scan_id: str) -> dic # refreshed). Wiping based on the older scan would zero counts the newer # scan just set. Skip and let the newer scan's reset task do the work; if # this task was delayed in the queue, that's the correct outcome. - # `completed_at__isnull=False` is required: Postgres orders NULL first in - # DESC, so a sibling COMPLETED scan with a missing completed_at would sort - # as "newest" and incorrectly cause us to skip. + # + # The comparison must be against the newest *full-scope* scan, which is + # what this variable has always been named after but did not use to be: + # the query filtered nothing about scope, so any newer scan that is not + # full-scope (an imported one, for instance) made the full-scope scan + # skip its own cleanup and leave ephemeral resources with a stale + # failed_findings_count permanently. + # + # `is_full_scope()` reads `trigger` plus the scoping keys inside + # `scanner_args`, which is not expressible as a WHERE clause, so the + # candidates are walked newest-first in Python until the first full-scope + # one. The walk needs no cap: `scan` is itself a full-scope candidate, so + # it stops at `scan` at the latest, after reading only the scans newer than + # it. A fixed window would return None once more newer scoped scans had + # landed than it inspected, and skip the cleanup exactly like the bug above. + # + # NULL `completed_at` no longer needs an explicit filter here: the shared + # ordering in `ScanQuerySet.LATEST_ORDER_BY` sorts NULLs last + # rather than excluding them, which also fixes the case where a provider + # whose completed scans all have a NULL `completed_at` resolved to None and + # therefore never ran the reset at all. with rls_transaction(tenant_id): - latest_full_scope_scan_id = ( - Scan.objects.filter( - tenant_id=tenant_id, - provider_id=scan.provider_id, - state=StateChoices.COMPLETED, - completed_at__isnull=False, - ) - .order_by("-completed_at", "-inserted_at") - .values_list("id", flat=True) - .first() + candidates = ( + Scan.objects.filter(tenant_id=tenant_id, provider_id=scan.provider_id) + .latest_first() + .only("id", "trigger", "scanner_args") + .iterator(chunk_size=100) + ) + latest_full_scope_scan_id = next( + (candidate.id for candidate in candidates if candidate.is_full_scope()), + None, ) if latest_full_scope_scan_id != scan.id: logger.info( diff --git a/api/src/backend/tasks/tasks.py b/api/src/backend/tasks/tasks.py index e9101ff1bb..9ec6526963 100644 --- a/api/src/backend/tasks/tasks.py +++ b/api/src/backend/tasks/tasks.py @@ -1,3 +1,4 @@ +import json import os from datetime import UTC, datetime, timedelta from pathlib import Path @@ -42,6 +43,7 @@ from tasks.jobs.attack_paths import ( ) from tasks.jobs.attack_paths import db_utils as attack_paths_db_utils from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans +from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases from tasks.jobs.backfill import ( aggregate_scan_category_summaries, aggregate_scan_resource_group_summaries, @@ -73,7 +75,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, @@ -160,13 +165,28 @@ def create_scan_task_record( task_id: str, task_name: str = "scan-perform", task_status: str | None = states.PENDING, + task_kwargs: dict | None = None, ) -> Task: + """Pre-create the TaskResult + Task rows for a pre-generated task id. + + Pass ``task_kwargs`` when the response built from this record is serialized + before the broker publish. ``task_kwargs`` is otherwise only written by the + ``before_task_publish`` signal (``api/signals.py``), and the scan publish is + deferred to ``on_commit``, so the 202 would carry an empty ``task_args`` and + the caller would have no way to learn the scan id it was just handed a task + for. The publish later overwrites the field with the same kwargs as a Python + repr; both forms decode to the same dict (``decode_celery_field``). + """ if task_status is None: task_status = states.PENDING + defaults = {"status": task_status, "task_name": task_name} + if task_kwargs is not None: + defaults["task_kwargs"] = json.dumps(task_kwargs) + task_result, _ = TaskResult.objects.update_or_create( task_id=str(task_id), - defaults={"status": task_status, "task_name": task_name}, + defaults=defaults, ) prowler_task, _ = Task.objects.update_or_create( id=str(task_id), @@ -526,6 +546,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 +656,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: @@ -706,6 +728,13 @@ def cleanup_stale_attack_paths_scans_task(): return cleanup_stale_attack_paths_scans() +@shared_task( + name="attack-paths-reap-orphaned-tmp-databases", queue="attack-paths-scans" +) +def reap_orphaned_attack_paths_tmp_databases_task(): + return reap_orphaned_tmp_databases() + + @shared_task(name="reconcile-orphan-tasks", queue="celery") def reconcile_orphan_tasks_task(): """Periodic watchdog: recover tasks whose worker is gone (deploys, crashes).""" @@ -1188,85 +1217,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 +1459,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_attack_paths_tmp_db_reaper.py b/api/src/backend/tasks/tests/test_attack_paths_tmp_db_reaper.py new file mode 100644 index 0000000000..ab8d478383 --- /dev/null +++ b/api/src/backend/tasks/tests/test_attack_paths_tmp_db_reaper.py @@ -0,0 +1,286 @@ +from datetime import UTC, datetime, timedelta +from unittest.mock import patch +from uuid import uuid4 + +import pytest +from api.attack_paths.database import TEMP_DB_PREFIX +from api.models import AttackPathsScan, StateChoices +from api.uuid_utils import datetime_to_uuid7 +from config.django.base import ATTACK_PATHS_TMP_DB_REAP_SAFETY_MARGIN_HOURS + +MARGIN = timedelta(hours=ATTACK_PATHS_TMP_DB_REAP_SAFETY_MARGIN_HOURS) + + +def _tmp_db_name(scan_uuid) -> str: + return f"{TEMP_DB_PREFIX}{scan_uuid}" + + +@pytest.mark.django_db +class TestReapOrphanedTmpDatabases: + @patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database") + @patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases") + def test_ignores_databases_without_the_temp_prefix(self, mock_list, mock_drop): + from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases + + mock_list.return_value = ["db-tenant-abc123", "system", "neo4j"] + + result = reap_orphaned_tmp_databases() + + assert result == {"dropped_count": 0, "databases": []} + mock_drop.assert_not_called() + + @patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database") + @patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases") + def test_drops_temp_db_with_no_scan_row_past_safety_margin( + self, mock_list, mock_drop + ): + from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases + + old_scan_id = datetime_to_uuid7( + datetime.now(tz=UTC) - MARGIN - timedelta(hours=1) + ) + database = _tmp_db_name(old_scan_id) + mock_list.return_value = [database] + + result = reap_orphaned_tmp_databases() + + assert result == {"dropped_count": 1, "databases": [database]} + mock_drop.assert_called_once_with(database) + + @patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database") + @patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases") + def test_preserves_temp_db_with_no_scan_row_inside_safety_margin( + self, mock_list, mock_drop + ): + from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases + + recent_scan_id = datetime_to_uuid7(datetime.now(tz=UTC) - timedelta(minutes=5)) + database = _tmp_db_name(recent_scan_id) + mock_list.return_value = [database] + + result = reap_orphaned_tmp_databases() + + assert result == {"dropped_count": 0, "databases": []} + mock_drop.assert_not_called() + + @patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database") + @patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases") + def test_preserves_temp_db_with_unparseable_scan_id(self, mock_list, mock_drop): + from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases + + database = f"{TEMP_DB_PREFIX}not-a-uuid" + mock_list.return_value = [database] + + result = reap_orphaned_tmp_databases() + + assert result == {"dropped_count": 0, "databases": []} + mock_drop.assert_not_called() + + @patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database") + @patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases") + def test_drops_terminal_scan_past_safety_margin( + self, mock_list, mock_drop, tenants_fixture, aws_provider + ): + from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases + + tenant = tenants_fixture[0] + old_updated_at = datetime.now(tz=UTC) - MARGIN - timedelta(hours=1) + scan = AttackPathsScan.objects.create( + tenant_id=tenant.id, + provider=aws_provider, + state=StateChoices.COMPLETED, + ) + AttackPathsScan.objects.filter(id=scan.id).update(updated_at=old_updated_at) + + database = _tmp_db_name(scan.id) + mock_list.return_value = [database] + + result = reap_orphaned_tmp_databases() + + assert result == {"dropped_count": 1, "databases": [database]} + mock_drop.assert_called_once_with(database) + + @pytest.mark.parametrize( + "state", + [StateChoices.FAILED, StateChoices.CANCELLED], + ) + @patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database") + @patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases") + def test_drops_other_terminal_states_past_safety_margin( + self, mock_list, mock_drop, tenants_fixture, aws_provider, state + ): + from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases + + tenant = tenants_fixture[0] + old_updated_at = datetime.now(tz=UTC) - MARGIN - timedelta(hours=1) + scan = AttackPathsScan.objects.create( + tenant_id=tenant.id, + provider=aws_provider, + state=state, + ) + AttackPathsScan.objects.filter(id=scan.id).update(updated_at=old_updated_at) + + database = _tmp_db_name(scan.id) + mock_list.return_value = [database] + + result = reap_orphaned_tmp_databases() + + assert result["dropped_count"] == 1 + mock_drop.assert_called_once_with(database) + + @patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database") + @patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases") + def test_preserves_terminal_scan_inside_safety_margin( + self, mock_list, mock_drop, tenants_fixture, aws_provider + ): + from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases + + tenant = tenants_fixture[0] + scan = AttackPathsScan.objects.create( + tenant_id=tenant.id, + provider=aws_provider, + state=StateChoices.COMPLETED, + ) + + database = _tmp_db_name(scan.id) + mock_list.return_value = [database] + + result = reap_orphaned_tmp_databases() + + assert result == {"dropped_count": 0, "databases": []} + mock_drop.assert_not_called() + + @patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database") + @patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases") + def test_margin_counts_from_completed_at_not_updated_at( + self, mock_list, mock_drop, tenants_fixture, aws_provider + ): + from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases + + tenant = tenants_fixture[0] + old = datetime.now(tz=UTC) - MARGIN - timedelta(hours=1) + scan = AttackPathsScan.objects.create( + tenant_id=tenant.id, + provider=aws_provider, + state=StateChoices.COMPLETED, + ) + AttackPathsScan.objects.filter(id=scan.id).update( + updated_at=old, completed_at=datetime.now(tz=UTC) + ) + + database = _tmp_db_name(scan.id) + mock_list.return_value = [database] + + result = reap_orphaned_tmp_databases() + + assert result == {"dropped_count": 0, "databases": []} + mock_drop.assert_not_called() + + @patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database") + @patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases") + def test_drops_scan_completed_past_safety_margin( + self, mock_list, mock_drop, tenants_fixture, aws_provider + ): + from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases + + tenant = tenants_fixture[0] + old = datetime.now(tz=UTC) - MARGIN - timedelta(hours=1) + scan = AttackPathsScan.objects.create( + tenant_id=tenant.id, + provider=aws_provider, + state=StateChoices.FAILED, + ) + AttackPathsScan.objects.filter(id=scan.id).update( + updated_at=old, completed_at=old + ) + + database = _tmp_db_name(scan.id) + mock_list.return_value = [database] + + result = reap_orphaned_tmp_databases() + + assert result == {"dropped_count": 1, "databases": [database]} + mock_drop.assert_called_once_with(database) + + @patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database") + @patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases") + def test_never_drops_an_executing_scan_regardless_of_age( + self, mock_list, mock_drop, tenants_fixture, aws_provider + ): + from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases + + tenant = tenants_fixture[0] + very_old = datetime.now(tz=UTC) - timedelta(days=30) + scan = AttackPathsScan.objects.create( + tenant_id=tenant.id, + provider=aws_provider, + state=StateChoices.EXECUTING, + ) + AttackPathsScan.objects.filter(id=scan.id).update(updated_at=very_old) + + database = _tmp_db_name(scan.id) + mock_list.return_value = [database] + + result = reap_orphaned_tmp_databases() + + assert result == {"dropped_count": 0, "databases": []} + mock_drop.assert_not_called() + + @patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database") + @patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases") + def test_one_failed_drop_does_not_stop_the_rest_of_the_sweep( + self, mock_list, mock_drop + ): + from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases + + old_time = datetime.now(tz=UTC) - MARGIN - timedelta(hours=1) + failing_scan_id = datetime_to_uuid7(old_time) + succeeding_scan_id = datetime_to_uuid7(old_time) + failing_db = _tmp_db_name(failing_scan_id) + succeeding_db = _tmp_db_name(succeeding_scan_id) + mock_list.return_value = [failing_db, succeeding_db] + mock_drop.side_effect = [Exception("boom"), None] + + result = reap_orphaned_tmp_databases() + + assert result == {"dropped_count": 1, "databases": [succeeding_db]} + assert mock_drop.call_count == 2 + + @patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases") + def test_returns_empty_result_when_listing_databases_fails(self, mock_list): + from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases + + mock_list.side_effect = Exception("neo4j unreachable") + + result = reap_orphaned_tmp_databases() + + assert result == {"dropped_count": 0, "databases": []} + + @patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.drop_database") + @patch("tasks.jobs.attack_paths.tmp_db_reaper.graph_database.list_databases") + def test_preserves_temp_db_with_random_uuid_and_no_row(self, mock_list, mock_drop): + """A non-UUIDv7 id with no matching row has no reliable timestamp, so it + must be left alone rather than guessed at.""" + from tasks.jobs.attack_paths.tmp_db_reaper import reap_orphaned_tmp_databases + + database = _tmp_db_name(uuid4()) + mock_list.return_value = [database] + + result = reap_orphaned_tmp_databases() + + assert result == {"dropped_count": 0, "databases": []} + mock_drop.assert_not_called() + + +class TestReapOrphanedTmpDatabasesTask: + @patch( + "tasks.tasks.reap_orphaned_tmp_databases", + return_value={"dropped_count": 2, "databases": ["db-tmp-scan-a"]}, + ) + def test_task_invokes_the_reaper(self, mock_reap): + from tasks.tasks import reap_orphaned_attack_paths_tmp_databases_task + + result = reap_orphaned_attack_paths_tmp_databases_task.run() + + assert result == {"dropped_count": 2, "databases": ["db-tmp-scan-a"]} + mock_reap.assert_called_once_with() diff --git a/api/src/backend/tasks/tests/test_connection.py b/api/src/backend/tasks/tests/test_connection.py index e8b27e0b00..032d34f787 100644 --- a/api/src/backend/tasks/tests/test_connection.py +++ b/api/src/backend/tasks/tests/test_connection.py @@ -3,7 +3,8 @@ from datetime import UTC, datetime from unittest.mock import MagicMock, patch import pytest -from api.models import Integration, LighthouseConfiguration, Provider +from api.models import Integration, LighthouseConfiguration, Provider, ProviderSecret +from api.v1.serializers import KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR from tasks.jobs.connection import ( check_integration_connection, check_lighthouse_connection, @@ -65,17 +66,52 @@ def test_check_provider_connection_exception( mock_provider_connection_test.return_value = MagicMock() mock_provider_connection_test.return_value.is_connected = False - mock_provider_connection_test.return_value.error = Exception() + mock_provider_connection_test.return_value.error = Exception( + "Unable to assume role" + ) result = check_provider_connection(provider_id="provider_id") assert result["connected"] is False - assert result["error"] is not None + assert result["error"] == "Unable to assume role" mock_provider_instance.save.assert_called_once() assert mock_provider_instance.connected is False +@pytest.mark.django_db +def test_check_provider_connection_with_stored_non_inline_kubeconfig( + kubernetes_provider, +): + """A kubeconfig stored before the inline-only rule marks the provider as disconnected.""" + kubernetes_provider.connected = True + kubernetes_provider.save() + ProviderSecret.objects.create( + tenant_id=kubernetes_provider.tenant_id, + provider=kubernetes_provider, + secret_type=ProviderSecret.TypeChoices.STATIC, + secret={ + "kubeconfig_content": ( + "apiVersion: v1\n" + "kind: Config\n" + "users:\n" + "- name: test-user\n" + " user:\n" + " tokenFile: /etc/passwd\n" + ) + }, + ) + + result = check_provider_connection(provider_id=str(kubernetes_provider.id)) + + kubernetes_provider.refresh_from_db() + assert result == { + "connected": False, + "error": KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR, + } + assert kubernetes_provider.connected is False + + @pytest.mark.parametrize( "lighthouse_data", [ diff --git a/api/src/backend/tasks/tests/test_deletion.py b/api/src/backend/tasks/tests/test_deletion.py index 9a6c4acc7c..b4b33c10e5 100644 --- a/api/src/backend/tasks/tests/test_deletion.py +++ b/api/src/backend/tasks/tests/test_deletion.py @@ -4,6 +4,7 @@ import pytest from api.attack_paths import database as graph_database from api.models import Provider, Tenant, TenantComplianceSummary from django.core.exceptions import ObjectDoesNotExist +from django.test import override_settings from tasks.jobs.deletion import delete_provider, delete_tenant @@ -123,6 +124,60 @@ class TestDeleteProvider: "tenant-db", str(instance.id) ) + @override_settings(ATTACK_PATHS_SINK_DATABASE="neo4j") + def test_delete_provider_skips_unconfigured_retired_sink( + self, aws_provider, create_attack_paths_scan + ): + instance = aws_provider + tenant_id = str(instance.tenant_id) + create_attack_paths_scan(instance, sink_backend="neo4j") + create_attack_paths_scan(instance, sink_backend="neptune") + neo4j_backend = MagicMock() + + def get_backend_for_name(name): + if name == "neptune": + raise RuntimeError("NEPTUNE_WRITER_ENDPOINT and AWS_REGION must be set") + return neo4j_backend + + with ( + patch( + "tasks.jobs.deletion.graph_database.get_database_name", + return_value="tenant-db", + ), + patch( + "tasks.jobs.deletion.sink_module.get_backend_for_name", + side_effect=get_backend_for_name, + ), + patch("tasks.jobs.deletion.graph_database.drop_database"), + ): + result = delete_provider(tenant_id, instance.id) + + assert result + assert not Provider.all_objects.filter(pk=instance.id).exists() + neo4j_backend.drop_subgraph.assert_called_once_with( + "tenant-db", str(instance.id) + ) + + @override_settings(ATTACK_PATHS_SINK_DATABASE="neo4j") + def test_delete_provider_raises_when_active_sink_unconfigured( + self, aws_provider, create_attack_paths_scan + ): + instance = aws_provider + tenant_id = str(instance.tenant_id) + create_attack_paths_scan(instance, sink_backend="neo4j") + + with ( + patch( + "tasks.jobs.deletion.sink_module.get_backend_for_name", + side_effect=RuntimeError("NEO4J_HOST / NEO4J_PORT must be set"), + ), + patch("tasks.jobs.deletion.graph_database.drop_database"), + pytest.raises(RuntimeError), + ): + delete_provider(tenant_id, instance.id) + + assert Provider.all_objects.filter(pk=instance.id).exists() + def test_delete_provider_continues_when_temp_db_drop_fails( self, aws_provider, create_attack_paths_scan ): @@ -149,10 +204,10 @@ class TestDeleteProvider: assert result assert not Provider.all_objects.filter(pk=instance.id).exists() + @pytest.mark.usefixtures("provider_compliance_scores_fixture") def test_delete_provider_recalculates_tenant_compliance_summary( self, aws_provider_pair, - provider_compliance_scores_fixture, ): instance = aws_provider_pair[0] tenant_id = instance.tenant_id diff --git a/api/src/backend/tasks/tests/test_export.py b/api/src/backend/tasks/tests/test_export.py index 416361d95e..3fca90e065 100644 --- a/api/src/backend/tasks/tests/test_export.py +++ b/api/src/backend/tasks/tests/test_export.py @@ -3,16 +3,20 @@ import uuid import zipfile from datetime import datetime from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, call, patch +from urllib.parse import parse_qs, urlparse +import boto3 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,15 +51,59 @@ 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") + @override_settings(DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL="http://minio:9000") + def test_get_s3_client_passes_the_endpoint_when_set(self, mock_boto_client): + get_s3_client() + + assert mock_boto_client.call_args.kwargs["endpoint_url"] == "http://minio:9000" + + @patch("tasks.jobs.export.boto3.client") + @override_settings(DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL="") + def test_get_s3_client_endpoint_empty_by_default(self, mock_boto_client): + """Empty keeps today's behavior: no endpoint override, real S3 is used.""" + get_s3_client() + + assert mock_boto_client.call_args.kwargs["endpoint_url"] is None + + @patch("tasks.jobs.export.boto3.client") + @override_settings(DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL="http://minio:9000") + def test_get_s3_client_does_not_fall_back_when_endpoint_set(self, mock_boto_client): + """A configured endpoint means the explicit client failed talking to it. The fallback + goes to the default provider chain (e.g. an EC2 instance role) against real AWS, so it + must not be used: the original error propagates instead.""" + error = ClientError({"Error": {"Code": "403"}}, "ListBuckets") + mock_boto_client.side_effect = error + + with pytest.raises(ClientError): + get_s3_client() + + mock_boto_client.assert_called_once() + @patch("tasks.jobs.export.boto3.client") @patch("tasks.jobs.export.settings") def test_get_s3_client_fallback(self, mock_settings, mock_boto_client): + mock_settings.DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL = "" mock_boto_client.side_effect = [ ClientError({"Error": {"Code": "403"}}, "ListBuckets"), MagicMock(), ] client = get_s3_client() assert client is not None + assert mock_boto_client.call_args_list[1] == call("s3") @patch("tasks.jobs.export.get_s3_client") @patch("tasks.jobs.export.base") @@ -243,3 +291,167 @@ 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_DEFAULT_REGION": ""}, + DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="", + ) + def test_no_public_endpoint_and_no_region_returns_none(self): + # Without a region, SigV4 would have to guess one and break other regions. + assert get_s3_presign_client() is None + + @override_settings(**PRESIGN_SETTINGS, DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="") + def test_region_without_public_endpoint_signs_sigv4_on_the_regional_host(self): + # SSE-KMS objects reject the SigV2 URLs boto3 presigns by default, and the + # global host redirects for new buckets, which breaks a SigV4 signature. + url = urlparse(_presign(get_s3_presign_client())) + query = parse_qs(url.query) + + assert url.netloc == "s3.eu-west-1.amazonaws.com" + assert url.path == "/output-bucket/tenant/scan/report.zip" + assert query["X-Amz-Algorithm"] == ["AWS4-HMAC-SHA256"] + assert "/eu-west-1/s3/aws4_request" in query["X-Amz-Credential"][0] + + @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="", + ) + def test_region_without_static_keys_signs_with_the_default_chain(self, monkeypatch): + # An ECS task role reaches boto3 through the default chain, like the env here. + # A fresh default session keeps these keys from being cached for later tests. + monkeypatch.setattr(boto3, "DEFAULT_SESSION", None) + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "role-access-key") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "role-secret-key") + monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1") + + query = parse_qs(urlparse(_presign(get_s3_presign_client())).query) + + assert query["X-Amz-Credential"][0].startswith("role-access-key/") + assert "/eu-west-1/s3/aws4_request" in query["X-Amz-Credential"][0] + + @override_settings( + **{**PRESIGN_SETTINGS, "DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION": ""}, + DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="", + DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL="http://minio:9000", + ) + def test_internal_endpoint_without_public_endpoint_signs_against_it(self): + # No browser-reachable host was configured, so the internal one is the best + # available target instead of falling through to the real AWS host. + url = urlparse(_presign(get_s3_presign_client())) + + assert url.netloc == "minio:9000" + assert url.path == "/output-bucket/tenant/scan/report.zip" + + @override_settings( + **PRESIGN_SETTINGS, + DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL="https://storage.example.com", + DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL="http://minio:9000", + ) + def test_public_endpoint_wins_over_the_internal_endpoint(self): + url = urlparse(_presign(get_s3_presign_client())) + + assert url.netloc == "storage.example.com" + + @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..213fac207d 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,8 +9,10 @@ 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 ( + AttackSurfaceOverview, Finding, MuteRule, Provider, @@ -2795,6 +2796,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 +4885,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 +4903,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 +4933,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 +4953,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 +4976,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 +4990,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 +5018,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 +5038,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 +5067,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 +5087,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 +5113,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 +5130,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 +5156,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 +5176,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 +5200,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 +5215,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() @@ -5164,8 +5328,13 @@ class TestAggregateAttackSurface: mock_queryset = MagicMock() mock_queryset.values.return_value = mock_queryset mock_queryset.annotate.return_value = [ - {"check_id": "check_internet_1", "total": 10, "failed": 3, "muted": 1}, - {"check_id": "check_secrets_1", "total": 5, "failed": 2, "muted": 0}, + { + "check_id": "check_internet_1", + "total": 10, + "failed": 3, + "muted_count": 1, + }, + {"check_id": "check_secrets_1", "total": 5, "failed": 2, "muted_count": 0}, ] ctx = MagicMock() @@ -5214,7 +5383,7 @@ class TestAggregateAttackSurface: mock_queryset = MagicMock() mock_queryset.values.return_value = mock_queryset mock_queryset.annotate.return_value = [ - {"check_id": "check_internet_1", "total": 5, "failed": 1, "muted": 0}, + {"check_id": "check_internet_1", "total": 5, "failed": 1, "muted_count": 0}, ] ctx = MagicMock() @@ -5297,8 +5466,13 @@ class TestAggregateAttackSurface: mock_queryset = MagicMock() mock_queryset.values.return_value = mock_queryset mock_queryset.annotate.return_value = [ - {"check_id": "check_internet_1", "total": 10, "failed": 3, "muted": 1}, - {"check_id": "check_internet_2", "total": 5, "failed": 2, "muted": 0}, + { + "check_id": "check_internet_1", + "total": 10, + "failed": 3, + "muted_count": 1, + }, + {"check_id": "check_internet_2", "total": 5, "failed": 2, "muted_count": 0}, ] ctx = MagicMock() @@ -5319,6 +5493,62 @@ class TestAggregateAttackSurface: assert overview.failed_findings == 5 # 3 + 2 assert overview.muted_failed_findings == 1 # 1 + 0 + @patch("tasks.jobs.scan._get_attack_surface_mapping_from_provider") + def test_aggregate_attack_surface_counts_real_findings( + self, mock_get_mapping, tenants_fixture, scans_fixture + ): + """Run the aggregation query against real Finding rows. + + The other tests mock the queryset, so they never execute the real + `annotate`. This one guards the row keys the query returns.""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + + mock_get_mapping.return_value = { + "privilege-escalation": {"check_privesc_1"}, + "secrets": {"check_secrets_1"}, + } + + def create_finding(uid, check_id, status, muted): + Finding.objects.create( + tenant_id=tenant.id, + uid=uid, + scan=scan, + status=status, + status_extended="status extended", + impact=Severity.high, + severity=Severity.high, + raw_result={"status": status}, + check_id=check_id, + check_metadata={"CheckId": check_id}, + muted=muted, + first_seen_at="2024-01-02T00:00:00Z", + ) + + create_finding("privesc_fail", "check_privesc_1", Status.FAIL, False) + create_finding("privesc_fail_2", "check_privesc_1", Status.FAIL, False) + create_finding("privesc_fail_muted", "check_privesc_1", Status.FAIL, True) + create_finding("privesc_pass", "check_privesc_1", Status.PASS, False) + create_finding("secrets_pass_muted", "check_secrets_1", Status.PASS, True) + create_finding("unmapped_fail", "check_unmapped", Status.FAIL, False) + + aggregate_attack_surface(str(tenant.id), str(scan.id)) + + overviews = { + overview.attack_surface_type: overview + for overview in AttackSurfaceOverview.objects.filter( + tenant_id=tenant.id, scan_id=scan.id + ) + } + + assert set(overviews) == {"privilege-escalation", "secrets"} + assert overviews["privilege-escalation"].total_findings == 4 + assert overviews["privilege-escalation"].failed_findings == 2 + assert overviews["privilege-escalation"].muted_failed_findings == 1 + assert overviews["secrets"].total_findings == 1 + assert overviews["secrets"].failed_findings == 0 + assert overviews["secrets"].muted_failed_findings == 0 + @patch("tasks.jobs.scan.Scan.all_objects.select_related") @patch("tasks.jobs.scan.rls_transaction") def test_aggregate_attack_surface_uses_select_related( @@ -5804,6 +6034,112 @@ class TestResetEphemeralResourceFindingsCount: resource2.refresh_from_db() assert resource2.failed_findings_count == 5 + def test_runs_when_newer_scan_is_not_full_scope( + self, tenants_fixture, scans_fixture, aws_provider, resources_fixture + ): + """A newer scoped scan must not block the full-scope scan's cleanup. + + The race guard used to pick the newest COMPLETED scan of any scope + despite being named after full-scope ones, so a single scoped scan + landing after a complete one made the complete scan skip its cleanup + and leave ephemeral resources with a stale count permanently. + """ + from datetime import timedelta + + tenant, *_ = tenants_fixture + scan1, *_ = scans_fixture + resource1, resource2, _ = resources_fixture + + Resource.objects.filter(id=resource2.id).update(failed_findings_count=5) + self._make_scan_summary(tenant.id, scan1.id, resource1) + + newer_completed_at = scan1.completed_at + timedelta(minutes=5) + Scan.objects.create( + name="Newer scoped scan", + provider=aws_provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant.id, + started_at=newer_completed_at, + completed_at=newer_completed_at, + scanner_args={"checks": ["check1"]}, + ) + + result = reset_ephemeral_resource_findings_count( + tenant_id=str(tenant.id), scan_id=str(scan1.id) + ) + + assert result["status"] == "completed" + + resource2.refresh_from_db() + assert resource2.failed_findings_count == 0 + + def test_runs_when_many_newer_scans_are_not_full_scope( + self, tenants_fixture, scans_fixture, aws_provider, resources_fixture + ): + """The walk must not give up before it reaches the full-scope scan. + + A fixed look-back window returned None once more newer scoped scans + had landed than it inspected, and skipped the cleanup exactly like the + original bug did with one. + """ + from datetime import timedelta + + tenant, *_ = tenants_fixture + scan1, *_ = scans_fixture + resource1, resource2, _ = resources_fixture + + Resource.objects.filter(id=resource2.id).update(failed_findings_count=5) + self._make_scan_summary(tenant.id, scan1.id, resource1) + + for minutes in range(1, 41): + newer_completed_at = scan1.completed_at + timedelta(minutes=minutes) + Scan.objects.create( + name=f"Newer scoped scan {minutes}", + provider=aws_provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant.id, + started_at=newer_completed_at, + completed_at=newer_completed_at, + scanner_args={"checks": ["check1"]}, + ) + + result = reset_ephemeral_resource_findings_count( + tenant_id=str(tenant.id), scan_id=str(scan1.id) + ) + + assert result["status"] == "completed" + + resource2.refresh_from_db() + assert resource2.failed_findings_count == 0 + + def test_runs_when_completed_at_is_null( + self, tenants_fixture, scans_fixture, aws_provider, resources_fixture + ): + """NULL `completed_at` used to make the reset never run at all. + + The old guard filtered `completed_at__isnull=False`, so a provider + whose completed scans all had a NULL `completed_at` resolved the + "latest" scan to None, which never equals `scan.id`. + """ + tenant, *_ = tenants_fixture + scan1, *_ = scans_fixture + resource1, resource2, _ = resources_fixture + + Scan.all_objects.filter(id=scan1.id).update(completed_at=None) + Resource.objects.filter(id=resource2.id).update(failed_findings_count=5) + self._make_scan_summary(tenant.id, scan1.id, resource1) + + result = reset_ephemeral_resource_findings_count( + tenant_id=str(tenant.id), scan_id=str(scan1.id) + ) + + assert result["status"] == "completed" + + resource2.refresh_from_db() + assert resource2.failed_findings_count == 0 + def test_does_not_touch_other_providers_resources( self, tenants_fixture, scans_fixture, aws_provider, resources_fixture ): diff --git a/api/src/backend/tasks/tests/test_tasks.py b/api/src/backend/tasks/tests/test_tasks.py index 8c846be805..5ab1cdcf54 100644 --- a/api/src/backend/tasks/tests/test_tasks.py +++ b/api/src/backend/tasks/tests/test_tasks.py @@ -1,6 +1,7 @@ +import json 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 @@ -14,6 +15,7 @@ from api.models import ( StateChoices, Task, ) +from api.v1.serializers import TaskSerializer from botocore.exceptions import ClientError from celery import states from django_celery_beat.models import IntervalSchedule, PeriodicTask @@ -32,11 +34,12 @@ from tasks.tasks import ( _scan_tmp_output_directory, check_integrations_task, check_lighthouse_provider_connection_task, + create_scan_task_record, 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 +2962,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 +2986,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 +3187,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 +3198,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 +3217,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 +3263,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 +3272,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 +3284,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 +3322,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() @@ -3439,3 +3387,79 @@ class TestTaskTimeLimits: "lighthouse-provider-connection-check", ): assert celery_app.tasks[name].time_limit < default + + +@pytest.mark.django_db +class TestCreateScanTaskRecord: + """`task_kwargs` is what a response built before the publish can report.""" + + def _scan(self, tenant, provider): + """A manual scan, like the one `POST /api/v1/scans` creates.""" + return Scan.objects.create( + tenant_id=tenant.id, + provider=provider, + name="Manual scan", + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.AVAILABLE, + ) + + def _publish_kwargs(self, tenant, scan): + """What `enqueue_scan_execution_on_commit` publishes for this scan.""" + return { + "tenant_id": str(tenant.id), + "scan_id": str(scan.id), + "provider_id": str(scan.provider_id), + } + + def _task_args(self, task): + """Read the record back the way `TaskSerializer` does.""" + return TaskSerializer(task).data["task_args"] + + def test_the_stored_kwargs_are_the_ones_the_publish_would_send( + self, tenants_fixture, aws_provider + ): + """The 202 reports what is stored here, so it has to be the dispatch kwargs.""" + tenant = tenants_fixture[0] + scan = self._scan(tenant, aws_provider) + + task = create_scan_task_record( + tenant_id=str(tenant.id), + task_id=str(uuid.uuid4()), + task_kwargs=self._publish_kwargs(tenant, scan), + ) + + assert self._task_args(task) == { + "scan_id": str(scan.id), + "provider_id": str(aws_provider.id), + } + + def test_a_record_created_without_kwargs_reports_none(self, tenants_fixture): + """The argument is optional, so the other callers keep their behaviour.""" + task = create_scan_task_record( + tenant_id=str(tenants_fixture[0].id), + task_id=str(uuid.uuid4()), + ) + + assert self._task_args(task) == {} + + def test_the_publish_can_overwrite_the_stored_kwargs( + self, tenants_fixture, aws_provider + ): + """django-celery-results stores a Python repr; both must decode alike.""" + tenant = tenants_fixture[0] + scan = self._scan(tenant, aws_provider) + task_id = str(uuid.uuid4()) + kwargs = self._publish_kwargs(tenant, scan) + + task = create_scan_task_record( + tenant_id=str(tenant.id), task_id=task_id, task_kwargs=kwargs + ) + before = self._task_args(task) + + # What `before_task_publish` writes once the task reaches the broker. + task_result = TaskResult.objects.get(task_id=task_id) + task_result.task_kwargs = json.dumps(repr(kwargs)) + task_result.save(update_fields=["task_kwargs"]) + task.refresh_from_db() + + assert self._task_args(task) == before diff --git a/api/uv.lock b/api/uv.lock index 604e3bc35c..58a1fffa90 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -45,7 +45,7 @@ constraints = [ { name = "alibabacloud-sls20201230", specifier = "==5.9.0" }, { name = "alibabacloud-sts20150401", specifier = "==1.1.6" }, { name = "alibabacloud-tea", specifier = "==0.4.3" }, - { name = "alibabacloud-tea-openapi", specifier = "==0.4.5" }, + { name = "alibabacloud-tea-openapi", specifier = "==0.4.6" }, { name = "alibabacloud-tea-util", specifier = "==0.3.14" }, { name = "alibabacloud-tea-xml", specifier = "==0.0.3" }, { name = "alibabacloud-vpc20160428", specifier = "==6.13.0" }, @@ -53,7 +53,7 @@ constraints = [ { name = "aliyun-log-fastpb", specifier = "==0.2.0" }, { name = "amqp", specifier = "==5.3.1" }, { name = "annotated-types", specifier = "==0.7.0" }, - { name = "anyio", specifier = "==4.12.1" }, + { name = "anyio", specifier = "==4.14.2" }, { name = "applicationinsights", specifier = "==0.11.10" }, { name = "apscheduler", specifier = "==3.11.2" }, { name = "argcomplete", specifier = "==3.5.3" }, @@ -138,7 +138,7 @@ constraints = [ { name = "dill", specifier = "==0.4.1" }, { name = "distro", specifier = "==1.9.0" }, { name = "dj-rest-auth", specifier = "==7.0.1" }, - { name = "django", specifier = "==5.1.15" }, + { name = "django", specifier = "==5.2.17" }, { name = "django-allauth", specifier = "==65.15.0" }, { name = "django-celery-beat", specifier = "==2.9.0" }, { name = "django-celery-results", specifier = "==2.6.0" }, @@ -162,7 +162,7 @@ constraints = [ { name = "drf-simple-apikey", specifier = "==2.2.1" }, { name = "drf-spectacular", specifier = "==0.27.2" }, { name = "drf-spectacular-jsonapi", specifier = "==0.5.1" }, - { name = "dulwich", specifier = "==1.2.5" }, + { name = "dulwich", specifier = "==1.2.9" }, { name = "duo-client", specifier = "==5.5.0" }, { name = "durationpy", specifier = "==0.10" }, { name = "email-validator", specifier = "==2.2.0" }, @@ -254,7 +254,7 @@ constraints = [ { name = "nltk", specifier = "==3.9.4" }, { name = "numpy", specifier = "==2.2.6" }, { name = "oauthlib", specifier = "==3.3.1" }, - { name = "oci", specifier = "==2.183.0" }, + { name = "oci", specifier = "==2.184.1" }, { name = "openai", specifier = "==1.109.1" }, { name = "openstacksdk", specifier = "==4.2.0" }, { name = "opentelemetry-api", specifier = "==1.39.1" }, @@ -291,11 +291,11 @@ constraints = [ { name = "pydantic-core", specifier = "==2.41.5" }, { name = "pygithub", specifier = "==2.8.0" }, { name = "pygments", specifier = "==2.20.0" }, - { name = "pyjwt", specifier = "==2.13.0" }, + { name = "pyjwt", specifier = "==2.14.0" }, { name = "pylint", specifier = "==3.2.5" }, { name = "pymsalruntime", specifier = "==0.18.1" }, { name = "pynacl", specifier = "==1.6.2" }, - { name = "pyopenssl", specifier = "==26.2.0" }, + { name = "pyopenssl", specifier = "==26.4.0" }, { name = "pyparsing", specifier = "==3.3.2" }, { name = "pyreadline3", specifier = "==3.5.4" }, { name = "pysocks", specifier = "==1.7.1" }, @@ -339,7 +339,7 @@ constraints = [ { name = "six", specifier = "==1.17.0" }, { name = "slack-sdk", specifier = "==3.39.0" }, { name = "sniffio", specifier = "==1.3.1" }, - { name = "sqlparse", specifier = "==0.5.5" }, + { name = "sqlparse", specifier = "==0.6.0" }, { name = "statsd", specifier = "==4.0.1" }, { name = "std-uritemplate", specifier = "==2.0.8" }, { name = "stevedore", specifier = "==5.6.0" }, @@ -355,7 +355,7 @@ constraints = [ { name = "tzdata", specifier = "==2025.3" }, { name = "tzlocal", specifier = "==5.3.1" }, { name = "uritemplate", specifier = "==4.2.0" }, - { name = "urllib3", specifier = "==2.7.0" }, + { name = "urllib3", specifier = "==2.8.0" }, { name = "uuid6", specifier = "==2024.7.10" }, { name = "uvicorn", specifier = "==0.49.0" }, { name = "uvloop", specifier = "==0.22.1" }, @@ -373,12 +373,12 @@ constraints = [ { name = "zipp", specifier = "==3.23.0" }, { name = "zope-event", specifier = "==6.1" }, { name = "zope-interface", specifier = "==8.2" }, - { name = "zstd", specifier = "==1.5.7.3" }, + { name = "zstd", specifier = "==1.5.7.2" }, ] overrides = [ { name = "azure-mgmt-containerservice", specifier = "==34.1.0" }, { name = "cryptography", specifier = "==50.0.0" }, - { name = "dulwich", specifier = "==1.2.5" }, + { name = "dulwich", specifier = "==1.2.9" }, { name = "microsoft-kiota-abstractions", specifier = "==1.9.10" }, { name = "microsoft-kiota-authentication-azure", specifier = "==1.9.10" }, { name = "microsoft-kiota-http", specifier = "==1.9.10" }, @@ -387,7 +387,8 @@ overrides = [ { name = "microsoft-kiota-serialization-multipart", specifier = "==1.9.10" }, { name = "microsoft-kiota-serialization-text", specifier = "==1.9.10" }, { name = "okta", specifier = "==3.4.2" }, - { name = "pyjwt", extras = ["crypto"], specifier = "==2.13.0" }, + { name = "pyjwt", extras = ["crypto"], specifier = "==2.14.0" }, + { name = "pymongo", specifier = "==4.18.2" }, ] [[package]] @@ -860,7 +861,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/9a/7d/b22cb9a0d4f396ee0 [[package]] name = "alibabacloud-tea-openapi" -version = "0.4.5" +version = "0.4.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "alibabacloud-credentials" }, @@ -869,9 +870,9 @@ dependencies = [ { name = "cryptography" }, { name = "darabonba-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3b/73/fb0c4d44759791ecdf269fc715c1e810fa1aba3981bfaaf8a01f61899296/alibabacloud_tea_openapi-0.4.5.tar.gz", hash = "sha256:75fa1f4360a46e41f5bf5f8d4917e52efb6f64885839bc1328c35590670c97b9", size = 26616, upload-time = "2026-07-14T13:15:39.364Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/34/1918a2d780676494365c7f945bfab397ecddb988054d78025bd26f438977/alibabacloud_tea_openapi-0.4.6.tar.gz", hash = "sha256:dafc32401712f5b21c12dc3d05ba887a91ad156d9b49a7662279f9fd90526fb2", size = 26742, upload-time = "2026-08-17T08:34:11.55Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/ec/6b368a10e9c2e8b1b394c69b96ac213ae66e8c4895e0baa1ffaf7178fd32/alibabacloud_tea_openapi-0.4.5-py3-none-any.whl", hash = "sha256:338979095c7beda80a5b413c31262892cafdc12069dde4ce4fc2e4f7ce0fc609", size = 33333, upload-time = "2026-07-14T13:15:38.365Z" }, + { url = "https://files.pythonhosted.org/packages/35/00/2f534f5884e5f299d9cb3a1e8be2def8071bc6a6e2a192ba4ff2a8cd5e02/alibabacloud_tea_openapi-0.4.6-py3-none-any.whl", hash = "sha256:c9e1727b9fb2936f487d050fc3590c99f9f2065256dc3a927e5b61f414674ed6", size = 33448, upload-time = "2026-08-17T08:34:10.472Z" }, ] [[package]] @@ -969,15 +970,15 @@ wheels = [ [[package]] name = "anyio" -version = "4.12.1" +version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] [[package]] @@ -2329,16 +2330,16 @@ with-social = [ [[package]] name = "django" -version = "5.1.15" +version = "5.2.17" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, { name = "sqlparse" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/10/45/1ac68964193cfcc0b0912a0f68025d5bdb54f71ba7b8716e85b959874bd0/django-5.1.15.tar.gz", hash = "sha256:46a356b5ff867bece73fc6365e081f21c569973403ee7e9b9a0316f27d0eb947", size = 10719662, upload-time = "2025-12-02T14:01:31.931Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/d8/43e9d000519adceb189620b6869ff88031e046df91c2e9da72f8f6918399/django-5.2.17.tar.gz", hash = "sha256:9d4d93be539a18ab80d058eb515900e10951e04c537c5a6b394fc49528d3251f", size = 10889740, upload-time = "2026-08-04T15:04:03.173Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/79/372e091f0eba4ddb8228245ccd1baaa140e9658711f5e3a0056e540b4c1e/django-5.1.15-py3-none-any.whl", hash = "sha256:117871e58d6eda37f09870b7d73a3d66567b03aecd515b386b1751177c413432", size = 8260901, upload-time = "2025-12-02T14:01:27.352Z" }, + { url = "https://files.pythonhosted.org/packages/df/f8/ce120525ca78f12b07daf65786679c5d0b54a75285a8958d3ae55e39da35/django-5.2.17-py3-none-any.whl", hash = "sha256:f04fb3b36ee119e1af4fa1d397d5fd6cf12700f49321e84d4f4c642c5b1973db", size = 8315563, upload-time = "2026-08-04T15:03:59.1Z" }, ] [[package]] @@ -2667,27 +2668,27 @@ wheels = [ [[package]] name = "dulwich" -version = "1.2.5" +version = "1.2.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.12'" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7f/85/ceb8ecff5cdeee4ceeebb86b599476dee559041dacc6c2c50cc0d4711549/dulwich-1.2.5.tar.gz", hash = "sha256:0395b2c8924c3424bafe2d9c1edd5348cc4b21ce9c1d6655bf01f9a5c47164c8", size = 1253230, upload-time = "2026-05-28T22:27:55.17Z" } +sdist = { url = "https://files.pythonhosted.org/packages/de/17/0a0fa2b998cd9a852b1aa901e3f6145f13a4334f64b91564847e5880968d/dulwich-1.2.9.tar.gz", hash = "sha256:b395466b78eb5c36baf34381b8b831fd26da8a44b428a2eea1d57ba53f68a234", size = 1309797, upload-time = "2026-07-06T20:57:23.202Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/4a/654ae1671610fdf6b65a64586ad67ddd8550d4d08a632b2a4b9614754b6d/dulwich-1.2.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:556593fd11637f80f6018bee1916b1a84f5b420423b470ebb3f1a782ad6ef081", size = 1399277, upload-time = "2026-05-28T22:27:00.801Z" }, - { url = "https://files.pythonhosted.org/packages/85/d8/06ee3bc8eded4bd7adf8adf0c9ea5f19bf96f7e5e626bfaf7311cde4208a/dulwich-1.2.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a70477c991e96cfe8fdd7c866e7251faf71b38bfeb51d6f27554c9cce1caabf3", size = 1382310, upload-time = "2026-05-28T22:27:02.216Z" }, - { url = "https://files.pythonhosted.org/packages/07/17/a03adf50b9095f9f5d863393f21d585dea39bdc4fdf60788ff3a9407a512/dulwich-1.2.5-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:9008ef25cabd379cda4fa86000fc38ca14b72afe17db798a8c85c0b2b7ce4d1e", size = 1470993, upload-time = "2026-05-28T22:27:04.075Z" }, - { url = "https://files.pythonhosted.org/packages/60/58/1dc352d2a5e80befe4338af7208febb44bcfd7496b0dde5ac6dacb07b031/dulwich-1.2.5-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a5549f4afc973e0a15ea6b0244d57f848d3f3ee13dac557eb311024aebebf128", size = 1497820, upload-time = "2026-05-28T22:27:05.549Z" }, - { url = "https://files.pythonhosted.org/packages/c1/a8/e058959a87e7df7753b112ef66a43ccbc57338c1bbdc23a0edf3833396df/dulwich-1.2.5-cp311-cp311-win32.whl", hash = "sha256:5108acead814d1de8b6262d6d8fb90af7e82f5a4d83788b6b48e39d01800a92f", size = 1066549, upload-time = "2026-05-28T22:27:06.832Z" }, - { url = "https://files.pythonhosted.org/packages/33/91/ff0b444f686718635348986bd73dfce42e947912417893de35de399b878b/dulwich-1.2.5-cp311-cp311-win_amd64.whl", hash = "sha256:5e067b7feceb7034bc99e7c7143a704f1d97d4be7027d9a0aa5a83c0657ff091", size = 1079481, upload-time = "2026-05-28T22:27:08.33Z" }, - { url = "https://files.pythonhosted.org/packages/19/22/4f75770bbe5521cac61c4820ef46d4fbf8c2175d3519ba3d0378d4ba798e/dulwich-1.2.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:701a9ecf7a8a44f5e2459e46befa93530cf36a8b1ae3140aefc007db1d7d0207", size = 1396522, upload-time = "2026-05-28T22:27:09.997Z" }, - { url = "https://files.pythonhosted.org/packages/e5/b1/c07c347681c0cf6acd4b189bf6e8d6207c71a1347b7a1e865eb40faa46b9/dulwich-1.2.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f90d68bfa97c4ca71de7507984365aefe27b6d248cb28dc99644d0f3ae8c60b", size = 1334826, upload-time = "2026-05-28T22:27:11.582Z" }, - { url = "https://files.pythonhosted.org/packages/13/80/6818eb7ce492e18ab2efa92ab901d173b4b0b159e5681c1424f329600c40/dulwich-1.2.5-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:00b54a1d56ddbacdd8eadd6d4787a51b3a05fefa30eadbf9165fd283a00b90ed", size = 1416616, upload-time = "2026-05-28T22:27:13.195Z" }, - { url = "https://files.pythonhosted.org/packages/14/a7/9790e60d19870f6554f7583722bb324c1355784316f20aeda1c0b5b1491a/dulwich-1.2.5-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d8f7ea8f47e38e5b0de3fab97e07e9c9161ffddc90b3964512cab2b7749df4e6", size = 1441354, upload-time = "2026-05-28T22:27:14.683Z" }, - { url = "https://files.pythonhosted.org/packages/91/44/0ea8a69c24aa1254ff5996d682eae2eab287d471b937dcdb26d9ea9720b4/dulwich-1.2.5-cp312-cp312-win32.whl", hash = "sha256:8929134acf4ff967203df7600b38535f9b5b590462067a7e30dbce01acb97af9", size = 1017058, upload-time = "2026-05-28T22:27:16.121Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9c/2fcddda7faec3bae52db7c64bfcb5dc756f597f33fae90e8d4e4b4d3b39b/dulwich-1.2.5-cp312-cp312-win_amd64.whl", hash = "sha256:9693d2c9e226b2ea855c1dc3a87e2f4d972f7523fc0f7924e5997e9f4c23d97f", size = 1031731, upload-time = "2026-05-28T22:27:17.633Z" }, - { url = "https://files.pythonhosted.org/packages/07/4b/4a18a59ad230581cd0ef460e96001f90762e566dc2dfdba22aa358eb5a0e/dulwich-1.2.5-py3-none-any.whl", hash = "sha256:1679b376433a0fc7f36586afda1d4ed7427afa7a79d4bf17e5014474eea69fa4", size = 686745, upload-time = "2026-05-28T22:27:53.695Z" }, + { url = "https://files.pythonhosted.org/packages/95/70/0345eb2b07a9b26337d7e9ee2c0fc634be81de9969d683b72eca04878f72/dulwich-1.2.9-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:b01fd4d8bd4fdd2bd99eb465e101b4fac2b50436e317b971da7ac3a1bd3a83d8", size = 1378231, upload-time = "2026-07-06T20:56:20.301Z" }, + { url = "https://files.pythonhosted.org/packages/56/dd/a699facf7371cb26233ea1f659ff172995cc06a999ba07992d2762a7dc1f/dulwich-1.2.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e92f76f0048b076ef692094d28645c5e0f65dae1d5e393fbb2e8db2edc017cd3", size = 1371812, upload-time = "2026-07-06T20:56:21.958Z" }, + { url = "https://files.pythonhosted.org/packages/35/02/73baf09f61b66431adb1cd01a9fbe748369a62814b6e27362daaaa924f04/dulwich-1.2.9-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:ac47e02988bdcde0c23f98275908390448617d491ad5d9cb9f33cf3eb8ad512b", size = 1493888, upload-time = "2026-07-06T20:56:23.697Z" }, + { url = "https://files.pythonhosted.org/packages/48/e4/aa2dff296c4a9cd769b5a55a8de68622c1605ad39d75b741527e63117acf/dulwich-1.2.9-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:5e6eb07fbd288059da71019f6746655cd1e136db2f1664fa3e1c81bc11973187", size = 1523051, upload-time = "2026-07-06T20:56:25.525Z" }, + { url = "https://files.pythonhosted.org/packages/24/da/73162905ffb2041a8f484e46c7bb269cb39cc984335b1e66eef23d38cb1d/dulwich-1.2.9-cp311-cp311-win32.whl", hash = "sha256:e96f5d29bf509ceb2dbf9cf4de67fe6e5d785b22f721814360563be6757e7473", size = 1043350, upload-time = "2026-07-06T20:56:27.443Z" }, + { url = "https://files.pythonhosted.org/packages/ab/e6/8e40755771b5d05c9268771f26cef8f2ba5f7a849bedcee06504d7f842ae/dulwich-1.2.9-cp311-cp311-win_amd64.whl", hash = "sha256:255c4c3605582fe8958afeff94fc1f0883aedc37495c43a74e031b904f3bb86d", size = 1103738, upload-time = "2026-07-06T20:56:29.145Z" }, + { url = "https://files.pythonhosted.org/packages/ab/b0/e4f14c603fa42e07d8a7f1d5a73f123a8148d52b79b3cc478797126dcdd3/dulwich-1.2.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d5a2896b6dc3f0c794d34b3eb1af317a43b557fefbee0099ef83d35bb6e102f", size = 1372001, upload-time = "2026-07-06T20:56:30.605Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b7/11c40f1c3ce304e7c2e0e759c589430f043a7967689e61ecb0142ad1ebdc/dulwich-1.2.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:30e72110630e2990d11770227191dfa730b7c045030f818eca5803c6b89bbc5b", size = 1356130, upload-time = "2026-07-06T20:56:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/10d61b784c295a4567615579272d91ede81793c72ead207484c0ed845ddd/dulwich-1.2.9-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:5c636fcb33c4db5495a2dc7686cf6a26cf537060a859982e104dcf67635ea7f7", size = 1437923, upload-time = "2026-07-06T20:56:33.741Z" }, + { url = "https://files.pythonhosted.org/packages/6e/68/3073e2737e2c3f299e736490ec2e8b5864593858b9d08f36fbcbca80a862/dulwich-1.2.9-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:1aa1b55f59a046fc8f25a67560473c789b7ec113b85c6a08021aae10c6f78945", size = 1463517, upload-time = "2026-07-06T20:56:35.812Z" }, + { url = "https://files.pythonhosted.org/packages/f2/61/f96c0da72b08bfbc4e38571374c3000dee13e6d72dd06f25281d36d5b0b7/dulwich-1.2.9-cp312-cp312-win32.whl", hash = "sha256:e27a06f9f71275d712b616ad23008df5f2dcf54a06cb8e2a24be8819ee1ce13a", size = 1082071, upload-time = "2026-07-06T20:56:38.139Z" }, + { url = "https://files.pythonhosted.org/packages/38/96/567f32885e780aaa7441f9f5c6f162e7271d149e27d3cb661caf939cd671/dulwich-1.2.9-cp312-cp312-win_amd64.whl", hash = "sha256:81e1005ffc5d5c872bfc87ac3442f722bce27ba10e435543d5302853c34abf56", size = 1053612, upload-time = "2026-07-06T20:56:39.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f9/a6677c0e698b5b781d663eb394bd15cc69c4a270b11cdcc128076753d75c/dulwich-1.2.9-py3-none-any.whl", hash = "sha256:79ff0ff35c4e17ccd58f9bfb23f4f8d416b0b09337b8c99ee3b4f10459bb0d68", size = 711459, upload-time = "2026-07-06T20:57:21.516Z" }, ] [[package]] @@ -4202,7 +4203,7 @@ wheels = [ [package.optional-dependencies] broker = [ - { name = "pymsalruntime", marker = "sys_platform == 'win32'" }, + { name = "pymsalruntime" }, ] [[package]] @@ -4426,7 +4427,7 @@ wheels = [ [[package]] name = "oci" -version = "2.183.0" +version = "2.184.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -4439,9 +4440,9 @@ dependencies = [ { name = "pytz" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1e/2a/77bd6cbf1c69b2f368fe3d6462d84369b0cba15e37ce713cdc08d459b95a/oci-2.183.0.tar.gz", hash = "sha256:ff572ef5f2030a788796bb509d257e6a41c6510ef9b4b6a75a079efd06e533ce", size = 17759723, upload-time = "2026-07-28T06:02:29.76Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/2d/fa5368cfabb868f4111c6978e8b5f66aa3a55076c40c1a59ac3081b0227b/oci-2.184.1.tar.gz", hash = "sha256:617dad69caf8dd6e521d224dbc3e8a8bc289906943a0214fd2c3419094e26435", size = 17990631, upload-time = "2026-08-11T11:01:26.194Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/de/8574b3e527996a099d196e87794a4652d91a0c3185fcc7fdbb5649b75a8a/oci-2.183.0-py3-none-any.whl", hash = "sha256:bd789c98a94d7c5ea08c20d11dcf68c9cd1ad479b134727d80a930b84387070b", size = 36133501, upload-time = "2026-07-28T06:02:18.239Z" }, + { url = "https://files.pythonhosted.org/packages/5f/63/5ae22e42aaf96a5da74dc2b9de449c78b4d7418cce621d5da723b3e49f32/oci-2.184.1-py3-none-any.whl", hash = "sha256:bd814e38a70da2190e721937455a08689ab13c0750bd2ef8dd0c98b2dc5a38ea", size = 36628063, upload-time = "2026-08-11T11:01:18.178Z" }, ] [[package]] @@ -4835,8 +4836,8 @@ wheels = [ [[package]] name = "prowler" -version = "5.38.0" -source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#b3d174d0c1eb202ed7cb9a9daf0500683f4443be" } +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 +4929,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.40.0" +version = "1.46.0" source = { virtual = "." } dependencies = [ { name = "cartography" }, @@ -5009,7 +5013,7 @@ requires-dist = [ { name = "celery", specifier = "==5.6.2" }, { name = "defusedxml", specifier = "==0.7.1" }, { name = "dj-rest-auth", extras = ["with-social", "jwt"], specifier = "==7.0.1" }, - { name = "django", specifier = "==5.1.15" }, + { name = "django", specifier = "==5.2.17" }, { name = "django-allauth", extras = ["saml"], specifier = "==65.15.0" }, { name = "django-celery-beat", specifier = "==2.9.0" }, { name = "django-celery-results", specifier = "==2.6.0" }, @@ -5040,7 +5044,7 @@ requires-dist = [ { name = "pytest-celery", extras = ["redis"], specifier = "==1.3.0" }, { name = "reportlab", specifier = "==4.4.10" }, { name = "sentry-sdk", extras = ["django"], specifier = "==2.56.0" }, - { name = "sqlparse", specifier = "==0.5.5" }, + { name = "sqlparse", specifier = "==0.6.0" }, { name = "uuid6", specifier = "==2024.7.10" }, { name = "uvicorn-worker", specifier = "==0.4.0" }, { name = "uvloop", specifier = "==0.22.1" }, @@ -5329,11 +5333,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.13.0" +version = "2.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" }, ] [package.optional-dependencies] @@ -5361,33 +5365,33 @@ wheels = [ [[package]] name = "pymongo" -version = "4.15.1" +version = "4.18.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "dnspython" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/22/f5/c0c6732fbd358b75a07e17d7e588fd23d481b9812ca96ceeff90bbf879fc/pymongo-4.15.1.tar.gz", hash = "sha256:b9f379a4333dc3779a6bf7adfd077d4387404ed1561472743486a9c58286f705", size = 2470613, upload-time = "2025-09-16T16:39:47.24Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/04/455039b475bb8afa5af9b8c363fb773a45d898016f04ee40c1c75003a3f9/pymongo-4.18.2.tar.gz", hash = "sha256:1bd4321cc63c06954953eae9eb8fb5d9075296b96c0e3ed34ccb91a0dcce31a5", size = 2746691, upload-time = "2026-09-24T16:59:59.472Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/da/89066930a70b4299844f1155fc23baaa7e30e77c8a0cbf62a2ae06ee34a5/pymongo-4.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:363445cc0e899b9e55ac9904a868c8a16a6c81f71c48dbadfd78c98e0b54de27", size = 865410, upload-time = "2025-09-16T16:38:16.279Z" }, - { url = "https://files.pythonhosted.org/packages/99/8f/a1d0402d52e5ebd14283718abefdc0c16f308cf10bee56cdff04b1f5119b/pymongo-4.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:da0a13f345f4b101776dbab92cec66f0b75015df0b007b47bd73bfd0305cc56a", size = 865695, upload-time = "2025-09-16T16:38:18.015Z" }, - { url = "https://files.pythonhosted.org/packages/53/38/d1ef69028923f86fd00638d9eb16400d4e60a89eabd2011fe631fd3186cf/pymongo-4.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9481a492851e432122a83755d4e69c06aeb087bbf8370bac9f96d112ac1303fd", size = 1434758, upload-time = "2025-09-16T16:38:20.141Z" }, - { url = "https://files.pythonhosted.org/packages/b0/eb/a8d5dff748a2dd333610b2e4c8120b623e38ea2b5e30ad190d0ce2803840/pymongo-4.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:625dec3e9cd7c3d336285a20728c01bfc56d37230a99ec537a6a8625af783a43", size = 1485716, upload-time = "2025-09-16T16:38:21.607Z" }, - { url = "https://files.pythonhosted.org/packages/c4/d4/17ba457a828b733182ddc01a202872fef3006eed6b54450b20dc95a2f77d/pymongo-4.15.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26a31af455bffcc64537a7f67e2f84833a57855a82d05a085a1030c471138990", size = 1460160, upload-time = "2025-09-16T16:38:23.509Z" }, - { url = "https://files.pythonhosted.org/packages/c3/25/42b8662c09f5ca9c81d18d160f48e58842e0fa4c314ea02613c5e5d54542/pymongo-4.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea4415970d2a074d5890696af10e174d84cb735f1fa7673020c7538431e1cb6e", size = 1439284, upload-time = "2025-09-16T16:38:25.248Z" }, - { url = "https://files.pythonhosted.org/packages/b3/bb/46b9d978161828eb91973bd441a3f05f73c789203e976332a8de2832d5db/pymongo-4.15.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:51ee050a2e026e2b224d2ed382830194be20a81c78e1ef98f467e469071df3ac", size = 1407933, upload-time = "2025-09-16T16:38:27.045Z" }, - { url = "https://files.pythonhosted.org/packages/4b/55/bd5af98f675001f4b06f7314b3918e45809424a7ad3510f823f6703cd8f2/pymongo-4.15.1-cp311-cp311-win32.whl", hash = "sha256:9aef07d33839f6429dc24f2ef36e4ec906979cb4f628c57a1c2676cc66625711", size = 844328, upload-time = "2025-09-16T16:38:28.513Z" }, - { url = "https://files.pythonhosted.org/packages/c3/78/90989a290dd458ed43a8a04fa561ac9c7b3391f395cdacd42e21f0f22ce4/pymongo-4.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ea6e5ff4d6747e7b64966629a964db3089e9c1e0206d8f9cc8720c90f5a7af1", size = 858951, upload-time = "2025-09-16T16:38:30.074Z" }, - { url = "https://files.pythonhosted.org/packages/de/bb/d4d23f06e166cd773f2324cff73841a62d78a1ad16fb799cf7c5490ce32c/pymongo-4.15.1-cp311-cp311-win_arm64.whl", hash = "sha256:bb783d9001b464a6ef3ee76c30ebbb6f977caee7bbc3a9bb1bd2ff596e818c46", size = 848290, upload-time = "2025-09-16T16:38:31.741Z" }, - { url = "https://files.pythonhosted.org/packages/7e/31/bc4525312083706a59fffe6e8de868054472308230fdee8db0c452c2b831/pymongo-4.15.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bab357c5ff36ba2340dfc94f3338ef399032089d35c3d257ce0c48630b7848b2", size = 920261, upload-time = "2025-09-16T16:38:33.614Z" }, - { url = "https://files.pythonhosted.org/packages/ae/55/4d99aec625494f21151b8b31e12e06b8ccd3b9dcff609b0dd1acf9bbbc0e/pymongo-4.15.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46d1af3eb2c274f07815372b5a68f99ecd48750e8ab54d5c3ff36a280fb41c8e", size = 919956, upload-time = "2025-09-16T16:38:35.121Z" }, - { url = "https://files.pythonhosted.org/packages/be/60/8f1afa41521df950e13f6490ecdef48155fc63b78f926e7649045e07afd1/pymongo-4.15.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7dc31357379318881186213dc5fc49b62601c955504f65c8e72032b5048950a1", size = 1698596, upload-time = "2025-09-16T16:38:36.586Z" }, - { url = "https://files.pythonhosted.org/packages/bc/3f/e48d50ee8d6aa0a4cda7889dd73076ec2ab79a232716a5eb0b9df070ffcf/pymongo-4.15.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12140d29da1ecbaefee2a9e65433ef15d6c2c38f97bc6dab0ff246a96f9d20cd", size = 1762833, upload-time = "2025-09-16T16:38:38.09Z" }, - { url = "https://files.pythonhosted.org/packages/63/87/db976859efc617f608754e051e1468459d9a818fe1ad5d0862e8af57720b/pymongo-4.15.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cf193d2dcd91fa1d1dfa1fd036a3b54f792915a4842d323c0548d23d30461b59", size = 1731875, upload-time = "2025-09-16T16:38:39.742Z" }, - { url = "https://files.pythonhosted.org/packages/18/59/3643ad52a5064ad3ef8c32910de6da28eb658234c25f2db5366f16bffbfb/pymongo-4.15.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2c0bdcf4d57e4861ed323ba430b585ad98c010a83e46cb8aa3b29c248a82be1", size = 1701853, upload-time = "2025-09-16T16:38:41.333Z" }, - { url = "https://files.pythonhosted.org/packages/d8/96/441c190823f855fc6445ea574b39dca41156acf723c5e6a69ee718421700/pymongo-4.15.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43fcfc19446e0706bbfe86f683a477d1e699b02369dd9c114ec17c7182d1fe2b", size = 1660978, upload-time = "2025-09-16T16:38:42.877Z" }, - { url = "https://files.pythonhosted.org/packages/47/49/bd7e783fb78aaf9bdaa3f88cc238449be5bc5546e930ec98845ef235f809/pymongo-4.15.1-cp312-cp312-win32.whl", hash = "sha256:e5fedea0e7b3747da836cd5f88b0fa3e2ec5a394371f9b6a6b15927cfeb5455d", size = 891175, upload-time = "2025-09-16T16:38:44.658Z" }, - { url = "https://files.pythonhosted.org/packages/2e/28/7de5858bdeaa07ea4b277f9eb06123ea358003659fe55e72e4e7c898b321/pymongo-4.15.1-cp312-cp312-win_amd64.whl", hash = "sha256:330a17c1c89e2c3bf03ed391108f928d5881298c17692199d3e0cdf097a20082", size = 910619, upload-time = "2025-09-16T16:38:46.124Z" }, - { url = "https://files.pythonhosted.org/packages/17/87/c39f4f8415e7c65f8b66413f53a9272211ff7dfe78a5128b27027bf88864/pymongo-4.15.1-cp312-cp312-win_arm64.whl", hash = "sha256:756b7a2a80ec3dd5b89cd62e9d13c573afd456452a53d05663e8ad0c5ff6632b", size = 896229, upload-time = "2025-09-16T16:38:48.563Z" }, + { url = "https://files.pythonhosted.org/packages/65/01/e8358c562ce4e3255035f16eed43a6f77669b0779f3aa918362c670e869f/pymongo-4.18.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:257519b59d8509a950b21c4a626e5295b3ad4397c12ec47e6e93353c49236145", size = 818562, upload-time = "2026-09-24T16:58:20.602Z" }, + { url = "https://files.pythonhosted.org/packages/1f/18/48a9e7f44a35560284b36bba24622a959ec1dd940e7c4f7cd308cfdba756/pymongo-4.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:845b890622e7a2f031140f9cb1c0f266c1baa876ea359f7e51d0c38112cb3c6e", size = 819081, upload-time = "2026-09-24T16:58:22.043Z" }, + { url = "https://files.pythonhosted.org/packages/fc/24/2d9c0bb5649f262257f507b03f84419ac1254e94894cd493bda87fb935e1/pymongo-4.18.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a746f7e183c6d48aba6152d9f333e3bae2e8b6b00fac7abe69d4bdb3d10915c8", size = 1023284, upload-time = "2026-09-24T16:58:23.437Z" }, + { url = "https://files.pythonhosted.org/packages/70/40/af888435ce8e3ae1e1a201b87fb397aa91e3ce115bb1bb3999197f2b9b72/pymongo-4.18.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf9d13d812e55d406080b8ec3d5b9ef8eb2279bac32529e4f7b41cfefd5340e2", size = 1031549, upload-time = "2026-09-24T16:58:24.869Z" }, + { url = "https://files.pythonhosted.org/packages/3c/66/f5053862cbeddf37b3184870fa179a3e9d9e27e668c720bb7ab28b65b753/pymongo-4.18.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:930e8bcdf781a73d3df05976f5eaca1193102571cb91726e30009d64878ef8ce", size = 1054025, upload-time = "2026-09-24T16:58:26.229Z" }, + { url = "https://files.pythonhosted.org/packages/70/29/4d7c9d7cb718ea7bbac56465de3aea5f79d1266683c16bdcd40e77746530/pymongo-4.18.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a695bd34d1e942eca93a186e679be196e288370f1a203e383b2187c06349e348", size = 1046337, upload-time = "2026-09-24T16:58:27.822Z" }, + { url = "https://files.pythonhosted.org/packages/d8/aa/c18c564883c5b2616744d1ee7ab073a3b4045aaa4bbbd524105448d4f630/pymongo-4.18.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d95e339e94b82950bfd62ec9ff8fea9bc8dfd9db0940e9134d1647d366560df5", size = 1032845, upload-time = "2026-09-24T16:58:29.518Z" }, + { url = "https://files.pythonhosted.org/packages/20/16/f8a1cb7ac3f1c2c45649f18b7cb398779b4b45e1954bbfcb9317c74c744d/pymongo-4.18.2-cp311-cp311-win32.whl", hash = "sha256:2e2a3b816270c493f6645a168150ecbfb19a0bd0d9d3b56be728a847954da717", size = 814203, upload-time = "2026-09-24T16:58:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/04/53/e9d6350ea753b7a72d0346fb2c8e0c589e0eb443b5041989f3b4ea52ce55/pymongo-4.18.2-cp311-cp311-win_amd64.whl", hash = "sha256:812b4ad5fe577f829cdb4e39a5601025b9cd7f2e3f8cd373cd5ab64ce78b0fb0", size = 821265, upload-time = "2026-09-24T16:58:32.479Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2a/be9693eca04bb6f53f3bdb027220a210bd429c7bf902b79538471849297e/pymongo-4.18.2-cp311-cp311-win_arm64.whl", hash = "sha256:c36cadbc3a7712575900c6f50cdf24ebe2b376b6ad643cb5df1b3dce93447eae", size = 815444, upload-time = "2026-09-24T16:58:34.212Z" }, + { url = "https://files.pythonhosted.org/packages/10/eb/995ec978741cfc56a29223ec0806976218b95d79a516d9b719c984ead130/pymongo-4.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:78a3b324407ba7228a57f207e175efc40341c1312d1e7df2d4ad8141ff6d8e00", size = 819057, upload-time = "2026-09-24T16:58:35.844Z" }, + { url = "https://files.pythonhosted.org/packages/40/e4/5a166bbac7f6e620cfb75b0640e793dd3ce69423a1f89cf6a0bfc588c107/pymongo-4.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:498187b5171d5efa87291b30e4cbf1e9849929c02bc436e95a3d01ff19e49f42", size = 819448, upload-time = "2026-09-24T16:58:37.221Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c8/82194d9a1387a56cac996901f92ea6a25ae817622ee32d15c986638b263f/pymongo-4.18.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2c003646d3fa5db4e835483fc29018eb06d8c637cf69f676ffbb3f0734005220", size = 1039602, upload-time = "2026-09-24T16:58:38.821Z" }, + { url = "https://files.pythonhosted.org/packages/cc/06/628f93f15f269d8a1ea20a024083e67871f722876a33992aec5525a6c6c4/pymongo-4.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c743347143298252948e038f768ea6f71b71ef8a85b7cf0dc13b734e072f74da", size = 1050746, upload-time = "2026-09-24T16:58:40.322Z" }, + { url = "https://files.pythonhosted.org/packages/bf/93/58ea76803128651ec9c732b59b8c1eb73d511c085c2e5786da3289d4015c/pymongo-4.18.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:09e6afa68001433c42925a258b57f2ceccb95c73fe8920452e147a82d90c1ae9", size = 1075681, upload-time = "2026-09-24T16:58:41.722Z" }, + { url = "https://files.pythonhosted.org/packages/7c/6d/702ee4593420e2a8ceb169ece6394f0bb9fc2e8e8d8101e99f2468ed6d0d/pymongo-4.18.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8d3445caeeb47dc64bf416dc03e0b3bfcf3b60a109dec355a563aa62c135cb33", size = 1068235, upload-time = "2026-09-24T16:58:43.228Z" }, + { url = "https://files.pythonhosted.org/packages/a9/92/a02a4108ca4faf05bca394d65dcebc7b84b0248cff23aa89927c81a0d5dd/pymongo-4.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecde9e735e1f86559e0a71a5bbec671e59a35549bfd8c60968d9f032b05013de", size = 1049896, upload-time = "2026-09-24T16:58:44.914Z" }, + { url = "https://files.pythonhosted.org/packages/6e/93/65ef17eecdd86c153f7907b2d490d85eb655d3a5eb35a8494e7e09376353/pymongo-4.18.2-cp312-cp312-win32.whl", hash = "sha256:a463c3fc072655b7fe9e9ded4601876bef7f1df98f6f3faeed07fa7f11309fff", size = 815124, upload-time = "2026-09-24T16:58:46.39Z" }, + { url = "https://files.pythonhosted.org/packages/3c/cc/fd66aa9d8956e8269ea63b2977a7fd38fbec45795d38b8067bfafc98992f/pymongo-4.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:cbb67901ba9b6334fa94643d66587a30ffe5be05061af287364e6b5f7d239dc5", size = 821996, upload-time = "2026-09-24T16:58:47.769Z" }, + { url = "https://files.pythonhosted.org/packages/06/21/983d340cfe226c987014f1caf9568b176cbd7f25dd87536379a609e6371e/pymongo-4.18.2-cp312-cp312-win_arm64.whl", hash = "sha256:94c5e0fe9af4c05f382ae7c4addd0255a8ddcc91b3d2a6bc09b34230cb28290b", size = 815707, upload-time = "2026-09-24T16:58:49.493Z" }, ] [[package]] @@ -5426,15 +5430,15 @@ wheels = [ [[package]] name = "pyopenssl" -version = "26.2.0" +version = "26.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1a/51/27a5ad5f939d08f690a326ef9582cda7140555180db71695f6fb747d6a36/pyopenssl-26.2.0.tar.gz", hash = "sha256:8c6fcecd1183a7fc897548dfe388b0cdb7f37e018200d8409cf33959dbe35387", size = 182195, upload-time = "2026-05-04T23:06:09.72Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/e8/7325d258199b159eb2c03fe32107533e2832e70e63f4fb88a6aa00023201/pyopenssl-26.4.0.tar.gz", hash = "sha256:28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7", size = 182046, upload-time = "2026-08-01T19:50:50.512Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/73/b8/a0e2790ae249d6f38c9f66de7a211621a7ab2650217bcd04e1262f578a56/pyopenssl-26.2.0-py3-none-any.whl", hash = "sha256:4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70", size = 55823, upload-time = "2026-05-04T23:06:08.395Z" }, + { url = "https://files.pythonhosted.org/packages/51/ad/2cf6d3fa2fae5c79e1ed9960c0d42badd0f94d81dd12b50604cdc839e648/pyopenssl-26.4.0-py3-none-any.whl", hash = "sha256:f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c", size = 56026, upload-time = "2026-08-01T19:50:48.94Z" }, ] [[package]] @@ -6049,11 +6053,11 @@ wheels = [ [[package]] name = "sqlparse" -version = "0.5.5" +version = "0.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/d3/3f06a1006f2261d1342aefb3c71eed02f5d4ca5bdbecd86ebc12ad38306e/sqlparse-0.6.0.tar.gz", hash = "sha256:113c35c75365ab9cc9c7231d68c6428fb11c085fc8e9eb1ad659b7ddbf6cd2b9", size = 178477, upload-time = "2026-08-13T19:16:06.396Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, + { url = "https://files.pythonhosted.org/packages/d9/50/f00935da0ec7cbf325f8dc4f772ae46fbc7b672dd62876e73f0a94adda57/sqlparse-0.6.0-py3-none-any.whl", hash = "sha256:b861c0288ce2fa56209a9a6412d2e066ac664b3873b89c26c9d8415e8e32996f", size = 50070, upload-time = "2026-08-13T19:16:04.062Z" }, ] [[package]] @@ -6117,6 +6121,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 +6244,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" @@ -6305,11 +6333,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.7.0" +version = "2.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, ] [[package]] @@ -6622,40 +6650,70 @@ wheels = [ ] [[package]] -name = "zstd" -version = "1.5.7.3" +name = "zstandard" +version = "0.25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/49/62/b9c075ad664e7c4cbb3d8d2be7c246506abe1bc7f778eb58d260ef9538c8/zstd-1.5.7.3.tar.gz", hash = "sha256:403e5205f4ac04b92e6b0cda654be2f51de268228a0db0067bc087faacf2f495", size = 672559, upload-time = "2026-01-08T16:24:43.361Z" } +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/75/0d/8c89c0d010b58c21a7865a239790bb1c6822029c053b1ded858d6b573e3a/zstd-1.5.7.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a3c1781a24e2ced2c0ddee11d45b1f04018b03615eeb622a62eca4d56d3358a", size = 267641, upload-time = "2026-01-08T16:30:50.812Z" }, - { url = "https://files.pythonhosted.org/packages/a3/6d/155d8c344d96eca2a5a003a5ddd63373a5f13591fd5cf2b9490250d6805a/zstd-1.5.7.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a6c7c81056362b60a04baa34632e713d596662a860ec34efd8e9b109c10e6ec7", size = 230962, upload-time = "2026-01-08T16:30:49.155Z" }, - { url = "https://files.pythonhosted.org/packages/c8/c7/ab93916a26eb58cd501ad701974c31b4bc67a7f6abd6c24bef8fe4d7649b/zstd-1.5.7.3-cp311-cp311-manylinux_2_14_x86_64.whl", hash = "sha256:e564f34a55effc7d654eb293468edc80b64d476b0f899f82760ecd8323223ff5", size = 304166, upload-time = "2026-01-10T11:17:45.697Z" }, - { url = "https://files.pythonhosted.org/packages/c2/54/27a7040a360019a4602343e3c98c0c0a140f382186002c01e1992fd21837/zstd-1.5.7.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:fbc49a57188184931d5e3c9f1133cad7eea5a370a9e9418fb8122d58c14340a5", size = 1540288, upload-time = "2026-01-08T17:50:26.913Z" }, - { url = "https://files.pythonhosted.org/packages/96/93/4a4d4edd1b2e809e0ebbb16000404bdcc9a09743c04ee1661442c9581b75/zstd-1.5.7.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:d121d3e63722819e1fe5effbcd9628d8a7cfea0cddabcc5bb37ea861a6a83424", size = 1619134, upload-time = "2026-01-08T17:50:32.324Z" }, - { url = "https://files.pythonhosted.org/packages/31/6b/cd6f0a7f4f0d98e4110aa77763cf3e85f594d983ea9ca3d64cc0cee10684/zstd-1.5.7.3-cp311-cp311-manylinux_2_4_i686.whl", hash = "sha256:621f2e7ca8e9eb52a83eb9c91ec3cd283d87591bf75cc658de486b65f44742c7", size = 300166, upload-time = "2026-01-10T11:12:27.938Z" }, - { url = "https://files.pythonhosted.org/packages/05/3f/c717e0d15127d04b7fa58ba9b4c56e8b88b803048b9766cd9d158dbb22ea/zstd-1.5.7.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:c1950fcae690ba32d0f31702b335c548fb42547821565925e48576afdad774a5", size = 1525776, upload-time = "2026-01-08T17:50:35.518Z" }, - { url = "https://files.pythonhosted.org/packages/3e/a2/1813cd787d1a2f9ab8e8a90d28dcbc8e8098997dd04de38897ea8e75dd08/zstd-1.5.7.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bac4f0d03da69115878bedbfa03c4a3f64364e8396b432028c4ce0f05141a0fb", size = 2096057, upload-time = "2026-01-08T17:50:33.984Z" }, - { url = "https://files.pythonhosted.org/packages/36/ce/f5a3c7c12de458dd9ce15c484d627fe5412b60c155da23dacb5fcf08d9d5/zstd-1.5.7.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:da0ab134b7fd28023dedf013751ca850de300a090eb11f689d2a1c178c87d9dc", size = 2132659, upload-time = "2026-01-08T17:50:29.534Z" }, - { url = "https://files.pythonhosted.org/packages/f1/66/151f9546498bfd8971a0b6ad67d87c26d7a0df17d57f724da674f3778666/zstd-1.5.7.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b9923175842ee8f7602ec9cc578f5fc396896f0e8460d3ac9a5adc3cea77244e", size = 2124811, upload-time = "2026-01-08T17:50:37.612Z" }, - { url = "https://files.pythonhosted.org/packages/6a/34/4d2dbb36cb2373d3f115c047cb901b64f89de0703d10779da39de9453812/zstd-1.5.7.3-cp311-cp311-win32.whl", hash = "sha256:0612b604948d7b58aecc6788c7ceb53c5f21d94a155bb6ea9bd0f54ffa43725d", size = 150363, upload-time = "2026-01-08T17:11:02.392Z" }, - { url = "https://files.pythonhosted.org/packages/d9/de/f53687e0dd8c0d0ebfaed9ae88f6a96a1a0388ae7424b469e74bb17ac57d/zstd-1.5.7.3-cp311-cp311-win_amd64.whl", hash = "sha256:5b7f8c81b2bd3b62c0345242247d484cafa4b518d59d18619813d9225af5c5c3", size = 167577, upload-time = "2026-01-08T17:11:03.356Z" }, - { url = "https://files.pythonhosted.org/packages/f2/58/d4a6a902e229e953ed273fe9b78587ed31f57567aa68d3e34af6056e42af/zstd-1.5.7.3-cp311-cp311-win_arm64.whl", hash = "sha256:ea112e3acd9e1765adca35df7b54ac75b36194290f64ea03a3a59664209c8527", size = 157238, upload-time = "2026-01-08T16:36:06.25Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ed/5a3bf2e29dc56d4cc7619929bb51f0c758de6d02967cc73c5d8755a862c0/zstd-1.5.7.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01a39efb0eeab7cc45cb308618233b624b0840d5e16dcf85456b6cca0592f203", size = 268124, upload-time = "2026-01-08T16:29:57.091Z" }, - { url = "https://files.pythonhosted.org/packages/e2/1d/efc2074ac90af938e78f2ed4004639fe24f294d9086c5280f8d9a02b9897/zstd-1.5.7.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7a8e8838cf35fa3987bfe1958584cc22e1797efce8e155a63544b4144fc671f8", size = 230988, upload-time = "2026-01-08T16:29:55.604Z" }, - { url = "https://files.pythonhosted.org/packages/2a/52/178393b8d70e23fba67f42dfce4663e4e8a30867110168beb490a36d4639/zstd-1.5.7.3-cp312-cp312-manylinux_2_14_i686.whl", hash = "sha256:f3920ac1d1cc7e9f252f3e29f217fe3cd36f2191bb3dbcae826c29e189b7ad54", size = 300207, upload-time = "2026-01-10T11:26:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/6a/7a/8dcd86a2efb2ed3f9dae39545a05d3c7ed26c7678330786ce4a44cd8b099/zstd-1.5.7.3-cp312-cp312-manylinux_2_14_x86_64.whl", hash = "sha256:143f9062953fb5590cbd47c1040d357336742c79696bf90b6d5b835279a68304", size = 304154, upload-time = "2026-01-10T11:17:40.91Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ce/0c96905ab01ffe0e53a3cec8132123b82db26bd583a71608029bcc789ebc/zstd-1.5.7.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36d1fd8647e47e1f21b345e192f1a279e925678c23dad8236b547d04456cd699", size = 2162222, upload-time = "2026-01-08T18:02:22.762Z" }, - { url = "https://files.pythonhosted.org/packages/11/c4/db4807d6a68b4628c74fd379de7e3c67ec34f19a2a80ac246b3837cde6cb/zstd-1.5.7.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1538db419afa62773cf534fc7f3009ff59ecf55ecee4e889587ac2ef0010ed8", size = 2201732, upload-time = "2026-01-08T18:02:20.835Z" }, - { url = "https://files.pythonhosted.org/packages/c5/99/c19a3c0f5580ff9c33a74f06d98d6060ed1fa6bd09b55aed9be852ec191f/zstd-1.5.7.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5efd16adb092e2a547a7d51cfdaf6fd5680528227684c5bafc7669ab4a55f41", size = 2096459, upload-time = "2026-01-08T18:02:25.336Z" }, - { url = "https://files.pythonhosted.org/packages/23/fd/02eac30419475dbe50212c119043a2d0698a0cbc756da85fd3fd9abddf42/zstd-1.5.7.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:39b3438e64637d80a5b1860526903b92020acb9bae9ceb5adffd9838c1441328", size = 2125442, upload-time = "2026-01-08T18:02:17.715Z" }, - { url = "https://files.pythonhosted.org/packages/bb/43/3a16ff0a8c913bb9825379db1bd533c75c57c2d2f31dd9111aa9b53711f4/zstd-1.5.7.3-cp312-cp312-win32.whl", hash = "sha256:cbf48c53461e224ffc2490cfe5120a1ff40d14c84d2b512c6d6d99fc91685cf3", size = 150367, upload-time = "2026-01-08T17:03:40.178Z" }, - { url = "https://files.pythonhosted.org/packages/46/83/b85875d7428e63dfa9247e41d17fac611443c774f7892f8643bd4164a6b2/zstd-1.5.7.3-cp312-cp312-win_amd64.whl", hash = "sha256:943a189910f2fea997462e3e4d7fbf727a06d231ef801ebee557b1c87568981c", size = 167604, upload-time = "2026-01-08T17:03:41.355Z" }, - { url = "https://files.pythonhosted.org/packages/37/42/cf291e26804de2f55500cdac93f5e9fa6267cf315def8aa402529bae3a87/zstd-1.5.7.3-cp312-cp312-win_arm64.whl", hash = "sha256:85c4d508f8109afa7c51c4960626c3325af2cf1e442c6c36ebfea15d04757e3f", size = 157241, upload-time = "2026-01-08T16:47:34.615Z" }, - { url = "https://files.pythonhosted.org/packages/04/b8/d13d584867d5eb1bc607877a870858e02a256d4706a4274e475413a000aa/zstd-1.5.7.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:76c49ea969bc08389ea59155cea7c5dea224522ffc62f443f3c0a915f5fd184d", size = 260025, upload-time = "2026-01-08T16:57:45.739Z" }, - { url = "https://files.pythonhosted.org/packages/16/a1/1e5faf75bedfd2bfccfb83e18736b115bed6e348504bd21800cd8f30dcea/zstd-1.5.7.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:6b1a638ff3dfce8f4cb1203c662fb5606dd99b4a62c5ddc4c406d2d1326bcfdd", size = 221038, upload-time = "2026-01-08T17:16:32.005Z" }, - { url = "https://files.pythonhosted.org/packages/b7/2c/0fe74d8b2029eef8000bc71aac5b3e5b55d00581238711cf627814183ea3/zstd-1.5.7.3-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5e96a5cb100a0edc162935227f2d9784b1031ce4a8a83e96e66eae2673c10143", size = 326792, upload-time = "2026-01-08T16:57:35.631Z" }, - { url = "https://files.pythonhosted.org/packages/96/e0/2c7f081f3524f872128ff31bea2acb6b21cb1dacccef920eb6a1a77a87c6/zstd-1.5.7.3-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bda0bbf3a9553720cd33f1f85940a259656c7ffba4be717ff82b7f062052188", size = 322283, upload-time = "2026-01-08T16:57:36.759Z" }, - { url = "https://files.pythonhosted.org/packages/c9/a7/3bebfcc18d66b90bc7b506a61b2ff4af5ee1b0b16e784ea644afa06241c5/zstd-1.5.7.3-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac36e4022422f6e49b3f07bdbb8a964fd348223d3dc9c82ad5398a4f0432a719", size = 311553, upload-time = "2026-01-08T16:57:38.465Z" }, - { url = "https://files.pythonhosted.org/packages/41/75/8a791cae2c98e5e44a158e15db50d21b7ec0b37aeaffa68d151bc8ffb6d6/zstd-1.5.7.3-pp311-pypy311_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:fa4d760a220541b18ce732a3a2cf7547ea05afc76d05b3b39edebfeb721f6079", size = 317071, upload-time = "2026-01-08T16:36:07.47Z" }, - { url = "https://files.pythonhosted.org/packages/2f/25/b6624e6b08d515242154436c9d06fb20b790d300ac82e84f3c4c133e25e1/zstd-1.5.7.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a69e60146bf8aaa6a0e6c9a94a7c5f3133d68091e2e5c5a3c5ababf71fd5ec7a", size = 167654, upload-time = "2026-01-08T17:00:56.667Z" }, + { 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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/78/9a476e09c825304df47b98be80d1ffe223733b03550af71325415028f615/zstd-1.5.7.2.tar.gz", hash = "sha256:6d8684c69009be49e1b18ec251a5eb0d7e24f93624990a8a124a1da66a92fc8a", size = 670481, upload-time = "2025-06-23T12:36:08.131Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/2a/0885f6f1921ec1ef4a8f8ab29ab0a335cc867abe4c7aaa4e5031435a32a5/zstd-1.5.7.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f799c1e9900ad77e7a3d994b9b5146d7cfd1cbd1b61c3db53a697bf21ffcc57b", size = 269702, upload-time = "2025-06-23T12:50:11.695Z" }, + { url = "https://files.pythonhosted.org/packages/05/e6/629cf6b77e47fc7149f5724fb4853c48edcdeb10d8c64e391d7026cb10e1/zstd-1.5.7.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1ff4c667f29101566a7b71f06bbd677a63192818396003354131f586383db042", size = 228145, upload-time = "2025-06-23T12:50:10.411Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b8/9ddefd4670bfe9328ca6657ad335eb8d9c657466247e234a579818b6b0b9/zstd-1.5.7.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:8526a32fa9f67b07fd09e62474e345f8ca1daf3e37a41137643d45bd1bc90773", size = 1536530, upload-time = "2025-06-23T13:51:38.853Z" }, + { url = "https://files.pythonhosted.org/packages/d1/6a/1bb836c18760dc1e28ca7a9706016e482ebdea633b980d8505dbb65e18f8/zstd-1.5.7.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:2cec2472760d48a7a3445beaba509d3f7850e200fed65db15a1a66e315baec6a", size = 1616141, upload-time = "2025-06-23T13:51:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/b5/7a/bb6c6e2cb2a066e347dc27d45d5205058b69d6c8b8d4ae2ee7d6b91c64a5/zstd-1.5.7.2-cp311-cp311-manylinux_2_4_i686.whl", hash = "sha256:a200c479ee1bb661bc45518e016a1fdc215a1d8f7e4bf6c7de0af254976cfdf6", size = 322188, upload-time = "2025-06-23T13:01:48.704Z" }, + { url = "https://files.pythonhosted.org/packages/5a/4f/cf0669c8a89fdcc91814bf92bd05cc363d5d12a79b656418c0add6f2d266/zstd-1.5.7.2-cp311-cp311-manylinux_2_4_x86_64.whl", hash = "sha256:f5d159e57a13147aa8293c0f14803a75e9039fd8afdf6cf1c8c2289fb4d2333a", size = 302736, upload-time = "2025-06-23T13:05:33.649Z" }, + { url = "https://files.pythonhosted.org/packages/be/bc/e5f8b7f61826323e39e099db1eb5c0e09b18315df1b1ff778f7ae9aadcac/zstd-1.5.7.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:7206934a2bd390080e972a1fed5a897e184dfd71dbb54e978dc11c6b295e1806", size = 1522687, upload-time = "2025-06-23T13:51:35.494Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7660a949a020ac9d02b3166a25dd1c12144572d77b11ae92a31d341016da/zstd-1.5.7.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7e0027b20f296d1c9a8e85b8436834cf46560240a29d623aa8eaa8911832eb58", size = 2098794, upload-time = "2025-06-23T13:51:37.219Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b2/730c811a78d670104d40c7f08cc8092577cdff870cba42b3158f20fceb57/zstd-1.5.7.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d6b17e5581dd1a13437079bd62838d2635db8eb8aca9c0e9251faa5d4d40a6d7", size = 2112266, upload-time = "2025-06-23T13:51:31.258Z" }, + { url = "https://files.pythonhosted.org/packages/44/74/2c16e1632094db36c8920d4c13b8e2e843024d548ae26888c2d22af6a676/zstd-1.5.7.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b13285c99cc710f60dd270785ec75233018870a1831f5655d862745470a0ca29", size = 2109465, upload-time = "2025-06-23T13:51:32.884Z" }, + { url = "https://files.pythonhosted.org/packages/58/6e/b9c9a834769d96cab2122da1be8c8c700d3f76be796d2b7516e85d2eca0e/zstd-1.5.7.2-cp311-cp311-win32.whl", hash = "sha256:cdb5ec80da299f63f8aeccec0bff3247e96252d4c8442876363ff1b438d8049b", size = 149448, upload-time = "2025-06-23T13:06:21.144Z" }, + { url = "https://files.pythonhosted.org/packages/47/b7/fc22ad6292a32d7676ab815de3a23573beac3679e8abd9914288d1496ceb/zstd-1.5.7.2-cp311-cp311-win_amd64.whl", hash = "sha256:4f6861c8edceb25fda37cdaf422fc5f15dcc88ced37c6a5b3c9011eda51aa218", size = 166592, upload-time = "2025-06-23T13:06:22.126Z" }, + { url = "https://files.pythonhosted.org/packages/45/14/096bb77f3e5ef525b452cd6294da33de7f8a8c9647ba78293378fbb0a7ce/zstd-1.5.7.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2ebe3e60dbace52525fa7aa604479e231dc3e4fcc76d0b4c54d8abce5e58734", size = 269408, upload-time = "2025-06-23T13:11:46.492Z" }, + { url = "https://files.pythonhosted.org/packages/08/b8/2bc2590a34c733ea0570f366e6ad7d889d05c7825bd3ccab01f36ece71c6/zstd-1.5.7.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ef201b6f7d3a6751d85cc52f9e6198d4d870e83d490172016b64a6dd654a9583", size = 228188, upload-time = "2025-06-23T13:11:47.539Z" }, + { url = "https://files.pythonhosted.org/packages/b7/80/6252de3a70cfd7767718ad476893f1c7dc129f942cc7ed0322e3137c03d9/zstd-1.5.7.2-cp312-cp312-manylinux_2_14_x86_64.whl", hash = "sha256:ac7bdfedda51b1fcdcf0ab69267d01256fc97ddf666ce894fde0fae9f3630eac", size = 302720, upload-time = "2025-06-23T12:40:11.522Z" }, + { url = "https://files.pythonhosted.org/packages/af/b6/af908387814b99172d3aea6aeb24b19583aadfa45f6021e5e2a0d6d8e99a/zstd-1.5.7.2-cp312-cp312-manylinux_2_4_i686.whl", hash = "sha256:b835405cc4080b378e45029f2fe500e408d1eaedfba7dd7402aba27af16955f9", size = 322237, upload-time = "2025-06-23T13:17:35.482Z" }, + { url = "https://files.pythonhosted.org/packages/ed/d7/ab9142e002a7eaa451cb4bb37a74c390c489ba8ae75ade543840496eda04/zstd-1.5.7.2-cp312-cp312-win32.whl", hash = "sha256:e4cf97bb97ed6dbb62d139d68fd42fa1af51fd26fd178c501f7b62040e897c50", size = 149453, upload-time = "2025-06-23T13:13:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c7/c182ea7bc283f591e3f3c5f0f239e7a92c9bc1f626642ae2c4dfbe51d6f2/zstd-1.5.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:55e2edc4560a5cf8ee9908595e90a15b1f47536ea9aad4b2889f0e6165890a38", size = 166628, upload-time = "2025-06-23T13:13:03.745Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c9/a6495a7bf168a78f0a0c01d61d830ebfb401315a64fd1ae8d725c458114c/zstd-1.5.7.2-pp311-pypy311_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:5fb2ff5718fe89181223c23ce7308bd0b4a427239379e2566294da805d8df68a", size = 315542, upload-time = "2025-06-23T12:39:27.598Z" }, ] 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/docker-compose.yml b/docker-compose.yml index 5ed0e97a28..4b836aba6c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,7 @@ services: api: hostname: "prowler-api" image: prowlercloud/prowler-api:${PROWLER_API_VERSION:-stable} + restart: unless-stopped env_file: - path: .env required: false @@ -44,6 +45,7 @@ services: ui: image: prowlercloud/prowler-ui:${PROWLER_UI_VERSION:-stable} + restart: unless-stopped env_file: - path: .env required: false @@ -61,6 +63,7 @@ services: postgres: image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 + restart: unless-stopped hostname: "postgres-db" volumes: - ./_data/postgres:/var/lib/postgresql/data @@ -81,6 +84,7 @@ services: valkey: image: valkey/valkey:8-alpine@sha256:a038175878d66b9d274fbf8be73c0305e93798b83917647f167e18cef3c71eec + restart: unless-stopped hostname: "valkey" volumes: - ./_data/valkey:/data @@ -97,6 +101,7 @@ services: neo4j: image: graphstack/dozerdb:5.26.27.0@sha256:9b54d6b3a98a76c00bd23e8e78d8c82081ff168162aebd47b25c234e092cb0a0 + restart: unless-stopped hostname: "neo4j" volumes: - ./_data/neo4j:/data @@ -129,6 +134,7 @@ services: worker: image: prowlercloud/prowler-api:${PROWLER_API_VERSION:-stable} + restart: unless-stopped # Give Celery soft shutdown time to drain/re-queue in-flight tasks on stop. stop_grace_period: 120s env_file: @@ -149,6 +155,7 @@ services: worker-beat: image: prowlercloud/prowler-api:${PROWLER_API_VERSION:-stable} + restart: unless-stopped env_file: - path: ./.env required: false @@ -165,6 +172,7 @@ services: mcp-server: image: prowlercloud/prowler-mcp:${PROWLER_MCP_VERSION:-stable} + restart: unless-stopped environment: - PROWLER_MCP_TRANSPORT_MODE=http env_file: diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 2233ee1077..ecef9d41bd 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -4,6 +4,527 @@ description: "New features and improvements in each Prowler release" rss: true --- + + ### 🔁 Findings — Re-check a Resource with a Partial Scan + + + This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing). + + + A resource that has just been fixed can be confirmed from the Findings page without waiting for the next full scan. **Re-check resource** is available in the actions menu of every resource row and in the resource detail drawer, and a hint icon next to **Last seen** opens it directly. It launches a partial scan that runs again only the checks that last reported on that resource; its findings update when the scan completes, and every other resource keeps the results of the latest full scan. Roles need the Manage Scans permission, and a re-check is refused while the provider has a scan running or queued. + + Re-checked resources that now pass drop out of the finding groups list and its drill-down instead of opening a detail panel that still reports `FAIL`. Partial scans do not change overviews or compliance until the next full scan and produce no report files, so the Scans table marks them as **Partial**, offers no report download for them, and the per-scan Compliance selector leaves them out. + + Partial scans can also be launched outside the Findings page: + + - **API:** `POST /api/v1/scans` accepts up to 10 resources in `resource_uids`, and scans expose `is_partial` with a `filter[is_partial]` filter. + - **MCP Server:** `prowler_trigger_scan` takes a `resource_uids` argument, and `prowler_list_scans` and `prowler_get_scan` return `is_partial`, with an `is_partial` filter on `prowler_list_scans`. + - **Lighthouse AI:** can launch a partial scan to re-check specific resources, such as confirming a remediation. + + ### ☁️ AWS — Connect an Account in One Step + + The Add Provider wizard connects an AWS account in a single step. The account ID is read from the role ARN (or typed when using static access keys), the role is assumed with Prowler's own credentials, and the account, its credentials and the connection test are handled by one submit. A confirmed connection goes straight to the launch step. A refused connection stays on the form with the reason the API returned, so the fields can be fixed and retried without registering the account twice. + + New tenants without providers now land on this wizard on their first sign-in instead of a welcome modal, and the sidebar action reads **Add Provider** until the first provider is connected. + + Read more in the [Getting Started with AWS documentation](https://docs.prowler.com/user-guide/providers/aws/getting-started-aws). + + ### 🔌 Connection Tests No Longer Give Up Early + + The provider connection test no longer reports `Max retries exceeded` for checks that take longer than 30 seconds, such as networks where some AWS endpoints are unreachable. The UI now waits for the full time limit of the backend task, and if that is still exhausted it shows the provider's current connection state instead of a failure. + + On the SDK side, STS calls after a role assumption reuse the region that answered, so an unreachable partition region is waited on once instead of twice. The new `PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS` environment variable sets the Boto3 retries for deployments that build the AWS provider without CLI flags, next to the existing timeout variables; `0` disables retries. + + Read more in the [Boto3 configuration documentation](https://docs.prowler.com/user-guide/providers/aws/boto3-configuration#retries-configuration). + + ### 🗄️ Self-Hosted — S3-Compatible Storage and Air-Gapped Deployments + + - `DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL` points scan output uploads and downloads at S3-compatible object storage such as MinIO. Previously the only way to reach it was exporting process-wide AWS environment variables, which also hijacked unrelated AWS API calls such as role assumption for AWS providers. + - Report downloads from a bucket with default SSE-KMS encryption no longer fail with `InvalidArgument`: when `DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION` is set, download URLs are signed with Signature Version 4 for that region. + - Icons ship in the UI bundle instead of being fetched from `api.iconify.design`, so pages render correctly without internet access. + - Celery worker fatal errors are logged instead of silenced, and every long-running service in `docker-compose.yml` restarts automatically after an unexpected crash. + + ### 📊 Consistent Latest Scan Across Endpoints + + Every endpoint now resolves a provider's latest completed scan the same way, so overlapping scans no longer make findings, compliance and mute rules read from different scans. Providers whose latest completed scan has no `completed_at` timestamp are no longer missing from those endpoints, and resources no longer keep a stale failed findings count when a scoped or imported scan completes after a full scan. + + ### 🛠️ Prowler App Fixes + + - API key authentication no longer locks the key row on every request, so a heavily used key no longer serializes all its requests; `last_used_at` is updated at most once per minute. + - `POST /api/v1/scans` returns the new scan ID in `task_args` again. + - Provider deletion no longer fails when the provider has Attack Paths scans recorded on a sink that is no longer configured, such as Neptune after moving back to Neo4j. + - A periodic sweep drops orphaned Attack Paths temporary Neo4j databases left behind when a worker or Neo4j crashes mid-scan. + - **Prowler Cloud:** imported findings no longer stay stuck in `pending` when the ingestion worker picks up the job before it is committed, and a failed enqueue marks the ingestion as failed. + - **Prowler Cloud:** the Lighthouse AI connection check reports a network failure as one, naming the endpoint it could not reach, instead of hitting a time limit that looked the same as a bad key. + - **Prowler Cloud:** the finding groups endpoints no longer query Manual Pass triages once per finding, and skip that overlay for tenants with no active Manual Pass. + - The Findings page renders a skeleton at once and streams the table before the filters, and the **Finding Group** options load when the dropdown opens. + - Mute rule creation errors show the API error message instead of the raw response body. + - The sidebar no longer throws a hydration error on full page loads for users who last used the chat mode. + + ### 🔐 Security Updates + + - `DELETE /api/v1/tasks/{id}` requires the permission of the operation that queued the task and rejects provider deletions, and `GET /api/v1/tasks` hides tasks of providers outside the role's visibility. + - The UI E2E workflow receives its AWS credentials through environment variables instead of template expansion. + + See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.44.0) for the complete list of changes. + + + + ### 🏛️ Compliance — FedRAMP 20x Consolidated Rules 2026 + + The FedRAMP 20x Phase One pilot frameworks (`fedramp_20x_ksi_low_aws`, `fedramp_20x_ksi_low_azure` and `fedramp_20x_ksi_low_gcp`) are replaced by two universal frameworks built from the FedRAMP Consolidated Rules for 2026, each covering AWS, Azure, GCP, Kubernetes and Microsoft 365 from a single definition: + + - **FedRAMP 20x KSI** (`fedramp_20x_ksi_2026`): the 46 Key Security Indicators across 10 themes. There is one indicator catalog for every class instead of a separate Low fork; each indicator carries its class applicability and NIST SP 800-53 controls. + - **FedRAMP 20x Class C FRR** (`fedramp_20x_frr_class_c_2026`): the 158 FedRAMP Rules of the Class C ruleset that bind cloud service providers. Most are program obligations (reports, notifications, certification package) and stay manual; checks are mapped only where they evidence part of the rule text. Configurable checks carry configuration requirements, so a relaxed `audit_config` cannot turn a requirement green. + + Automation or stored results that reference the pilot framework IDs need to move to `fedramp_20x_ksi_2026`. In Prowler App, both frameworks show the per-provider breakdown in the cross-provider compliance view and can be downloaded as OCSF. + + Read more in the [Compliance documentation](https://docs.prowler.com/user-guide/compliance/tutorials/compliance). + + ### 🔎 AWS — Inspector Coverage, CISA KEV and FIPS Checks + + Seven new AWS checks back the vulnerability detection and cryptography rules of FedRAMP 20x Class C: + + - `inspector2_coverage_scan_status_active` and `inspector2_coverage_recently_scanned` report resources Amazon Inspector is not scanning, or last scanned more than `inspector2_max_days_since_last_scan` days ago (default 3). + - `inspector2_active_findings_no_known_exploited_vulnerabilities` and `inspector2_active_findings_kev_within_due_date` report active findings whose CVE is in the CISA Known Exploited Vulnerabilities catalog, and those still open past the CISA due date. The KEV data comes from Inspector itself through `inspector2:BatchGetFindingDetails`, so no external feed is needed. + - `inspector2_active_findings_within_max_age` reports active findings first observed more than `inspector2_active_finding_max_age_days` days ago (default 192). + - `elbv2_listener_fips_tls_enabled` and `transfer_server_fips_security_policy_enabled` report HTTPS/TLS load balancer listeners and Transfer Family servers without a FIPS security policy. + + `inspector2:BatchGetFindingDetails` is not part of `SecurityAudit`, so it is now included in the Prowler additions policy and the CloudFormation scan role. Without it, the KEV checks report `MANUAL` naming the missing permission instead of a false `FAIL`. + + Explore all AWS checks at [Prowler Hub](https://hub.prowler.com/check?provider=aws). + + ### ☁️ AWS — Partition Bootstrap Failover + + When `PROWLER_AWS_PARTITION` is set, the bootstrap STS calls (validating credentials, assuming a role and getting an MFA session token) now try up to two more regions of the partition if the first one cannot be reached. A GovCloud host whose configured region belongs to another partition was still sent to `us-gov-east-1`, and on a network that routes only to `us-gov-west-1` the connection check and the scan failed on perfectly valid credentials. Only connection errors and timeouts move on to the next region; credential errors are reported from the first one as before. Later STS calls reuse the region that answered, and nothing changes when `PROWLER_AWS_PARTITION` is unset. + + Read more in the [AWS Regions and Partitions documentation](https://docs.prowler.com/user-guide/providers/aws/regions-and-partitions). + + ### 🌐 Azure — Sovereign Cloud Endpoints for Defender and Key Vault + + Defender security contacts and Key Vault key rotation policies now use the endpoints of the cloud selected with `--azure-region` instead of the hardcoded `management.azure.com` and `vault.azure.net` hosts, so both work on `AzureUSGovernment` and `AzureChinaCloud`. Key Vault clients are built from the vault URI that Azure returns for each vault. + + Read more in the [Azure non-default cloud documentation](https://docs.prowler.com/user-guide/providers/azure/use-non-default-cloud). + + ### ✉️ Invitations — Expired Invitations No Longer Block Re-Invites + + A pending invitation past its expiry date is now reported as expired, and inviting the same email again marks it as expired and creates the new invitation instead of returning a generic error. The Invitations table disables Edit and Revoke on expired and revoked invitations, and `filter[state__in]` on the invitations endpoint no longer returns a server error. + + In Prowler Cloud, new organizations are offered an **Invite your team** step once the first provider is connected, and Prowler Private Cloud deployments can set `UI_SELF_REGISTRATION_ENABLED=false` to make sign-up invitation-only. + + Read more in the [Invitations documentation](https://docs.prowler.com/user-guide/tutorials/prowler-app-rbac#invitations). + + ### 📄 Reports — Downloads on Self-Hosted Storage + + Report downloads no longer depend on the storage host being the same inside and outside the container network. `DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL` signs the download URL against a browser-reachable host, so a deployment whose object storage answers only on an internal address serves the file instead of a link the browser cannot open. Leaving `DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION` unset, common on S3-compatible storage with no meaningful region, no longer makes the download fail with a server error. + + ### 🔍 Checks + + - **Huawei Cloud:** new `smn_topic_subscriptions` check reports SMN topics without any subscription. + - **Cloudflare:** the API token links in the provider wizard request the SSL and Certificates, Bot Management and Zone WAF read permissions the checks need, so a token created from the wizard no longer produces failures on permissions it was never granted. + - **Microsoft 365:** five Defender malware, anti-phishing and inbound anti-spam checks no longer fail with `KeyError` on tenants that use the Standard or Strict preset security policies, which dropped every finding of those checks. Preset policies are covered by `defender_strict_preset_security_policy_enabled`. + - **Google Workspace:** `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. + + Explore all checks at [Prowler Hub](https://hub.prowler.com/check). + + ### 🔐 Security Updates + + - `libsqlite3-0`, `gzip`, `perl-base`, `libssh2-1t64` and `libpcre2-8-0` upgraded in the SDK and API container images, patching high-severity Debian CVEs. + - PowerShell upgraded to 7.5.11 in the SDK and API container images, bundling .NET runtime 9.0.20 and patching CVE-2026-62901. + - `anyio` upgraded to 4.14.2 in the SDK, the API and the MCP Server, patching CVE-2026-63374. + + See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.43.0) for the complete list of changes. + + + + ### ☁️ 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 + + + This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing). + + + Alerts can now reach Slack. Connect a Slack workspace from the Integrations page, authorize one or several destination channels (the connection check confirms each channel with a one-time message and names any channel Slack refuses), and pick those channels in the alert modal's "Destination channels" selector, next to the "Recipients" selector for email. The alerts list summarizes both in a single "Destinations" column, showing a rule's email recipients and Slack channels at a glance. Disconnecting the workspace and recovering from revoked credentials are handled from the same page. + + ![Connected Slack workspace on the Integrations page](/images/prowler-app/slack/connected-workspace.png) + + ![Alert rule with Slack channel destinations](/images/prowler-app/alerts/create-alert-modal.png) + + Read more in the [Slack integration documentation](https://docs.prowler.com/user-guide/tutorials/prowler-app-slack-integration) and the [Alerts documentation](https://docs.prowler.com/user-guide/tutorials/prowler-alerts). + + ### 🤖 Lighthouse AI — Answer Feedback + + + This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing). + + + Every Lighthouse AI answer can now be rated with a thumbs up or thumbs down, with an optional field to describe what worked or what did not. Feedback is collected per answer, directly in the chat, and tells the team where Lighthouse should improve next. + + Read more in the [Lighthouse AI documentation](https://docs.prowler.com/getting-started/products/prowler-cloud-lighthouse). + + ### 📥 Providers — Imported Findings Indicator + + + This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing). + + + Providers whose findings were imported with the Prowler CLI now show an "Imported provider" indicator next to their connection status in the providers table. In accounts that mix connected providers with Import Findings uploads, the table now tells them apart at a glance. + + ![Provider row with the Imported provider indicator and its tooltip](/images/changelog/v5.40.0-imported-provider-indicator.png) + + Read more in the [Import Findings documentation](https://docs.prowler.com/user-guide/tutorials/prowler-import-findings). + + ### 📚 Compliance — NCSC Cyber Essentials 3.3 + + Cyber Essentials is the UK National Cyber Security Centre (NCSC) scheme certifying the baseline technical controls an organization must implement, and cloud services are explicitly in scope and cannot be excluded from an assessment. Prowler now includes NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026) as a universal framework, with its 28 requirements organized in the five control themes: Firewalls, Secure Configuration, Security Update Management, User Access Control, and Malware Protection. + + Sixteen requirements map to Azure checks covering the controls the applicant organization owns under the shared responsibility model. The remaining twelve apply to end-user devices, on-premises network appliances, or organizational process, which cloud control-plane evidence cannot observe, so they are reported as Manual. + + Contributed by @m-khan-97. Thank you! + + Read more in the [Compliance documentation](https://docs.prowler.com/user-guide/compliance/tutorials/compliance). + + ### 🔍 Checks + + Fifteen new checks land across seven providers in this release. + + #### AWS + + - `ecr_repository_image_no_secrets` scans the latest image of each ECR repository, both its configuration and its filesystem layers, for hardcoded secrets. Thanks to @esquaredsec! + - Four new Amazon Bedrock checks, thanks to @tamg-aws! + - `bedrock_guardrail_contextual_grounding_filter_enabled` verifies that guardrails enable both contextual grounding filters, blocking responses that are not supported by the retrieved source or do not answer the question asked. + - `bedrock_custom_model_encrypted_with_cmk` verifies that custom models are encrypted at rest with a customer-managed KMS key instead of an AWS-owned key the organization cannot audit, rotate, or revoke. + - `bedrock_knowledge_base_encrypted_with_cmk` verifies that each knowledge-base data source encrypts with a customer-managed KMS key the transient storage used while documents are chunked and embedded. + - `bedrock_agent_role_not_shared_across_agents` verifies that every agent has a dedicated execution role, so no agent inherits another's permissions. + - `rolesanywhere_profile_restricts_session_permissions` flags IAM Roles Anywhere profiles that reference an administrative role without scoping down the vended session with a session policy or managed policies. + + Explore all AWS checks at [Prowler Hub](https://hub.prowler.com/check?provider=aws). + + #### GCP + + - `iam_workload_identity_pool_provider_attribute_condition` flags Workload Identity Federation providers that trust a multi-tenant issuer without an attribute condition restricting which external identities can impersonate federated principals. + + Explore all GCP checks at [Prowler Hub](https://hub.prowler.com/check?provider=gcp). + + #### GitHub + + Three new checks harden GitHub Actions defaults, all contributed by @Edneam. Thank you! + + - `organization_default_workflow_permissions_read_only` and `repository_default_workflow_permissions_read_only` verify that workflows get a read-only default `GITHUB_TOKEN` at the organization and repository level. + - `organization_actions_pull_request_approval_disabled` verifies that organizations prevent GitHub Actions from creating and approving pull requests. + + Explore all GitHub checks at [Prowler Hub](https://hub.prowler.com/check?provider=github). + + #### Microsoft 365 + + - `defender_domain_dmarc_records_published` checks that every Exchange Online domain publishes a DMARC record with an enforcing policy (`p=quarantine` or `p=reject`). Thanks to @Rishi943! + + Explore all Microsoft 365 checks at [Prowler Hub](https://hub.prowler.com/check?provider=m365). + + #### Alibaba Cloud + + - `oss_bucket_versioning_enabled` verifies that OSS buckets keep versioning enabled, allowing recovery from accidental or malicious object overwrite and deletion. Thanks to @abidedavana! + - `oss_bucket_server_side_encryption_enabled` verifies that OSS buckets define a default server-side encryption rule, either AES256 or KMS. Thanks to @alexchen-sys! + + OSS bucket logging, versioning, default encryption, and ACL configurations are also now read correctly from the Alibaba Cloud SDK, so the checks reading them no longer report every bucket as unconfigured. + + Explore all Alibaba Cloud checks at [Prowler Hub](https://hub.prowler.com/check?provider=alibabacloud). + + #### Huawei Cloud + + - `vpc_security_group_open_egress` flags VPC security groups that allow open egress to the internet. Thanks to @tomitobio! + + Explore all Huawei Cloud checks at [Prowler Hub](https://hub.prowler.com/check?provider=huaweicloud). + + #### STACKIT + + - `ske_cluster_no_public_endpoint` flags SKE clusters whose Kubernetes API endpoint is reachable from the whole internet, because the ACL extension is disabled or its allowed CIDR list contains `0.0.0.0/0` or `::/0`. Thanks to @johannes-engler-mw! + + Explore all STACKIT checks at [Prowler Hub](https://hub.prowler.com/check?provider=stackit). + + ### 🔐 Security Updates + + - The API and SDK container images upgrade OpenSSL to 3.5.7-1~deb13u2, patching ten high CVEs; the UI image upgrades `libcrypto3` and `libssl3` to 3.5.8-r0, patching seven high CVEs; the MCP Server image patches CVE-2026-14456 (OpenSSL), CVE-2026-11822, and CVE-2026-11824 (SQLite). + - `sqlparse` upgraded to 0.6.0 in the API, patching CVE-2026-54284, CVE-2026-59893, and CVE-2026-71491. + + ### 🙌 External Contributors + + Thank you to our community contributors for this release! + + - @Edneam: GitHub `organization_default_workflow_permissions_read_only` ([#12122](https://github.com/prowler-cloud/prowler/pull/12122)), `repository_default_workflow_permissions_read_only` ([#12143](https://github.com/prowler-cloud/prowler/pull/12143)), and `organization_actions_pull_request_approval_disabled` ([#12394](https://github.com/prowler-cloud/prowler/pull/12394)) checks + - @tamg-aws: four AWS Bedrock checks covering guardrail grounding, CMK encryption, and agent role isolation ([#12459](https://github.com/prowler-cloud/prowler/pull/12459)) + - @esquaredsec: AWS `ecr_repository_image_no_secrets` check ([#12123](https://github.com/prowler-cloud/prowler/pull/12123)) + - @Rishi943: Microsoft 365 `defender_domain_dmarc_records_published` check ([#11936](https://github.com/prowler-cloud/prowler/pull/11936)) + - @abidedavana: Alibaba Cloud `oss_bucket_versioning_enabled` check ([#11913](https://github.com/prowler-cloud/prowler/pull/11913)) + - @alexchen-sys: Alibaba Cloud `oss_bucket_server_side_encryption_enabled` check ([#11981](https://github.com/prowler-cloud/prowler/pull/11981)) + - @tomitobio: Huawei Cloud `vpc_security_group_open_egress` check ([#12209](https://github.com/prowler-cloud/prowler/pull/12209)) + - @johannes-engler-mw: STACKIT `ske_cluster_no_public_endpoint` check ([#11943](https://github.com/prowler-cloud/prowler/pull/11943)) + - @gabrielfrdev: cluster name in Kubernetes compliance report outputs ([#12506](https://github.com/prowler-cloud/prowler/pull/12506)) + - @jfgmesquita: AWS FSBP compliance mapping fix for IAM.9 and EKS.1 ([#12372](https://github.com/prowler-cloud/prowler/pull/12372)) + - @hackertwinten: `ec2_securitygroup_not_used` no longer flags security groups held only by scaled-down AWS Batch compute environments ([#12458](https://github.com/prowler-cloud/prowler/pull/12458)) + - @0xTaoZ: ECS task-definition checks no longer report PASS when `DescribeTaskDefinition` fails, shipped early in v5.39.1 ([#12217](https://github.com/prowler-cloud/prowler/pull/12217)) + - @ye11oc4t: `ses_identity_not_publicly_accessible` now evaluates every identity authorization policy, shipped early in v5.39.1 ([#12464](https://github.com/prowler-cloud/prowler/pull/12464)) + - @Zuhef: IaC provider raises typed exceptions instead of `sys.exit` when cloning the scanned repository or running Trivy fails ([#12227](https://github.com/prowler-cloud/prowler/pull/12227)), Kubernetes kubelet checks no longer disappear from the scan when a `kubelet-config` ConfigMap is broken ([#12225](https://github.com/prowler-cloud/prowler/pull/12225)), and the CLI `--slack` summary is sent for scans with no findings instead of failing with `ZeroDivisionError` ([#12229](https://github.com/prowler-cloud/prowler/pull/12229)) + - @m-khan-97: NCSC Cyber Essentials 3.3 compliance framework with Azure provider coverage across the five Cyber Essentials themes ([#11588](https://github.com/prowler-cloud/prowler/pull/11588)) + + See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.40.0) for the complete list of changes. + + + + ### 🤖 Lighthouse AI — Finding Skills + + + This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing). + + + Lighthouse AI now embeds a Skills menu on every finding, answering the questions an analyst actually asks. **Contextual Fix** produces the fix for the finding, **Triage Decision** judges whether it is real and closes it out when it is not, and **Systemic Scope** determines whether the problem is a one-off or everywhere. A free-form "Ask Lighthouse anything" prompt sits in the same menu, and each run shows its progress and offers follow-up actions such as creating a Jira issue or muting the finding. + + ![Lighthouse AI Skills menu on a finding resource](/images/changelog/v5.39.0-lighthouse-finding-skills.png) + + Read more in the [Lighthouse AI documentation](https://docs.prowler.com/getting-started/products/prowler-cloud-lighthouse). + + ### ☁️ Azure Management Group Onboarding + + + This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing). + + + Azure subscriptions no longer onboard one at a time. Choose "Add Multiple Subscriptions With Azure Management Group" in the add-provider wizard, enter the Microsoft Entra tenant ID, and authenticate once with a single tenant-wide service principal: Prowler discovers the entire management-group hierarchy under the tenant root, lets you select the subscriptions to onboard, and creates their providers with the management-group structure preserved. Azure now matches the one-step onboarding that AWS Organizations and GCP organizations already have. + + ![Azure onboarding method selector with the Management Group option](/images/changelog/v5.39.0-azure-mg-selector.png) + + Read more in the [Azure Management Groups documentation](https://docs.prowler.com/user-guide/tutorials/prowler-cloud-azure-management-groups). + + ### ✅ Findings Triage — Verify MANUAL Findings as PASS + + + This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing). + + + Checks that require human judgment report `MANUAL` findings. For these findings, and only for them, the triage status selector now offers **Resolved**: choosing it asks for the required written evidence and verifies the finding as passing. The finding then reports an effective `PASS` while preserving the raw `MANUAL` scan result, across findings, finding groups, compliance reports, and scans, with the attestation's author, evidence, and validity always visible. Attestations expire automatically after 90 days, or as soon as a new scan reports a real failure, returning the finding to the review queue. + + ![Triage status selector offering Resolved on a MANUAL finding](/images/changelog/v5.39.0-manual-pass-selector.png) + + ![Manual Pass details showing evidence, author, and validity](/images/changelog/v5.39.0-manual-pass-details.png) + + Read more in the [Findings Triage documentation](https://docs.prowler.com/user-guide/tutorials/prowler-app-findings-triage#verify-a-manual-finding-as-pass). + + ### ☁️ Prowler Cloud MCP — Organizations Management and Grouped Jira Dispatch + + + This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing). + + + The hosted Prowler Cloud MCP server adds eight organization tools, so an agent can onboard and manage entire cloud organizations end to end: create the organization, discover its accounts, subscriptions, and projects, apply the selection, and manage the resulting providers. The tools cover AWS Organizations, GCP organizations, and Azure tenant root management groups, and they are available to Lighthouse AI. + + `prowler_send_findings_to_jira` also gains Cloud-only dispatch capabilities: select failed findings by check IDs against the latest completed scan, and send them in grouped mode, one Jira work item per check listing up to 50 affected resources, with per-group error reporting. + + Read more in the [Prowler MCP tools documentation](https://docs.prowler.com/getting-started/basic-usage/prowler-mcp-tools) and its [Jira operations reference](https://docs.prowler.com/getting-started/basic-usage/prowler-mcp-tools#jira-operations). + + ### 🕸️ Attack Paths — Grouped Graph with Outcome Destinations + + + This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing). + + + The Attack Paths graph now reads from source to destination. Resources of the same class collapse into a single expandable node with a count, clicking reveals its members, and every path terminates in an explicit outcome node naming the destination impact: code execution, privilege escalation, public exposure, or resource inventory. The per-account hub node is gone, and the clicked resource stays highlighted while its findings are expanded. + + ![Attack Paths graph from the Internet to a public exposure outcome node](/images/changelog/v5.39.0-attack-paths-graph.png) + + Explore the full Attack Paths query catalog at [Prowler Hub](https://hub.prowler.com/attack-paths). + + Read more in the [Attack Paths documentation](https://docs.prowler.com/user-guide/tutorials/prowler-app-attack-paths). + + ### 📚 New Compliance Framework — CMMC 2.0 + + The Cybersecurity Maturity Model Certification (CMMC) is the certification the US Department of Defense requires from contractors and suppliers that handle federal contract data. Prowler now includes CMMC 2.0 as a universal framework with all 149 requirements defined by the CMMC Program rule (32 CFR Part 170), organized in its three levels: + + - **Level 1 (Foundational):** 15 requirements for the basic safeguarding of Federal Contract Information, from FAR 52.204-21. + - **Level 2 (Advanced):** 110 requirements from NIST SP 800-171 Rev 2, protecting Controlled Unclassified Information. + - **Level 3 (Expert):** 24 enhanced requirements from NIST SP 800-172 for the most sensitive programs. + + Requirements map to Prowler checks across AWS, Azure, GCP, Alibaba Cloud, Oracle Cloud, and Microsoft 365, so one framework reports the compliance posture of the whole estate. + + Read more in the [Compliance documentation](https://docs.prowler.com/user-guide/compliance/tutorials/compliance). + + ### 🔍 Checks + + #### Microsoft 365 + + Twenty new Entra ID checks expand the coverage of CIS Microsoft 365 Foundations Benchmark v7.0.0: + + - **Password protection:** custom banned password list, on-premises enforcement, and lockout threshold and duration. + - **Default user permissions:** security group and Microsoft 365 group creation restricted, and guest invitations limited to allowed domains. + - **Conditional Access:** high and medium sign-in risk blocked, authentication transfer blocked, untrusted locations blocked, trusted named locations defined, sign-in frequency enforced, and token protection enforced. + - **Sessions and authentication methods:** idle session timeout configured, email one-time passcodes disabled, and Microsoft Authenticator context shown. + - **PIM and access reviews:** approval required to activate the Global Administrator and Privileged Role Administrator roles, and access reviews configured for guest users and privileged roles. + + Explore all Microsoft 365 checks at [Prowler Hub](https://hub.prowler.com/check?provider=m365). + + #### AWS + + Two new checks detect hardcoded secrets: + + - `batch_job_definition_no_secrets` scans Batch job definition environment variables and command parameters. Thanks to @praneetrajv! + - `awslambda_layer_no_secrets_in_content` scans Lambda layer package content. Thanks to @ganiganesh25! + + Explore all AWS checks at [Prowler Hub](https://hub.prowler.com/check?provider=aws). + + ### 🙌 External Contributors + + Thank you to our community contributors for this release! + + - @praneetrajv: AWS `batch_job_definition_no_secrets` check ([#12117](https://github.com/prowler-cloud/prowler/pull/12117)) + - @ganiganesh25: AWS `awslambda_layer_no_secrets_in_content` check ([#12233](https://github.com/prowler-cloud/prowler/pull/12233)) + - @andoniaf: GitHub `organization_repository_creation_limited` now reports low severity when repository creation is limited to private or internal visibility ([#12164](https://github.com/prowler-cloud/prowler/pull/12164)) + + See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.39.0) for the complete list of changes. + + ### 📌 Compliance Watchlist @@ -165,7 +686,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 ad24731b4f..a653148a4c 100644 --- a/docs/developer-guide/aws-details.mdx +++ b/docs/developer-guide/aws-details.mdx @@ -57,7 +57,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. @@ -131,7 +131,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 dca22cb0cd..6f500ae9ec 100644 --- a/docs/developer-guide/checks.mdx +++ b/docs/developer-guide/checks.mdx @@ -129,12 +129,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. @@ -224,9 +254,9 @@ Each check **must** populate the report with a unique identifier for the audited - `resource_name`: Description of the configuration (e.g., "SharePoint Settings") - GitHub - Resource ID — `report.resource_id`. - - The ID of the Github resource. This is a system-generated integer that uniquely identifies the resource within the Github platform. + - The ID of the GitHub resource. This is a system-generated integer that uniquely identifies the resource within the GitHub platform. - Resource Name — `report.resource_name`. - - The name of the Github resource. In the case of a repository, this is just the repository name. For full repository names use the resource `full_name`. + - The name of the GitHub resource. In the case of a repository, this is just the repository name. For full repository names use the resource `full_name`. ### Configurable Checks in Prowler @@ -437,6 +467,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 04a31a8cde..c44f82c5aa 100644 --- a/docs/developer-guide/configurable-checks.mdx +++ b/docs/developer-guide/configurable-checks.mdx @@ -154,6 +154,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 647b23c614..8bb6643eb7 100644 --- a/docs/developer-guide/debugging.mdx +++ b/docs/developer-guide/debugging.mdx @@ -10,7 +10,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 df99de6fee..f8b54af222 100644 --- a/docs/developer-guide/end2end-testing.mdx +++ b/docs/developer-guide/end2end-testing.mdx @@ -50,7 +50,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 @@ -71,7 +71,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 @@ -256,7 +256,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 e2bec0f94b..863f49cd7f 100644 --- a/docs/developer-guide/environment-variables.mdx +++ b/docs/developer-guide/environment-variables.mdx @@ -40,6 +40,19 @@ 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 Registry links read `PROWLER_REGISTRY_INDEX_URL`, the same base URL the backend installs artifacts from. The Registry page and the credential banner link to it, and its origin is allowed for images. `UI_REGISTRY_MEDIA_URL` adds the Registry media service origin so artifact logos load. When `PROWLER_REGISTRY_INDEX_URL` is unset or invalid, the links are hidden instead of pointing to the public Prowler Registry, which keeps private and air-gapped deployments from sending users to an unreachable host. `UI_REGISTRY_URL` is no longer read. + +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/mcp-server.mdx b/docs/developer-guide/mcp-server.mdx index 186786651f..7e1461249e 100644 --- a/docs/developer-guide/mcp-server.mdx +++ b/docs/developer-guide/mcp-server.mdx @@ -120,14 +120,14 @@ class NewFeatureTools(BaseTool): Returns complete feature details including configuration and metadata. """ - try: - response = await self.api_client.get(f"/api/v1/features/{feature_id}") - return DetailedFeature.from_api_response(response["data"]).model_dump() - except Exception as e: - self.logger.error(f"Failed to get feature {feature_id}: {e}") - return {"error": str(e), "status": "failed"} + response = await self.api_client.get(f"/api/v1/features/{feature_id}") + return DetailedFeature.from_api_response(response["data"]).model_dump() ``` +There is no `try`/`except` here on purpose. A failed request raises, and +[Error Handling](#error-handling) explains what turns that raise into a message +the agent can act on. + ### Step 2: Create the Models Create corresponding models in `prowler_app/models/`: @@ -369,18 +369,62 @@ async def search_items(self, status: str = Field(...)) -> dict: ### Error Handling -Return structured error responses instead of raising exceptions: +**Raise, never return.** A returned `{"error": ...}` dict is reported to the +client as `isError: false` -- a *successful* tool call whose payload happens to +mention a failure. Clients and models read that as success. A raised exception +becomes a spec-correct tool execution error instead. + +The common case therefore needs no handler at all: ```python async def get_item(self, item_id: str) -> dict: - try: - response = await self.api_client.get(f"/api/v1/items/{item_id}") - return DetailedItem.from_api_response(response["data"]).model_dump() - except Exception as e: - self.logger.error(f"Failed to get item {item_id}: {e}") - return {"error": str(e), "status": "failed"} + response = await self.api_client.get(f"/api/v1/items/{item_id}") + return DetailedItem.from_api_response(response["data"]).model_dump() ``` +`prowler_mcp_server/lib/errors.py` classifies the failures every tool shares -- +a rejected credential, a missing permission, a rate limit, an outage, an +unreachable API, a bad argument -- and gives each one a message that says what +went wrong and what to do about it. Anything it does not recognise is masked, +because `mask_error_details=True` is set on every sub-server and upstream +response bodies must never be replayed into a model's context. + +Three ways to raise, in the order to reach for them: + +```python +from fastmcp.exceptions import ToolError + +from prowler_mcp_server.lib.errors import InvalidArgument + +# 1. An argument this server rejected before any request went out. The message +# is repeated to the agent verbatim, so write it for one to read. +if not 1 <= page_size <= 1000: + raise InvalidArgument("page_size must be between 1 and 1000.") + +# 2. A request the API answered or never answered: let it propagate untouched. +# `ProwlerAPIError` and `ProwlerAPIUnreachable` are what the classifier keys +# on, and the second one is what stops a retry from duplicating a write. +response = await self.api_client.get(f"/api/v1/items/{item_id}") +data = response["data"] + +# 3. A sentence the classifier cannot know -- a resource name, a precondition, +# the next tool to call. NOTE the absent `from` clause: it is what marks the +# message as already final. With `from e` the classifier would replace it. +if not data: + raise ToolError( + f"No item with the ID {item_id!r} exists. Use prowler_list_items to " + "find a valid one." + ) +``` + +The one thing that still *returns* rather than raises is a write whose outcome is +genuinely unknown. `prowler_send_findings_to_jira` is the worked example: work +items are created one at a time and Prowler cannot delete them, so a dispatch +that stopped halfway answers with a result object carrying +`safe_to_retry: false`. "This may have been applied" is a fact about the world, +not an error, and squashing it into one loses the only thing that stops a retry +from duplicating the write. + ### Parameter Descriptions Use Pydantic `Field()` with clear descriptions. This also helps LLMs understand diff --git a/docs/developer-guide/provider.mdx b/docs/developer-guide/provider.mdx index be709a12a2..3d96e41ab5 100644 --- a/docs/developer-guide/provider.mdx +++ b/docs/developer-guide/provider.mdx @@ -25,7 +25,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. @@ -107,7 +107,7 @@ Once you have decided the provider you want or need to add to Prowler, the next - **SDK Providers**: Low complexity. You have mature examples like AWS, Azure, GCP, Kubernetes, etc. that you can leverage to implement your provider. - **API Providers**: Medium complexity. You need to implement the authentication and session management, and the API calls to the provider. You now have NHN and MongoDB Atlas as example to follow. - **Tool/Wrapper Providers**: High complexity. You need to implement the argument/output mapping to the provider and handle problems that the tool/wrapper may have. You now have IAC and the PowerShell wrapper as example to follow. -- **Hybrid Providers**: High complexity. You need to "customize" your provider, mixing the other types of providers in order to achieve the desired result. You have M365 (msgraph SDK + PowerShell wrapper) and Github (PyGithub SDK + graphql API requests) as examples. +- **Hybrid Providers**: High complexity. You need to "customize" your provider, mixing the other types of providers to achieve the desired result. You have M365 (msgraph SDK + PowerShell wrapper) and GitHub (PyGithub SDK + graphql API requests) as examples. ### Determining Regional vs Non-Regional Architecture @@ -814,7 +814,7 @@ class YourProviderMutelist(Mutelist): Region management is essential for cloud providers that operate across multiple geographic locations. This component handles region validation and provides region-specific functionality. -Regions are optional, only if the provider has regions, for example Github does not have regions, but AWS does. +Regions are optional, only if the provider has regions, for example GitHub does not have regions, but AWS does. **File:** `prowler/providers//lib/regions/_regions.py` @@ -1090,7 +1090,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. @@ -1773,7 +1773,7 @@ The implementation of the mutelist is the same as the [SDK providers](#step-5-im Region management is essential for cloud providers that operate across multiple geographic locations. This component handles region validation and provides region-specific functionality. -Regions are optional, only if the provider has regions, for example Github does not have regions, but AWS does. +Regions are optional, only if the provider has regions, for example GitHub does not have regions, but AWS does. **File:** `prowler/providers//lib/regions/_regions.py` @@ -1966,7 +1966,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. @@ -2648,7 +2648,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. @@ -2808,7 +2808,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. @@ -3209,7 +3209,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. @@ -3272,7 +3272,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 46d8d2722e..3dc84dc58c 100644 --- a/docs/developer-guide/security-compliance-framework.mdx +++ b/docs/developer-guide/security-compliance-framework.mdx @@ -18,7 +18,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 | | --- | --- | --- | --- | @@ -45,7 +45,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: @@ -57,7 +57,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 { @@ -198,7 +198,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. @@ -226,7 +226,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: @@ -259,7 +259,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`). @@ -362,7 +362,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. @@ -408,7 +408,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. @@ -425,7 +425,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: @@ -439,7 +439,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. @@ -453,19 +453,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. @@ -474,7 +474,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. @@ -497,7 +497,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: @@ -506,7 +506,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: @@ -533,7 +533,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. @@ -547,7 +547,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: @@ -590,7 +590,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: @@ -616,7 +616,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: @@ -630,7 +630,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). @@ -639,7 +639,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: @@ -670,7 +670,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`. @@ -688,7 +688,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: @@ -708,7 +708,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 @@ -728,7 +728,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 c91f27227f..6fb9c7a00e 100644 --- a/docs/developer-guide/server-sent-events.mdx +++ b/docs/developer-guide/server-sent-events.mdx @@ -12,7 +12,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 | |------|-----| @@ -22,7 +22,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/`: @@ -34,11 +34,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) @@ -53,7 +53,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. @@ -161,7 +161,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. @@ -197,7 +197,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: @@ -206,7 +206,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: @@ -215,7 +215,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 775c0f52c7..beda2dcd56 100644 --- a/docs/developer-guide/services.mdx +++ b/docs/developer-guide/services.mdx @@ -537,7 +537,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 ca6d068bd4..0a07257b0b 100644 --- a/docs/developer-guide/unit-testing.mdx +++ b/docs/developer-guide/unit-testing.mdx @@ -154,7 +154,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): @@ -408,7 +408,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 @@ -475,7 +475,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 @@ -525,7 +525,7 @@ with mock.patch( ): ``` -As demonstrated in the code above, mocking both the AWS audit information and all utilized services is mandatory for proper test execution. +As demonstrated in the code above, mocking both the AWS audit information and all used services is mandatory for proper test execution. #### Patching vs. Importing @@ -603,7 +603,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/docs.json b/docs/docs.json index 363d6ac082..c67a053b24 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -182,7 +182,8 @@ "pages": [ "user-guide/tutorials/prowler-app-s3-integration", "user-guide/tutorials/prowler-app-security-hub-integration", - "user-guide/tutorials/prowler-app-jira-integration" + "user-guide/tutorials/prowler-app-jira-integration", + "user-guide/tutorials/prowler-app-slack-integration" ] }, { @@ -301,6 +302,15 @@ { "group": "Providers", "pages": [ + { + "group": "Organizations", + "pages": [ + "user-guide/organizations", + "user-guide/providers/aws/organizations", + "user-guide/providers/gcp/organization", + "user-guide/providers/azure/management-groups" + ] + }, { "group": "Alibaba Cloud", "pages": [ @@ -330,6 +340,7 @@ "user-guide/providers/azure/getting-started-azure", "user-guide/providers/azure/authentication", "user-guide/providers/azure/use-non-default-cloud", + "user-guide/providers/azure/management-groups", "user-guide/providers/azure/subscriptions", "user-guide/providers/azure/resource-groups", "user-guide/providers/azure/create-prowler-service-principal" diff --git a/docs/getting-started/basic-usage/prowler-cli.mdx b/docs/getting-started/basic-usage/prowler-cli.mdx index 0efa1ce8f1..d64bef0b77 100644 --- a/docs/getting-started/basic-usage/prowler-cli.mdx +++ b/docs/getting-started/basic-usage/prowler-cli.mdx @@ -2,9 +2,11 @@ title: 'Basic Usage' --- +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. @@ -91,6 +93,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/basic-usage/prowler-mcp-tools.mdx b/docs/getting-started/basic-usage/prowler-mcp-tools.mdx index 0a7ae1f271..24cb2f4b2a 100644 --- a/docs/getting-started/basic-usage/prowler-mcp-tools.mdx +++ b/docs/getting-started/basic-usage/prowler-mcp-tools.mdx @@ -11,10 +11,10 @@ Complete reference guide for all tools available in the Prowler MCP Server. Tool | Prowler Hub | 10 tools | No | Cloud and Local MCP Server | | Prowler Documentation | 2 tools | No | Cloud and Local MCP Server | | Prowler Cloud, Private Cloud & Local Server | 49 tools | Yes | Cloud and Local MCP Server | -| Prowler Cloud management | 32 tools | Yes | Cloud MCP Server only | +| Prowler Cloud management | 40 tools | Yes | Cloud MCP Server only | -48 of the 49 Prowler tools are available on both servers. `prowler_schedule_daily_scan` is the exception: it is Local-only, because the Cloud MCP Server supersedes it with the `prowler_cloud_*` [Scan Scheduling](#scan-scheduling) tools. +48 of the 49 Prowler tools are available on both servers. `prowler_schedule_daily_scan` is the exception: it is Local-only, because the Cloud MCP Server supersedes it with the `prowler_cloud_*` [Scan Scheduling](#scan-scheduling) tools. `prowler_send_findings_to_jira` is exposed by both servers but accepts two [extra parameters](#jira-operations) on the Cloud MCP Server. ## Tool Naming Convention @@ -124,7 +124,20 @@ Tools for managing where Prowler sends its results: Amazon S3 buckets, AWS Secur #### Jira Operations - **`prowler_get_jira_issue_types`** - List the issue types available in a Jira project, fetched live from Jira -- **`prowler_send_findings_to_jira`** - Create one Jira work item per finding, with its severity, resource, risk, and remediation steps +- **`prowler_send_findings_to_jira`** - Create Jira work items from findings, each carrying the check title, severity, status, provider, region, resource, risk, and remediation steps. Select the findings either by ID with `finding_ids`, or — on Prowler Cloud only — by check with `check_ids`, and choose between one work item per finding or one per check with `dispatch_mode` + + +`check_ids` and `dispatch_mode` are **Prowler Cloud only**: + +- **`check_ids`** - Send the failing findings of a check (for example `s3_bucket_public_access`) without listing their IDs. Prowler resolves them server-side, taking only the failed findings of the latest completed scan of every provider. Get the check IDs from `prowler_list_finding_groups`. Exactly one of `finding_ids` or `check_ids` is required — Prowler combines both filters, so sending both would only dispatch their intersection. A Local MCP Server rejects `check_ids` with a client error. +- **`dispatch_mode`** - `individual` (the default) creates one work item per finding. `grouped` creates one work item per check instead, listing up to 50 affected resources and linking back to the finding group in Prowler Cloud, which keeps a noisy check to a single ticket. Grouped dispatch only covers failed, unmuted findings of the latest completed scan of every provider. A Local MCP Server ignores `dispatch_mode` instead of rejecting it, and creates one work item per finding. + +In `grouped` mode the response counters change meaning: `created_count` counts work items (one per check) rather than findings, `failed_count` counts the entries of the new `failed_groups` field, and `failed_groups` details each failure with its reason and the `check_id` whose work item could not be created. + + + +`prowler_send_findings_to_jira` creates real work items that Prowler cannot delete or update afterwards. Only retry the same dispatch when the previous response returned `safe_to_retry: true`, otherwise the work items already created are duplicated. Combining `check_ids` with the default `individual` mode opens one work item per failing resource, which can be hundreds of them — use `dispatch_mode="grouped"` to keep it to one per check. + ### Attack Paths Analysis @@ -167,6 +180,23 @@ Manage Prowler Cloud-only features and configuration. **Requires authentication. These tools are available **only on the Cloud MCP Server** (`https://mcp.prowler.com/mcp`). A Local MCP Server does not expose them, because the features they manage exist only in Prowler Cloud. +### Organizations + +Tools for onboarding a cloud provider organization as a whole — an AWS Organization, an Azure tenant with its management groups, or a GCP organization with its folders. An organization holds org-level credentials, discovers the real account, subscription, or project structure in the cloud, and turns a selection from that discovery into Prowler providers linked into a hierarchy of nodes. Every tool that changes something — creating, updating, deleting, discovering, applying a discovery, or adjusting provider membership — requires the **Manage Providers** permission; listing and reading do not. + + +Use these tools for the whole organization. To register providers one by one, use the [Provider Management](#provider-management) tools instead; to build arbitrary RBAC buckets of providers, use provider groups. + + +- **`prowler_cloud_list_organizations`** - Browse the registered organizations with lightweight data (name, type, external id, provider and node counts), filtered by type or cloud-side external id +- **`prowler_cloud_get_organization`** - Get one organization in full: attributes, linked providers, credentials status, latest discovery, and the OU / management group / folder hierarchy. Set `include_hierarchy` to `false` to skip the tree on large organizations +- **`prowler_cloud_create_organization`** - Register an organization, optionally storing its org-level credentials in the same call. Idempotent: an organization with the same type and external id is reused and its credentials rotated, reported as `created: false` +- **`prowler_cloud_update_organization`** - Rename an organization, replace its metadata, and/or create or rotate its org-level credentials. `org_type` and `external_id` are immutable after creation +- **`prowler_cloud_delete_organization`** - Delete an organization, its entire hierarchy, and every linked provider +- **`prowler_cloud_discover_organization`** - Enumerate the real cloud structure: AWS accounts and OUs, Azure subscriptions and management groups, or GCP projects and folders. Each item comes back with its registration state so you can choose what to onboard +- **`prowler_cloud_apply_organization_discovery`** - Turn a discovery selection into Prowler providers and hierarchy nodes +- **`prowler_cloud_manage_organization_providers`** - Manually `add`, `replace`, or `remove` the providers linked to an organization or to one of its hierarchy nodes. Providers are detached, never deleted + ### Scan Configurations Tools for managing reusable scan configurations — per-provider check and compliance selections — and attaching them to providers. Providers without a configuration attached use the default. diff --git a/docs/getting-started/comparison/gcp.mdx b/docs/getting-started/comparison/gcp.mdx index 00f61091d7..7c80b990a0 100644 --- a/docs/getting-started/comparison/gcp.mdx +++ b/docs/getting-started/comparison/gcp.mdx @@ -22,7 +22,7 @@ Google Cloud Security Command Center (Cloud SCC) is a centralized security and r - **GCP-Centric:** While Cloud SCC is powerful within the GCP ecosystem, it is primarily focused on GCP and does not natively extend to multi-cloud environments without additional tools or connectors. -- **Cost Considerations:** As a managed service within GCP, costs can scale with the amount of data ingested and the complexity of the environment, especially as additional features or higher volumes of data are utilized. +- **Cost Considerations:** As a managed service within GCP, costs can scale with the amount of data ingested and the complexity of the environment, especially as additional features or higher volumes of data are used. - **Dependency on GCP Services:** Cloud SCC's capabilities depend on other GCP services being enabled, such as Security Health Analytics and Web Security Scanner, which may increase overall complexity and cost. diff --git a/docs/getting-started/installation/prowler-app.mdx b/docs/getting-started/installation/prowler-app.mdx index 529d544fa6..c768d078c4 100644 --- a/docs/getting-started/installation/prowler-app.mdx +++ b/docs/getting-started/installation/prowler-app.mdx @@ -128,12 +128,12 @@ To update the environment file: Edit the `.env` file and change version values: ```env -PROWLER_UI_VERSION="5.38.0" -PROWLER_API_VERSION="5.38.0" +PROWLER_UI_VERSION="5.44.0" +PROWLER_API_VERSION="5.44.0" ``` - You can find the latest versions of Prowler Local Server in the [Releases Github section](https://github.com/prowler-cloud/prowler/releases) or in the [Container Versions](#container-versions) section of this documentation. + You can find the latest versions of Prowler Local Server in the [Releases GitHub section](https://github.com/prowler-cloud/prowler/releases) or in the [Container Versions](#container-versions) section of this documentation. diff --git a/docs/getting-started/installation/prowler-cli.mdx b/docs/getting-started/installation/prowler-cli.mdx index 717b0e7cc7..597ea13319 100644 --- a/docs/getting-started/installation/prowler-cli.mdx +++ b/docs/getting-started/installation/prowler-cli.mdx @@ -45,7 +45,7 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.13`. Prowler i _Requirements_: * Have `docker` installed: https://docs.docker.com/get-docker/. - * In the command below, change `-v` to your local directory path in order to access the reports. + * In the command below, change `-v` to your local directory path to access the reports. * AWS, GCP, Azure and/or Kubernetes credentials _Commands_: diff --git a/docs/getting-started/products/prowler-mcp.mdx b/docs/getting-started/products/prowler-mcp.mdx index 3deb88489c..24520ed739 100644 --- a/docs/getting-started/products/prowler-mcp.mdx +++ b/docs/getting-started/products/prowler-mcp.mdx @@ -161,7 +161,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/changelog/v5.39.0-attack-paths-graph.png b/docs/images/changelog/v5.39.0-attack-paths-graph.png new file mode 100644 index 0000000000..68cd44278f Binary files /dev/null and b/docs/images/changelog/v5.39.0-attack-paths-graph.png differ diff --git a/docs/images/changelog/v5.39.0-azure-mg-selector.png b/docs/images/changelog/v5.39.0-azure-mg-selector.png new file mode 100644 index 0000000000..2badc72932 Binary files /dev/null and b/docs/images/changelog/v5.39.0-azure-mg-selector.png differ diff --git a/docs/images/changelog/v5.39.0-lighthouse-finding-skills.png b/docs/images/changelog/v5.39.0-lighthouse-finding-skills.png new file mode 100644 index 0000000000..06775297eb Binary files /dev/null and b/docs/images/changelog/v5.39.0-lighthouse-finding-skills.png differ diff --git a/docs/images/changelog/v5.39.0-manual-pass-details.png b/docs/images/changelog/v5.39.0-manual-pass-details.png new file mode 100644 index 0000000000..1914cc71e3 Binary files /dev/null and b/docs/images/changelog/v5.39.0-manual-pass-details.png differ diff --git a/docs/images/changelog/v5.39.0-manual-pass-selector.png b/docs/images/changelog/v5.39.0-manual-pass-selector.png new file mode 100644 index 0000000000..f14b05f877 Binary files /dev/null and b/docs/images/changelog/v5.39.0-manual-pass-selector.png differ diff --git a/docs/images/changelog/v5.40.0-imported-provider-indicator.png b/docs/images/changelog/v5.40.0-imported-provider-indicator.png new file mode 100644 index 0000000000..8792ec4471 Binary files /dev/null and b/docs/images/changelog/v5.40.0-imported-provider-indicator.png differ diff --git a/docs/images/organizations/azure/azure-authentication-details.png b/docs/images/organizations/azure/azure-authentication-details.png new file mode 100644 index 0000000000..998b089e77 Binary files /dev/null and b/docs/images/organizations/azure/azure-authentication-details.png differ diff --git a/docs/images/organizations/azure/azure-blocked-subscription.png b/docs/images/organizations/azure/azure-blocked-subscription.png new file mode 100644 index 0000000000..2c90d1606c Binary files /dev/null and b/docs/images/organizations/azure/azure-blocked-subscription.png differ diff --git a/docs/images/organizations/azure/azure-delete-organization.png b/docs/images/organizations/azure/azure-delete-organization.png new file mode 100644 index 0000000000..bad166184e Binary files /dev/null and b/docs/images/organizations/azure/azure-delete-organization.png differ diff --git a/docs/images/organizations/azure/azure-discovery-timeout.png b/docs/images/organizations/azure/azure-discovery-timeout.png new file mode 100644 index 0000000000..54d37359e3 Binary files /dev/null and b/docs/images/organizations/azure/azure-discovery-timeout.png differ diff --git a/docs/images/organizations/azure/azure-gathering-subscriptions.png b/docs/images/organizations/azure/azure-gathering-subscriptions.png new file mode 100644 index 0000000000..82ff78f967 Binary files /dev/null and b/docs/images/organizations/azure/azure-gathering-subscriptions.png differ diff --git a/docs/images/organizations/azure/azure-inert-group.png b/docs/images/organizations/azure/azure-inert-group.png new file mode 100644 index 0000000000..0d59f73d37 Binary files /dev/null and b/docs/images/organizations/azure/azure-inert-group.png differ diff --git a/docs/images/organizations/azure/azure-launch-scan.png b/docs/images/organizations/azure/azure-launch-scan.png new file mode 100644 index 0000000000..5505e8cb7f Binary files /dev/null and b/docs/images/organizations/azure/azure-launch-scan.png differ diff --git a/docs/images/organizations/azure/azure-organization-details-form.png b/docs/images/organizations/azure/azure-organization-details-form.png new file mode 100644 index 0000000000..847960ce93 Binary files /dev/null and b/docs/images/organizations/azure/azure-organization-details-form.png differ diff --git a/docs/images/organizations/azure/azure-organization-row-actions.png b/docs/images/organizations/azure/azure-organization-row-actions.png new file mode 100644 index 0000000000..5a7e810abe Binary files /dev/null and b/docs/images/organizations/azure/azure-organization-row-actions.png differ diff --git a/docs/images/organizations/azure/azure-providers-grouping.png b/docs/images/organizations/azure/azure-providers-grouping.png new file mode 100644 index 0000000000..66edf82821 Binary files /dev/null and b/docs/images/organizations/azure/azure-providers-grouping.png differ diff --git a/docs/images/organizations/azure/azure-replace-credentials-apply.png b/docs/images/organizations/azure/azure-replace-credentials-apply.png new file mode 100644 index 0000000000..a3f7d50a78 Binary files /dev/null and b/docs/images/organizations/azure/azure-replace-credentials-apply.png differ diff --git a/docs/images/organizations/azure/azure-replace-credentials-setup.png b/docs/images/organizations/azure/azure-replace-credentials-setup.png new file mode 100644 index 0000000000..04a3ad1b3a Binary files /dev/null and b/docs/images/organizations/azure/azure-replace-credentials-setup.png differ diff --git a/docs/images/organizations/azure/azure-test-connections.png b/docs/images/organizations/azure/azure-test-connections.png new file mode 100644 index 0000000000..6ed284c6d7 Binary files /dev/null and b/docs/images/organizations/azure/azure-test-connections.png differ diff --git a/docs/images/organizations/azure/azure-tree-view-subscriptions.png b/docs/images/organizations/azure/azure-tree-view-subscriptions.png new file mode 100644 index 0000000000..2af3fb3762 Binary files /dev/null and b/docs/images/organizations/azure/azure-tree-view-subscriptions.png differ diff --git a/docs/images/organizations/azure/select-azure-management-groups-method.png b/docs/images/organizations/azure/select-azure-management-groups-method.png new file mode 100644 index 0000000000..c95405ffb0 Binary files /dev/null and b/docs/images/organizations/azure/select-azure-management-groups-method.png differ diff --git a/docs/images/organizations/azure/select-azure-provider.png b/docs/images/organizations/azure/select-azure-provider.png new file mode 100644 index 0000000000..54f390ab72 Binary files /dev/null and b/docs/images/organizations/azure/select-azure-provider.png differ 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/alerts-list.png b/docs/images/prowler-app/alerts/alerts-list.png index 153f5d6b24..5ad9226e30 100644 Binary files a/docs/images/prowler-app/alerts/alerts-list.png and b/docs/images/prowler-app/alerts/alerts-list.png differ diff --git a/docs/images/prowler-app/alerts/create-alert-from-findings.png b/docs/images/prowler-app/alerts/create-alert-from-findings.png index 845aa627d3..a12205da34 100644 Binary files a/docs/images/prowler-app/alerts/create-alert-from-findings.png and b/docs/images/prowler-app/alerts/create-alert-from-findings.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 46ef36b690..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 2047792ca4..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/attack-paths/fullscreen-mode.png b/docs/images/prowler-app/attack-paths/fullscreen-mode.png index 190d973638..ff88c2b6de 100644 Binary files a/docs/images/prowler-app/attack-paths/fullscreen-mode.png and b/docs/images/prowler-app/attack-paths/fullscreen-mode.png differ diff --git a/docs/images/prowler-app/attack-paths/graph-filtered.png b/docs/images/prowler-app/attack-paths/graph-filtered.png index 3eddf04473..59fff5b50f 100644 Binary files a/docs/images/prowler-app/attack-paths/graph-filtered.png and b/docs/images/prowler-app/attack-paths/graph-filtered.png differ diff --git a/docs/images/prowler-app/attack-paths/graph-visualization-expanded.png b/docs/images/prowler-app/attack-paths/graph-visualization-expanded.png new file mode 100644 index 0000000000..a870df7519 Binary files /dev/null and b/docs/images/prowler-app/attack-paths/graph-visualization-expanded.png differ diff --git a/docs/images/prowler-app/attack-paths/graph-visualization.png b/docs/images/prowler-app/attack-paths/graph-visualization.png index d7f2a747eb..af1371c0d8 100644 Binary files a/docs/images/prowler-app/attack-paths/graph-visualization.png and b/docs/images/prowler-app/attack-paths/graph-visualization.png differ diff --git a/docs/images/prowler-app/attack-paths/navigation.png b/docs/images/prowler-app/attack-paths/navigation.png index 0526e05e5c..560a2d10a1 100644 Binary files a/docs/images/prowler-app/attack-paths/navigation.png and b/docs/images/prowler-app/attack-paths/navigation.png differ diff --git a/docs/images/prowler-app/findings-triage/findings-triage-manual-pass-details.png b/docs/images/prowler-app/findings-triage/findings-triage-manual-pass-details.png new file mode 100644 index 0000000000..1914cc71e3 Binary files /dev/null and b/docs/images/prowler-app/findings-triage/findings-triage-manual-pass-details.png differ diff --git a/docs/images/prowler-app/findings-triage/findings-triage-manual-pass-note.png b/docs/images/prowler-app/findings-triage/findings-triage-manual-pass-note.png new file mode 100644 index 0000000000..d447478dbe Binary files /dev/null and b/docs/images/prowler-app/findings-triage/findings-triage-manual-pass-note.png differ diff --git a/docs/images/prowler-app/findings-triage/findings-triage-manual-pass-selector.png b/docs/images/prowler-app/findings-triage/findings-triage-manual-pass-selector.png new file mode 100644 index 0000000000..f14b05f877 Binary files /dev/null and b/docs/images/prowler-app/findings-triage/findings-triage-manual-pass-selector.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 new file mode 100644 index 0000000000..bb1bf33bc0 Binary files /dev/null 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 new file mode 100644 index 0000000000..ae3437d84e Binary files /dev/null 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 new file mode 100644 index 0000000000..77e732a846 Binary files /dev/null and b/docs/images/prowler-app/slack/disconnect-confirmation.png differ diff --git a/docs/images/prowler-app/slack/integrations-tab.png b/docs/images/prowler-app/slack/integrations-tab.png new file mode 100644 index 0000000000..d18a5cc087 Binary files /dev/null and b/docs/images/prowler-app/slack/integrations-tab.png differ diff --git a/docs/images/prowler-app/slack/no-workspace-connected.png b/docs/images/prowler-app/slack/no-workspace-connected.png new file mode 100644 index 0000000000..513eff04ad Binary files /dev/null and b/docs/images/prowler-app/slack/no-workspace-connected.png differ diff --git a/docs/introduction.mdx b/docs/introduction.mdx index ea0a9c48b1..3d93b8253a 100644 --- a/docs/introduction.mdx +++ b/docs/introduction.mdx @@ -71,7 +71,7 @@ Prowler supports a wide range of providers organized by category: | [LLM](/user-guide/providers/llm/getting-started-llm) | Official | Models | CLI | | [M365](/user-guide/providers/microsoft365/getting-started-m365) | Official | Tenants | UI, API, CLI | | [MongoDB Atlas](/user-guide/providers/mongodbatlas/getting-started-mongodbatlas) | Official | Organizations | UI, API, CLI | -| [Okta](/user-guide/providers/okta/getting-started-okta) | Official | Organizations | CLI | +| [Okta](/user-guide/providers/okta/getting-started-okta) | Official | Organizations | UI, API, CLI | | [Vercel](/user-guide/providers/vercel/getting-started-vercel) | Official | Teams / Projects | UI, API, CLI | ### Kubernetes diff --git a/docs/style.css b/docs/style.css index 2460a1ae19..9092254324 100644 --- a/docs/style.css +++ b/docs/style.css @@ -92,6 +92,7 @@ li[id="/user-guide/tutorials/prowler-alerts"] a > div > div > span:first-child:: li[id="/user-guide/tutorials/prowler-app-attack-paths-active-queries"] a > div > div > span:first-child::after, li[id="/user-guide/tutorials/prowler-app-findings-triage"] a > div > div > span:first-child::after, li[id="/user-guide/tutorials/prowler-app-scan-configuration"] a > div > div > span:first-child::after, +li[id="/user-guide/tutorials/prowler-app-slack-integration"] a > div > div > span:first-child::after, li[id="/user-guide/tutorials/prowler-cloud-aws-organizations"] a > div > div > span:first-child::after, li[id="/user-guide/tutorials/prowler-cloud-azure-management-groups"] a > div > div > span:first-child::after, li[id="/user-guide/tutorials/prowler-cloud-gcp-organizations"] a > div > div > span:first-child::after, diff --git a/docs/troubleshooting.mdx b/docs/troubleshooting.mdx index 9e0c849796..8b23550c4b 100644 --- a/docs/troubleshooting.mdx +++ b/docs/troubleshooting.mdx @@ -22,7 +22,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 2b5ef617cf..a1c14e6626 100644 --- a/docs/user-guide/cli/tutorials/configuration_file.mdx +++ b/docs/user-guide/cli/tutorials/configuration_file.mdx @@ -51,6 +51,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 | `[]` | @@ -90,6 +91,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 | `[]` | @@ -489,13 +492,21 @@ 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 # AWS CloudTrail Configuration # aws.cloudtrail_threat_detection_privilege_escalation - threat_detection_privilege_escalation_threshold: 0.2 # Percentage of actions found to decide if it is an privilege_escalation attack event, by default is 0.2 (20%) + threat_detection_privilege_escalation_threshold: 0.2 # Percentage of actions found to decide if it is a privilege_escalation attack event, by default is 0.2 (20%) threat_detection_privilege_escalation_minutes: 1440 # Past minutes to search from now for privilege_escalation attacks, by default is 1440 minutes (24 hours) threat_detection_privilege_escalation_actions: [ diff --git a/docs/user-guide/cli/tutorials/custom-checks-metadata.mdx b/docs/user-guide/cli/tutorials/custom-checks-metadata.mdx index 9ca369eb94..f658432782 100644 --- a/docs/user-guide/cli/tutorials/custom-checks-metadata.mdx +++ b/docs/user-guide/cli/tutorials/custom-checks-metadata.mdx @@ -6,7 +6,7 @@ In certain organizations, the severity of specific checks might differ from the The custom metadata option offers a means to override default metadata set by Prowler. -You can utilize `--custom-checks-metadata-file` followed by the path to your custom checks metadata YAML file. +You can use `--custom-checks-metadata-file` followed by the path to your custom checks metadata YAML file. ## Available Fields diff --git a/docs/user-guide/cli/tutorials/dashboard.mdx b/docs/user-guide/cli/tutorials/dashboard.mdx index 46a910b67c..2a24644e68 100644 --- a/docs/user-guide/cli/tutorials/dashboard.mdx +++ b/docs/user-guide/cli/tutorials/dashboard.mdx @@ -99,7 +99,7 @@ def get_table(data): ## S3 Integration -If you are using Prowler Cloud with the S3 integration or that integration from Prowler CLI and you want to use your data from your S3 bucket, you can run the following command in order to load the dashboard with the new files: +If you are using Prowler Cloud with the S3 integration or that integration from Prowler CLI and you want to use your data from your S3 bucket, you can run the following command to load the dashboard with the new files: ```sh aws s3 cp s3:///output/csv ./output --recursive diff --git a/docs/user-guide/cli/tutorials/fixer.mdx b/docs/user-guide/cli/tutorials/fixer.mdx index 4170b340e2..f777d64713 100644 --- a/docs/user-guide/cli/tutorials/fixer.mdx +++ b/docs/user-guide/cli/tutorials/fixer.mdx @@ -105,7 +105,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 0940508ed7..59f34c3660 100644 --- a/docs/user-guide/cli/tutorials/misc.mdx +++ b/docs/user-guide/cli/tutorials/misc.mdx @@ -4,7 +4,7 @@ title: 'Miscellaneous' ## Prowler Version -### Showing the Prowler version: +### Showing the Prowler Version ```console prowler -V/-v/--version @@ -22,7 +22,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 77b710d3df..b11a41f1c9 100644 --- a/docs/user-guide/cli/tutorials/mutelist.mdx +++ b/docs/user-guide/cli/tutorials/mutelist.mdx @@ -268,7 +268,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 a9a8c728df..0d7be461d7 100644 --- a/docs/user-guide/cli/tutorials/pentesting.mdx +++ b/docs/user-guide/cli/tutorials/pentesting.mdx @@ -43,8 +43,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 @@ -58,8 +57,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/organizations.mdx b/docs/user-guide/organizations.mdx new file mode 100644 index 0000000000..39377483de --- /dev/null +++ b/docs/user-guide/organizations.mdx @@ -0,0 +1,58 @@ +--- +title: 'Organizations Across Cloud Providers' +description: 'Understand organization hierarchies and onboarding across AWS, Google Cloud, and Azure' +--- + +Cloud providers use organization-level hierarchies to group accounts, projects, or subscriptions and apply access and governance consistently. Prowler uses these hierarchies to discover cloud targets and help configure multi-account or multi-project scanning. + +This guide explains the shared lifecycle and the differences between AWS Organizations, Google Cloud organizations, and Azure Management Groups. Use the provider-specific guides for commands, permissions, and limitations. + +## Organization Lifecycle + +Organization-level onboarding generally follows these steps: + +1. **Identify the hierarchy:** Locate the organization, management account, management group, folder, organizational unit, or equivalent parent node in the cloud provider. +2. **Grant access:** Assign the provider permissions required to enumerate the hierarchy and read the resources that Prowler scans. +3. **Discover members:** Use Prowler to retrieve accounts, projects, or subscriptions under the selected hierarchy. +4. **Select scan targets:** Choose the cloud targets to connect or scan. Discovery does not necessarily make every discovered target a Prowler provider. +5. **Test access:** Confirm that Prowler can authenticate to each selected target and read its resources. +6. **Scan and maintain:** Run scans, review findings, and repeat discovery when the provider hierarchy changes. + + +Organization membership changes are not automatically synchronized in every Prowler workflow. Follow the provider-specific guide to learn when manual rediscovery is required. + + +## Capability Matrix + +| Capability | AWS Organizations | Google Cloud organization | Azure Management Groups | +| --- | --- | --- | --- | +| Hierarchy members | AWS accounts grouped in organizational units (OUs) | Projects grouped in folders and nested folders | Subscriptions grouped in management groups | +| Organization-level discovery | Supported through AWS Organizations APIs | Supported through the Cloud Asset API | Supported through Azure management-group and subscription APIs | +| Primary scan target | AWS account | Google Cloud project | Azure subscription | +| Common organization-level permission | IAM role in the management or delegated administrator account | Cloud Asset Viewer or Cloud Asset Owner at the organization node | Appropriate Azure role assignment at the management-group or subscription scope | +| Provider-specific onboarding | AWS account discovery and optional StackSet role deployment | Project discovery under an organization ID | Subscription discovery under a management group; role assignments inherit to subscriptions | +| Membership maintenance | Repeat the discovery flow when accounts are added or removed | Re-run organization discovery when projects or folders change | Refresh discovery when subscriptions move between management groups | + +## Provider Guides + +### AWS Organizations + +The [AWS Organizations guide](/user-guide/providers/aws/organizations) covers account details, delegated administration, IAM roles, CloudFormation StackSets, and CLI scanning. For Prowler Cloud onboarding, see [AWS Organizations in Prowler Cloud](/user-guide/tutorials/prowler-cloud-aws-organizations). + +### Google Cloud Organization + +The [Google Cloud organization guide](/user-guide/providers/gcp/organization) covers scanning projects under an organization ID, organization-level permissions, and Cloud Asset API requirements. For Prowler Cloud onboarding, see [Google Cloud organizations in Prowler Cloud](/user-guide/tutorials/prowler-cloud-gcp-organizations). + +### Azure Management Groups + +The [Azure Management Groups guide](/user-guide/providers/azure/management-groups) covers hierarchy setup, role assignment, subscription scope, and Azure-specific limitations. For Prowler Cloud onboarding, see [Azure Management Groups in Prowler Cloud](/user-guide/tutorials/prowler-cloud-azure-management-groups). + +## Scope Boundaries + +The organization concepts in this guide refer only to cloud-provider resource hierarchies: + +- **GitHub organizations** group repositories and GitHub resources. They are a separate provider concept and are not part of AWS, Google Cloud, or Azure organization discovery. +- **MongoDB Atlas organizations** group Atlas projects and teams. They use a separate provider API and authentication model. +- **Prowler Cloud organizations** are internal tenants that isolate providers, scans, findings, users, and permissions. They are not the same as a cloud-provider organization and do not replace one. + +Choose the guide that matches the hierarchy being configured, then use the relevant Prowler Cloud or CLI workflow for the scan targets. diff --git a/docs/user-guide/providers/alibabacloud/getting-started-alibabacloud.mdx b/docs/user-guide/providers/alibabacloud/getting-started-alibabacloud.mdx index ce9778a3b5..e887ead21e 100644 --- a/docs/user-guide/providers/alibabacloud/getting-started-alibabacloud.mdx +++ b/docs/user-guide/providers/alibabacloud/getting-started-alibabacloud.mdx @@ -144,25 +144,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/authentication.mdx b/docs/user-guide/providers/aws/authentication.mdx index e3c20ca99d..35c37548ea 100644 --- a/docs/user-guide/providers/aws/authentication.mdx +++ b/docs/user-guide/providers/aws/authentication.mdx @@ -64,7 +64,7 @@ This method grants permanent access and is the recommended setup for production 7. Click "Submit" to deploy the stack - ![Click on submit](/images/providers/submit-third-page.png) + ![Click Submit](/images/providers/submit-third-page.png) To provision the scan role using Terraform: diff --git a/docs/user-guide/providers/aws/boto3-configuration.mdx b/docs/user-guide/providers/aws/boto3-configuration.mdx index 83d01d5e80..c10db03ef8 100644 --- a/docs/user-guide/providers/aws/boto3-configuration.mdx +++ b/docs/user-guide/providers/aws/boto3-configuration.mdx @@ -1,14 +1,55 @@ --- -title: "Boto3 Retrier Configuration in Prowler" +title: "Boto3 Retrier and Timeout Configuration in Prowler" --- +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. + + + +## Retries Configuration + + + +The number of retries is set with `--aws-retries-max-attempts`, where `0` disables retries. It can also be set through an environment variable, which is the way to tune it in Prowler Cloud and other deployments without a CLI: + +```console +export PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS=0 +``` + +The CLI flag takes precedence over the environment variable. The value must be a non-negative integer; when neither is set, Prowler uses 3 retries. + + +The environment variable is process-wide: it applies to every AWS provider built in the process where it is set, not only to a connection check. A scan started in that same process picks it up too. Boto3's Standard retry mode, which Prowler uses, also retries service-side throttling responses (see the errors listed below), so `0` disables retries for those as well. On a large account a scan can hit throttling under normal load, and with retries disabled that throttling becomes a hard failure instead of a retried call. Set the variable only on the processes that run connection checks; leave scan workers on the default, or raise their retry count instead of lowering it. + + ## 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/multiaccount.mdx b/docs/user-guide/providers/aws/multiaccount.mdx index 824e367875..e89302ee65 100644 --- a/docs/user-guide/providers/aws/multiaccount.mdx +++ b/docs/user-guide/providers/aws/multiaccount.mdx @@ -2,7 +2,7 @@ title: 'Scanning Multiple AWS Accounts with Prowler' --- -Prowler enables security scanning across multiple AWS accounts by utilizing the [Assume Role feature](/user-guide/providers/aws/role-assumption) and [integration with AWS Organizations feature](/user-guide/providers/aws/organizations). +Prowler enables security scanning across multiple AWS accounts by using the [Assume Role feature](/user-guide/providers/aws/role-assumption) and [integration with AWS Organizations feature](/user-guide/providers/aws/organizations). This approach allows execution from a single account with permissions to assume roles in the target accounts. diff --git a/docs/user-guide/providers/aws/organizations.mdx b/docs/user-guide/providers/aws/organizations.mdx index b48df1c442..9729a86a26 100644 --- a/docs/user-guide/providers/aws/organizations.mdx +++ b/docs/user-guide/providers/aws/organizations.mdx @@ -12,6 +12,8 @@ See [AWS Organizations](/user-guide/tutorials/prowler-cloud-aws-organizations) i Prowler can integrate with AWS Organizations to manage the visibility and onboarding of accounts centrally. +For the cross-provider organization lifecycle and capability comparison, see [Organizations Across Cloud Providers](/user-guide/organizations). + When trusted access is enabled with the Organization, Prowler can discover accounts as they are created and even automate deployment of the Prowler Scan IAM Role. > ℹ️ Trusted access can be enabled in the Management Account from the AWS Console under **AWS Organizations → Settings → Trusted access for AWS CloudFormation StackSets**. @@ -165,7 +167,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 8556ccc74e..369f581826 100644 --- a/docs/user-guide/providers/aws/regions-and-partitions.mdx +++ b/docs/user-guide/providers/aws/regions-and-partitions.mdx @@ -21,10 +21,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/azure/management-groups.mdx b/docs/user-guide/providers/azure/management-groups.mdx new file mode 100644 index 0000000000..5131defc7d --- /dev/null +++ b/docs/user-guide/providers/azure/management-groups.mdx @@ -0,0 +1,53 @@ +--- +title: 'Azure Management Groups in Prowler' +--- + +Azure Management Groups provide a hierarchy above subscriptions. They allow Azure role assignments and governance policies to apply to multiple subscriptions through a shared scope. + +For the cross-provider concepts and lifecycle, see [Organizations Across Cloud Providers](/user-guide/organizations). + +## Azure Hierarchy + +Azure resources are organized in the following order: + +1. Tenant +2. Management groups +3. Subscriptions +4. Resource groups +5. Resources + +Prowler scans Azure subscriptions. Management groups help organize those subscriptions and provide a scope where permissions can be assigned, but a management group is not itself a scan target. + +## Create a Management Group + +To create a management group, follow the [official Azure guide](https://learn.microsoft.com/en-us/azure/governance/management-groups/create-management-group-portal). + +![Create management group](/images/create-management-group.gif) + +After creating the management group, add the subscriptions that Prowler should access and scan. + +![Add Subscription to Management Group](/images/add-sub-to-management-group.gif) + +## Assign Roles + +Assign the roles required by Prowler at the management-group scope instead of assigning them separately to every subscription. Role assignments at a management group can inherit to its child subscriptions, subject to Azure role-assignment and inheritance rules. + +Use the [subscription scope permissions](/user-guide/providers/azure/authentication#subscription-scope-permissions) guide to identify the permissions required for scans. The identity used by Prowler must be able to read the management-group hierarchy and access each subscription selected for scanning. + +## Subscription Scope + +Management groups organize subscriptions, but Azure scan results remain scoped to individual subscriptions: + +- Prowler Cloud scans one subscription per scan. +- Prowler CLI can scan multiple subscriptions by using the `--subscription-ids` option. +- A subscription must be accessible to the configured identity before Prowler can scan it. +- Moving a subscription between management groups can change the permissions it inherits and may require a connection test or rediscovery. + +See [Azure Subscription Scope](/user-guide/providers/azure/subscriptions) for subscription selection and CLI options. + +## Limitations + +- Management groups do not replace subscription providers in Prowler. +- Azure role inheritance depends on the management-group hierarchy and the scope of each assignment; verify access on every subscription selected for scanning. +- The Prowler Cloud workflow is designed around Azure management-group discovery and subscription onboarding. The Prowler CLI workflow still requires explicit subscription selection when restricting scans. +- Changes to management-group membership or role assignments may not be reflected until the hierarchy is refreshed and access is tested again. diff --git a/docs/user-guide/providers/azure/subscriptions.mdx b/docs/user-guide/providers/azure/subscriptions.mdx index efd37c13e5..e86101990e 100644 --- a/docs/user-guide/providers/azure/subscriptions.mdx +++ b/docs/user-guide/providers/azure/subscriptions.mdx @@ -25,14 +25,4 @@ Check the [Authentication > Subscription Scope Permissions](/user-guide/provider ## Recommendation for Managing Multiple Subscriptions -Scanning multiple subscriptions requires creating and assigning roles for each, which can be a time-consuming process. To streamline subscription management and auditing, use management groups in Azure. This approach allows Prowler to efficiently organize and audit multiple subscriptions collectively. - -1. **Create a Management Group**: Follow the [official guide](https://learn.microsoft.com/en-us/azure/governance/management-groups/create-management-group-portal) to create a new management group. - - ![Create management group](/images/create-management-group.gif) - -2. **Assign Roles**: Assign necessary roles to the management group, similar to the [role assignment process](#assigning-permissions-for-subscription-scans). - - Role assignment should be done at the management group level instead of per subscription. - -3. **Add Subscriptions**: Add all subscriptions you want to audit to the newly created management group. ![Add Subscription to Management Group](/images/add-sub-to-management-group.gif) +Scanning multiple subscriptions requires creating and assigning roles for each, which can be a time-consuming process. To streamline subscription management and auditing, use [Azure Management Groups](/user-guide/providers/azure/management-groups) to organize subscriptions and assign permissions collectively. diff --git a/docs/user-guide/providers/cloudflare/authentication.mdx b/docs/user-guide/providers/cloudflare/authentication.mdx index 37e2ffafef..78b8a1e5af 100644 --- a/docs/user-guide/providers/cloudflare/authentication.mdx +++ b/docs/user-guide/providers/cloudflare/authentication.mdx @@ -22,9 +22,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. @@ -46,8 +49,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. @@ -66,6 +69,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 9af3dbe589..efdae635cc 100644 --- a/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx +++ b/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx @@ -11,16 +11,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/gcp/organization.mdx b/docs/user-guide/providers/gcp/organization.mdx index 6790be5827..fe488ee6e5 100644 --- a/docs/user-guide/providers/gcp/organization.mdx +++ b/docs/user-guide/providers/gcp/organization.mdx @@ -4,6 +4,8 @@ title: 'Scanning a Specific GCP Organization' By default, Prowler scans all Google Cloud projects accessible to the authenticated user. +For the cross-provider organization lifecycle and capability comparison, see [Organizations Across Cloud Providers](/user-guide/organizations). + To limit the scan to projects within a specific Google Cloud organization, use the `--organization-id` option with the GCP organization’s ID: ```console diff --git a/docs/user-guide/providers/github/authentication.mdx b/docs/user-guide/providers/github/authentication.mdx index 1546bf284a..36daf3f7c6 100644 --- a/docs/user-guide/providers/github/authentication.mdx +++ b/docs/user-guide/providers/github/authentication.mdx @@ -42,7 +42,7 @@ Required for scanning repository security settings: | Permission | Access Level | Purpose | Checks Enabled | |------------|-------------|---------|----------------| -| **Administration** | Read | Branch protection, security settings | All branch protection checks, secret scanning status | +| **Administration** | Read | Branch protection, security and Actions settings | All branch protection checks, secret scanning status, `repository_default_workflow_permissions_read_only` | | **Contents** | Read | File existence checks | `repository_public_has_securitymd_file`, `repository_has_codeowners_file` | | **Metadata** | Read | Basic repository information | All checks (automatically granted) | | **Dependabot alerts** | Read | Dependency vulnerability scanning | `repository_dependency_scanning_enabled` | @@ -63,7 +63,7 @@ Required for scanning organization-level security settings: | Permission | Access Level | Purpose | Checks Enabled | |------------|-------------|---------|----------------| -| **Administration** | Read | Organization security policies | `organization_members_mfa_required`, `organization_repository_creation_limited`, `organization_default_repository_permission_strict` | +| **Administration** | Read | Organization security policies and Actions settings | `organization_members_mfa_required`, `organization_repository_creation_limited`, `organization_default_repository_permission_strict`, `organization_default_workflow_permissions_read_only`, `organization_actions_pull_request_approval_disabled` | | **Members** | Read | Member access reviews | Organization membership auditing | #### Account Permissions (Fine-Grained PAT only) @@ -83,8 +83,8 @@ With the **Read-only permissions** listed above, Prowler can run: | Check Category | Coverage | Notes | |----------------|----------|-------| | Branch protection checks (12 checks) | ✅ Full | Signed commits, status checks, PR reviews, etc. | -| Repository security checks | ✅ Full | Secret scanning, Dependabot, SECURITY.md, CODEOWNERS | -| Organization checks (3 checks) | ✅ Full | MFA, repo creation policies, default permissions | +| Repository security checks | ✅ Full | Secret scanning, Dependabot, SECURITY.md, CODEOWNERS, default workflow permissions | +| Organization checks (5 checks) | ✅ Full | MFA, repo creation policies, default permissions, Actions workflow permissions | | Compliance frameworks | ✅ Full | CIS GitHub Benchmark and others | | Merge settings (`delete_branch_on_merge`) | ⚠️ MANUAL | Requires write permission (see below) | @@ -171,6 +171,7 @@ Use OAuth App Tokens when building applications that need delegated user permiss - `repo`: Full control of repositories - `read:org`: Read organization and team membership +- `admin:org`: Required by `organization_default_workflow_permissions_read_only` and `organization_actions_pull_request_approval_disabled` to read the organization Actions workflow permissions - `read:user`: Read user profile data **Create an OAuth App:** @@ -214,7 +215,7 @@ If a GitHub App is required: | Permission | Access Level | Purpose | Checks Enabled | |------------|-------------|---------|----------------| -| **Administration** | Read | Branch protection, security settings | All branch protection checks, `repository_secret_scanning_enabled` | +| **Administration** | Read | Branch protection, security and Actions settings | All branch protection checks, `repository_secret_scanning_enabled`, `repository_default_workflow_permissions_read_only` | | **Contents** | Read | File existence checks | `repository_public_has_securitymd_file`, `repository_has_codeowners_file` | | **Metadata** | Read | Basic repository information | All checks (automatically granted) | | **Dependabot alerts** | Read | Dependency vulnerability scanning | `repository_dependency_scanning_enabled` | @@ -223,7 +224,7 @@ If a GitHub App is required: | Permission | Access Level | Purpose | Checks Enabled | |------------|-------------|---------|----------------| -| **Administration** | Read | Organization security policies | `organization_members_mfa_required`, `organization_repository_creation_limited`, `organization_default_repository_permission_strict` | +| **Administration** | Read | Organization security policies and Actions settings | `organization_members_mfa_required`, `organization_repository_creation_limited`, `organization_default_repository_permission_strict`, `organization_default_workflow_permissions_read_only`, `organization_actions_pull_request_approval_disabled` | | **Members** | Read | Member access reviews | Organization membership auditing | **Create a GitHub App:** diff --git a/docs/user-guide/providers/image/getting-started-image.mdx b/docs/user-guide/providers/image/getting-started-image.mdx index 6382f10e5c..6b5d30b72e 100644 --- a/docs/user-guide/providers/image/getting-started-image.mdx +++ b/docs/user-guide/providers/image/getting-started-image.mdx @@ -96,6 +96,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 @@ -306,9 +329,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/kubernetes/getting-started-k8s.mdx b/docs/user-guide/providers/kubernetes/getting-started-k8s.mdx index c62ec2b8a1..9ab064ef98 100644 --- a/docs/user-guide/providers/kubernetes/getting-started-k8s.mdx +++ b/docs/user-guide/providers/kubernetes/getting-started-k8s.mdx @@ -24,7 +24,7 @@ title: 'Getting Started with Kubernetes' For Kubernetes, Prowler Cloud uses a `kubeconfig` file to authenticate. Paste the contents of your `kubeconfig` file into the `Kubeconfig content` field. -Kubeconfigs that use `users[].user.exec` authentication are not supported in Prowler Cloud or Prowler Local Server. For security reasons, Prowler Cloud does not run commands declared by uploaded kubeconfigs. Use kubeconfig credentials that do not rely on `exec` authentication, such as the ServiceAccount token flow documented below. +Only inline kubeconfig credentials are supported: `token`, `username`/`password`, or `client-certificate-data` with `client-key-data` for users, and `certificate-authority-data` for clusters. Kubeconfigs that reference external files (`tokenFile`, `client-certificate`, `client-key`, `certificate-authority`), run commands (`exec`, `cmd-path`), use `auth-provider` directives, set a proxy URL, or include unsupported user or cluster fields are rejected. Impersonation fields (`as`, `as-uid`, `as-groups`, `as-user-extra`) are accepted but not applied, so Prowler connects with the identity of the user credentials. Use inline credentials such as the ServiceAccount token flow documented below. By default, the `kubeconfig` file is located at `~/.kube/config`. @@ -96,6 +96,8 @@ If you are adding an **EKS**, **GKE**, **AKS** or external cluster, follow these kubectl config set-context --user=prowler-sa ``` + Prowler validates the entire kubeconfig file and requires it to contain only the ServiceAccount user and its cluster entry with certificate-authority-data; remove any exec entries from EKS, GKE, or AKS or Prowler will reject the file. + Replace `` with the generated token and `` with your KubeConfig Context Name of your EKS, GKE or AKS cluster. 4. Add the modified `kubeconfig` in Prowler Cloud and test the connection. diff --git a/docs/user-guide/providers/mongodbatlas/getting-started-mongodbatlas.mdx b/docs/user-guide/providers/mongodbatlas/getting-started-mongodbatlas.mdx index 2fb415a11b..bf2c76447e 100644 --- a/docs/user-guide/providers/mongodbatlas/getting-started-mongodbatlas.mdx +++ b/docs/user-guide/providers/mongodbatlas/getting-started-mongodbatlas.mdx @@ -36,7 +36,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) @@ -45,13 +45,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. @@ -66,11 +66,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 \ @@ -78,7 +78,7 @@ prowler mongodbatlas \ --atlas-private-key ``` -#### Environment variables +#### Environment Variables ```bash export ATLAS_PUBLIC_KEY= @@ -86,9 +86,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 @@ -96,7 +96,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: @@ -104,7 +104,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/oci/getting-started-oci.mdx b/docs/user-guide/providers/oci/getting-started-oci.mdx index 6a8c8de3e9..950661ff28 100644 --- a/docs/user-guide/providers/oci/getting-started-oci.mdx +++ b/docs/user-guide/providers/oci/getting-started-oci.mdx @@ -12,7 +12,7 @@ The following steps apply to Prowler Cloud and Prowler Local Server. 1. Sign in to the [OCI Console](https://cloud.oracle.com/) and open **Tenancy Details** to copy the Tenancy OCID. 2. Go to **Identity & Security** → **Users**, select the principal that owns the API key, and copy the **User OCID**. 3. Generate or locate the API key fingerprint and private key for that user. Follow the [Config File Authentication steps](/user-guide/providers/oci/authentication#config-file-authentication-manual-api-key-setup) to create or rotate the key pair and copy the fingerprint. -4. Note the **Region** identifier to scan (for example, `us-ashburn-1`). +4. In **Tenancy Details**, note the **Home Region** identifier (for example, `me-abudhabi-1`). Any other region the tenancy is subscribed to also works. ### Step 2: Access Prowler Cloud 1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler Local Server](/user-guide/tutorials/prowler-app). @@ -26,12 +26,18 @@ Prowler Cloud connects to OCI with API key credentials. Provide: - **User OCID** for the API key owner - **Fingerprint** of the API key -- **Region** (for example, `us-ashburn-1`) +- **Home Region**: select it from the list (for example, `me-abudhabi-1`) - **Private Key Content** (paste the full PEM value) - **Passphrase (Optional)** if the private key is encrypted Select **Next**, then **Launch Scan** to validate the connection and start the first OCI scan. The private key content is encoded for secure transmission. + +The home region is used only to validate the credentials and discover the regions the tenancy is subscribed to. It does not limit the scan: Prowler audits every subscribed region. OCI Identity and Access Management (IAM) only answers in subscribed regions, so a tenancy that is not subscribed to the selected region cannot be validated. + + +Providers created without a region keep using `us-ashburn-1` for validation. If such a provider fails with `401 NotAuthenticated`, update its credentials and select the home region. + ![Add OCI API Key Credentials](./images/oci-add-api-key-credentials.png) --- @@ -334,6 +340,11 @@ prowler oci \ #### Region Issues +**Error: "OCI credential validation failed" with `401 NotAuthenticated` on `get_tenancy`** +- The tenancy is not subscribed to the region used for validation (by default `us-ashburn-1`) +- In Prowler Cloud or Prowler Local Server, update the provider credentials and select the tenancy home region +- In Prowler CLI, set `region` in `~/.oci/config` to the home region. All subscribed regions are still scanned; `--region` also works but limits the scan to that region + **Error: "Invalid region"** - Check available regions: `prowler oci --list-regions` - Verify your tenancy is subscribed to the region diff --git a/docs/user-guide/providers/okta/authentication.mdx b/docs/user-guide/providers/okta/authentication.mdx index 8f664160a2..059667ee09 100644 --- a/docs/user-guide/providers/okta/authentication.mdx +++ b/docs/user-guide/providers/okta/authentication.mdx @@ -67,7 +67,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: @@ -92,17 +92,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**. @@ -118,7 +118,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**: @@ -136,13 +136,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**: @@ -155,7 +155,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. @@ -206,20 +206,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/providers/scaleway/authentication.mdx b/docs/user-guide/providers/scaleway/authentication.mdx index cecabf3a97..13045104dd 100644 --- a/docs/user-guide/providers/scaleway/authentication.mdx +++ b/docs/user-guide/providers/scaleway/authentication.mdx @@ -34,4 +34,4 @@ prowler scaleway ## Required Scaleway Permissions -The API key bearer needs read access to the IAM API in order to list users and API keys. The `IAMReadOnly` policy is sufficient. Refer to the [Scaleway IAM policy reference](https://www.scaleway.com/en/docs/identity-and-access-management/iam/reference-content/permission-sets/) for the full list of permissions. +The API key bearer needs read access to the IAM API to list users and API keys. The `IAMReadOnly` policy is sufficient. Refer to the [Scaleway IAM policy reference](https://www.scaleway.com/en/docs/identity-and-access-management/iam/reference-content/permission-sets/) for the full list of permissions. diff --git a/docs/user-guide/providers/stackit/getting-started-stackit.mdx b/docs/user-guide/providers/stackit/getting-started-stackit.mdx index 6c56008a89..446747f2db 100644 --- a/docs/user-guide/providers/stackit/getting-started-stackit.mdx +++ b/docs/user-guide/providers/stackit/getting-started-stackit.mdx @@ -13,7 +13,7 @@ StackIT support in Prowler is community-maintained. For commercial support or to Before running Prowler with the StackIT provider, ensure you have: 1. A StackIT account with at least one project -2. A StackIT service account key file with permissions on the project (`iaas.viewer` is enough for the currently shipped IaaS checks; `project.owner` works for any future service). See the [Authentication guide](/user-guide/providers/stackit/authentication) for the full setup. +2. A StackIT service account key file with project permissions for the services to scan. `iaas.viewer` is sufficient for the currently shipped IaaS checks. For SKE, assign the project-scoped `Reader` role as a broad read-only convenience, or create a custom role with `resource-manager.project.get`, `ske.cluster.list`, `ske.cluster.get`, and `ske.version.list`. The `resource-manager.project.get` permission is optional for scanning; it only lets Prowler verify and display the project name. See the [Authentication guide](/user-guide/providers/stackit/authentication) for the full setup. 3. Access to Prowler CLI (see [Installation](/getting-started/installation/prowler-cli)) ## Prowler CLI @@ -124,7 +124,9 @@ Prowler outputs findings to the console and writes reports to the `output/` dire | Service | StackIT API | Description | Example Checks | |---------|-------------|-------------|----------------| -| **IaaS** | `iaas` | Virtual machines, network interfaces, security groups | `iaas_security_group_ssh_unrestricted`, `iaas_security_group_rdp_unrestricted`, `iaas_security_group_database_unrestricted`, `iaas_security_group_all_traffic_unrestricted` | +| **IaaS** | `iaas` | Virtual machines, network interfaces, security groups | `iaas_security_group_ssh_unrestricted`, `iaas_security_group_rdp_unrestricted`, `iaas_security_group_database_unrestricted`, `iaas_security_group_all_traffic_unrestricted`, `iaas_server_public_ip_attached` | +| **Object Storage** | `objectstorage` | Buckets, object lock, retention policies, access keys | `objectstorage_bucket_object_lock_enabled`, `objectstorage_bucket_retention_policy`, `objectstorage_access_key_expiration` | +| **Kubernetes Engine** | `ske` | Managed Kubernetes clusters | `ske_cluster_no_public_endpoint` | Additional services will be added in future releases. Track progress in the [Prowler release notes](https://github.com/prowler-cloud/prowler/releases). @@ -136,6 +138,8 @@ If the scan fails with a 401 error, the service account key is no longer valid ( ### Permission Errors -If checks fail with a 403 error, the service account is missing the required role on the project. Re-check the role assignment in the StackIT portal (`iaas.viewer` is the minimum for the shipped IaaS checks). +If the connection probe warns that Resource Manager access could not be verified with a 403 response, Prowler continues to service discovery. Grant the optional `resource-manager.project.get` permission to verify and display the project name. + +If a service call fails with a 403 response, the service account is missing a permission required by that service. `iaas.viewer` is sufficient for the shipped IaaS checks. The `ske_cluster_no_public_endpoint` check lists clusters and therefore requires `ske.cluster.list`; STACKIT least-privilege custom-role examples also include `ske.cluster.get` and `ske.version.list`. Service API calls remain authoritative and stop the scan when access is denied. For detailed setup steps, see the [Authentication guide](/user-guide/providers/stackit/authentication). diff --git a/docs/user-guide/tutorials/aws-organizations-bulk-provisioning.mdx b/docs/user-guide/tutorials/aws-organizations-bulk-provisioning.mdx index 188ac3540d..9217c28b0a 100644 --- a/docs/user-guide/tutorials/aws-organizations-bulk-provisioning.mdx +++ b/docs/user-guide/tutorials/aws-organizations-bulk-provisioning.mdx @@ -4,6 +4,8 @@ title: 'AWS Organizations Bulk Provisioning in Prowler' Prowler offers an automated tool to discover and provision all AWS accounts within an AWS Organization. This streamlines onboarding for organizations managing multiple AWS accounts by automatically generating the configuration needed for bulk provisioning. +For the cross-provider organization lifecycle and terminology, see [Organizations Across Cloud Providers](/user-guide/organizations). + The tool, `aws_org_generator.py`‎, complements the [Bulk Provider Provisioning](./bulk-provider-provisioning) tool and is available in the Prowler repository at: [util/prowler-bulk-provisioning](https://github.com/prowler-cloud/prowler/tree/master/util/prowler-bulk-provisioning) diff --git a/docs/user-guide/tutorials/prowler-alerts.mdx b/docs/user-guide/tutorials/prowler-alerts.mdx index fc205b72be..54775e35b1 100644 --- a/docs/user-guide/tutorials/prowler-alerts.mdx +++ b/docs/user-guide/tutorials/prowler-alerts.mdx @@ -1,7 +1,7 @@ --- title: 'Alerts' sidebarTitle: 'Alerts' -description: 'Create email alerts from Prowler Cloud findings to monitor relevant security changes after scans or in daily digests.' +description: 'Create alerts from Prowler Cloud findings, deliver them to email recipients and Slack channels, and monitor relevant security changes after scans or in daily digests.' --- import { VersionBadge } from "/snippets/version-badge.mdx" @@ -9,7 +9,7 @@ import { SubscriptionBanner } from "/snippets/subscription-banner.mdx" -Alerts notify recipients by email when security findings match saved filter conditions. Use Alerts to track high-priority findings, monitor specific providers or services, and keep teams informed about scan results that match defined criteria. +Alerts notify their destinations — email recipients, Slack channels, or both — when security findings match saved filter conditions. Use Alerts to track high-priority findings, monitor specific providers or services, and keep teams informed about scan results that match defined criteria. @@ -19,12 +19,13 @@ Before creating Alerts, ensure that: * At least one scan has completed and produced findings. * The user role includes the `manage_alerts` permission. +* To deliver Alerts to Slack channels, a Slack workspace is connected, at least one channel is authorized on it, and the integration's connection check has confirmed that channel. See [Slack Integration](/user-guide/tutorials/prowler-app-slack-integration). The `manage_alerts` permission is required to create, edit, test, enable, disable, and delete Alerts. See [RBAC Administrative Permissions](/user-guide/tutorials/prowler-app-rbac#rbac-administrative-permissions) for details. ## How Alerts Work -Alerts are created from Findings filters. When an Alert runs, Prowler Cloud evaluates the saved conditions against findings and sends an email digest when matching findings exist. +Alerts are created from Findings filters. When an Alert runs, Prowler Cloud evaluates the saved conditions against findings and notifies the Alert's destinations when matching findings exist: an email digest to each recipient, a message to each Slack channel, or both. Destination kinds are independent — neither requires the other, and neither displaces the other. Alerts evaluate findings with status `FAIL` only. Findings with status `PASS` or `MANUAL`, and muted findings, never trigger an Alert regardless of the saved filters. @@ -53,6 +54,7 @@ To create an Alert: * **Description:** Add optional context for the Alert. * **Frequency:** Select when Prowler Cloud should evaluate the Alert. * **Recipients:** Select the recipients who should receive the email digest. + * **Destination channels:** Select the Slack channels that should receive the Alert. See [Slack Channel Destinations](#slack-channel-destinations). ![Create Alert Modal](/images/prowler-app/alerts/create-alert-modal.png) @@ -86,11 +88,18 @@ Navigate to **Alerts** to review and manage existing Alerts. ![Alerts List](/images/prowler-app/alerts/alerts-list.png) +The **Destinations** column summarizes where each Alert delivers, without the Alert being opened: + +* **Email recipients:** The first address, plus a count of the rest, such as `security@example.com +2 more`. +* **Slack channels:** The first channel, plus a count of the rest, such as `#sec-alerts +1 more`. + +Each summary is omitted when that destination kind is empty, and the column reads **No destinations** when an Alert has neither. + Each Alert provides these actions: | Action | Description | |--------|-------------| -| Edit | Update name, description, recipients, frequency, or filters. | +| Edit | Update name, description, recipients, Slack channels, frequency, or filters. | | Enable/Disable | Start or stop Alert evaluation without deleting the Alert. | | Delete | Permanently remove the Alert. | @@ -125,6 +134,37 @@ By default, the **organization owner** receives a **daily digest** for **critica If a recipient unsubscribes from Alerts, that address stops receiving digests until it is reconfirmed. +An Alert does not require email recipients: an Alert that targets Slack channels only is accepted with those channels as its sole destinations. An Alert with no destinations at all stays valid and keeps evaluating its filters, but it delivers nothing. + +## Slack Channel Destinations + + + +An Alert can post to Slack channels alongside its email recipients, or instead of them. The **Destination channels** field sits directly below **Recipients** in the Alert form, both when creating an Alert and when editing one. When the Alert matches findings, Prowler Cloud posts a message to each of its channels and sends the email digest to each of its recipients, independently of each other. + +The channels offered are the confirmed channels of the connected Slack integration, never the whole Slack workspace. Widening the pool takes two steps on the integration: authorize the channel there, then run its connection check, which confirms the channel by posting a one-time confirmation message to it. Once confirmed, the channel is selectable on every Alert. See [Slack Integration](/user-guide/tutorials/prowler-app-slack-integration) for connecting a workspace, authorizing its channels, and confirming them. + +A channel that was authorized a moment ago but does not appear in the Alert form has not been confirmed yet. Run **Test connection** on the Slack integration, then reopen the Alert form. + +Private channels are identified as **Private** both in the open channel list and on the selected channels once the list is closed, so a private destination is never mistaken for a public one. + +### When Slack Channels Cannot Be Selected + +The field is always present, so channel delivery is never silently missing. It reports why it cannot be used: + +| State | What the Alert form shows | +|-------|---------------------------| +| No Slack workspace connected | The field is visible but cannot be edited, explaining that posting Alerts to Slack channels needs a connected Slack workspace, with a link to the Slack integration. | +| Workspace connected, no confirmed channels | A notice that no channels are available yet and that they are authorized and confirmed on the Slack integration, with the same link. | + +In both states the rest of the Alert is unaffected: it can still be created or saved with its filters, frequency, and email recipients. + + +Slack destinations stay in step with the integration. Removing a channel from the integration's authorized set — or disconnecting the Slack integration altogether — removes that channel from every Alert that targeted it, so an Alert never keeps a destination Prowler can no longer deliver to. The Alert keeps its filters, frequency, and email recipients, and future delivery to that channel stops: nothing is posted to announce the removal, and the notifications already delivered stay in the channel. Restoring delivery means authorizing and confirming the channel again on the integration, then selecting it again on the Alert. + + +Saving an Alert that names a channel which is not a confirmed channel of a connected Slack integration is refused, and the reason is reported on the Alert form. Authorize and confirm the channel on the Slack integration, or remove it from the Alert, and save again. + ## Email Notifications When an Alert matches findings, Prowler Cloud sends a security alert email that summarizes the matching findings. The email includes: @@ -141,6 +181,6 @@ When an Alert matches findings, Prowler Cloud sends a security alert email that * **Start with focused filters:** Create Alerts for specific high-priority scopes, such as critical findings, production providers, or important services. * **Use clear names:** Choose names that explain the intent of the Alert. -* **Review recipients regularly:** Keep recipient lists aligned with current ownership. +* **Review destinations regularly:** Keep recipient lists and channel selections aligned with current ownership. * **Test before saving edits:** Use **Test** after changing filters to confirm that the Alert matches the expected findings. -* **Disable instead of deleting during tuning:** Disable Alerts temporarily when adjusting filters or recipients. +* **Disable instead of deleting during tuning:** Disable Alerts temporarily when adjusting filters or destinations. diff --git a/docs/user-guide/tutorials/prowler-app-attack-paths.mdx b/docs/user-guide/tutorials/prowler-app-attack-paths.mdx index 0242c817f5..d4e60d6cf2 100644 --- a/docs/user-guide/tutorials/prowler-app-attack-paths.mdx +++ b/docs/user-guide/tutorials/prowler-app-attack-paths.mdx @@ -16,26 +16,24 @@ Attack Paths analyzes relationships between cloud resources, permissions, and se By mapping these relationships as a graph, Attack Paths reveals risks that individual security checks cannot detect on their own, such as an IAM role that can escalate its own permissions, or a chain of policies that grants unintended access to sensitive resources. - Attack Paths is currently available for **AWS** providers. Support for - additional providers is planned. + Attack Paths is currently available for **AWS** providers. Support for additional providers is planned. ## Prerequisites The following prerequisites are required for Attack Paths: -- **An AWS provider is configured** with valid credentials in Prowler Cloud. For setup instructions, see [Getting Started with AWS](/user-guide/providers/aws/getting-started-aws). +- **An AWS provider is configured** with valid credentials. For setup instructions, see [Getting Started with AWS](/user-guide/providers/aws/getting-started-aws). - **At least one scan has completed** on the configured AWS provider and produced graph data. Attack Paths scans run automatically alongside regular security scans, no separate configuration is required. ## How Attack Paths Scans Work -Attack Paths scans are generated automatically when a security scan runs on an AWS provider. Each completed scan produces graph data that maps relationships between IAM principals, policies, trust configurations, and other resources. +Attack Paths scans are generated automatically when a security scan runs on an AWS provider. When a scan produces graph data, it maps relationships between IAM principals, policies, trust configurations, and other resources. A scan can complete without producing graph data. -Once the scan finishes and graph data is ready, the scan appears in the Attack Paths scan table with a **Completed** status and a check in the **Graph** column. Scans that are still queued or running remain visible, but they cannot be selected until graph data is ready. +When graph data is ready, the scan appears in the Attack Paths scan table with a check in the **Graph** column and can be selected regardless of its current status. Scans without graph data remain visible but cannot be selected. If a new scan cycle starts after graph data is available, the previous cycle remains available while the new scan runs. - Since Prowler scans all configured providers every **24 hours** by default, - Attack Paths data stays up to date automatically. + Prowler Cloud and Prowler Private Cloud scan configured providers every **24 hours** by default, so Attack Paths data stays up to date automatically. ## Accessing Attack Paths @@ -66,7 +64,7 @@ The scans table displays all Attack Paths scans with the following columns: - **Graph:** Whether Attack Paths graph data is available for the scan. - **Duration:** Total scan time. -To select a scan for analysis, click the radio button on any row with a **Completed** status and available graph data. +To select a scan for analysis, click any row with a check in the **Graph** column. A row can remain selectable while a new scan cycle runs because Attack Paths keeps the graph from the previous completed cycle available. - Only scans with graph data can be selected. Disabled rows include a tooltip - that explains why the graph is not available yet. + Only scans with graph data can be selected. Disabled rows include a tooltip that explains why the graph is not available yet. ## Choosing a Query @@ -97,9 +94,7 @@ To choose a query, click the dropdown and select from the available options. Eac Once selected, a description panel appears below the dropdown with more context about the query. - In Prowler Cloud and Prowler Private Cloud, the query selector hides queries - confirmed empty for the selected scan, so only queries that return data remain - visible. See [Active Queries](/user-guide/tutorials/prowler-app-attack-paths-active-queries). + In Prowler Cloud and Prowler Private Cloud, the query selector hides built-in queries confirmed empty for the selected scan. Built-in queries without a confirmed empty result and the **Custom openCypher query** remain visible. See [Active Queries](/user-guide/tutorials/prowler-app-attack-paths-active-queries). ## Configuring Query Parameters @@ -120,7 +115,7 @@ For example, **Internet-Exposed EC2 with Sensitive S3 Access** uses **Tag key** ## Writing Custom openCypher Queries -In addition to the built-in queries, Attack Paths supports custom read-only [openCypher](https://opencypher.org/) queries. Custom queries provide direct access to the underlying graph so security teams can answer ad-hoc questions, prototype detections, or extend coverage beyond the built-in catalogue. +In addition to the built-in queries, Attack Paths supports custom read-only [openCypher](https://opencypher.org/) queries. Custom queries provide direct access to the underlying graph so security teams can answer ad-hoc questions, prototype detections, or extend coverage beyond the built-in catalog. To write a custom query, select **Custom openCypher query** from the query dropdown. A code editor with syntax highlighting and line numbers appears, ready to receive the query. @@ -192,11 +187,7 @@ Custom queries traverse the same Cartography graph the built-in queries use. Nod For the complete reference, including the graph model, list-typed and JSON-encoded properties, performance guidance, and openCypher compatibility rules, see [Attack Paths Queries](/developer-guide/attack-paths-queries) in the Developer Guide. - AI assistants connected through Prowler MCP Server can fetch the exact - Cartography schema for the active scan via the - `prowler_get_attack_paths_cartography_schema` tool. This guarantees that - generated queries match the schema version pinned by the running Prowler - release. + AI assistants connected through Prowler MCP Server can fetch the exact Cartography schema for the active scan via the `prowler_get_attack_paths_cartography_schema` tool. This guarantees that generated queries match the schema version pinned by the running Prowler release. ## Executing a Query @@ -221,12 +212,22 @@ If the query returns no results, an informational message appears. Common reason After a successful execution, the graph visualization renders below the query builder. The graph maps relationships between cloud resources, IAM entities, public exposure, and security findings. +### Grouped Graphs and Query Outcomes + + + +Prowler Cloud and Prowler Private Cloud group resources of the same class and graph level into expandable nodes. Built-in query graphs also end with a query outcome, which states the result that the path can lead to. + +Prowler Local Server keeps the flat graph view, including the provider root. Custom openCypher queries do not have a catalog outcome, so their graphs do not include an outcome node. + ### Node Types -- **Provider root nodes:** Represent the AWS account or provider root for the selected scan. -- **Resource nodes:** Represent cloud resources such as IAM roles, policies, EC2 instances, security groups, and S3 buckets. +- **Grouped resource nodes:** Represent multiple resources of the same class in Prowler Cloud and Prowler Private Cloud. A number in the upper-right corner shows how many resources the node contains. A red outline indicates that one or more resources in the group have findings. +- **Resource nodes:** Represent individual cloud resources such as IAM roles, policies, EC2 instances, security groups, and S3 buckets. A class with one resource remains an individual node. - **Internet nodes:** Represent exposure from the public internet. - **Finding nodes:** Represent Prowler findings linked to resources. Finding colors indicate risk level, such as critical, high, medium, or low. +- **Outcome nodes:** Mark the terminal result of a built-in query in Prowler Cloud and Prowler Private Cloud. The orange node displays outcomes such as **Code execution**, **Privilege escalation**, **Public exposure**, or **Resource inventory**. A dashed ring and the label **Latent outcome** indicate an inventory or another partial outcome. +- **Provider root nodes:** Represent the AWS account or provider root in the Prowler Local Server flat graph. ### Edge Types @@ -234,7 +235,7 @@ After a successful execution, the graph visualization renders below the query bu - **Finding edges:** Dashed relationships between resources and their associated findings. - **Highlighted paths:** Green edges that show the active path when you hover a node or focus a finding. -The standard graph view includes a minimap and a legend below the canvas. The legend shows the provider roots, visible node types, finding risk levels, node states, and edge types present in the current view. +The standard graph view includes a minimap and a legend below the canvas. The legend shows the visible node types, finding risk levels, node states, and edge types present in the current view. Prowler Local Server also displays the provider root in the legend. Attack Paths graph showing an expanded AWS Role group and remaining numbered resource groups + ### Showing Related Findings Resource nodes with related findings are clickable. Click one of these resources to show its finding nodes. Click the resource again to hide them. -The graph automatically fits the selected resource and its related findings when the findings are shown. +The selected resource is highlighted in green while its findings are visible. The graph automatically fits the selected resource and its related findings when the findings are shown. ### Focusing a Finding Path @@ -279,12 +295,12 @@ The toolbar in the top-right corner of the graph provides: - **Zoom in / Zoom out:** Adjust the zoom level - **Fit graph to view:** Reset the view to fit the visible graph -- **Export graph:** Download the current graph as a PNG file +- **Collapse all groups:** Close every expanded resource group. This control appears only when a group is expanded +- **Export graph:** Download the current graph, including its grouped or expanded state and outcome, as a PNG file - **Fullscreen:** Open the graph in a full-size modal - Use **Ctrl + Scroll** (or **Cmd + Scroll** on macOS) to zoom directly within - the graph area. + Use **Ctrl + Scroll** (or **Cmd + Scroll** on macOS) to zoom directly within the graph area. ## Viewing Finding Details diff --git a/docs/user-guide/tutorials/prowler-app-findings-triage.mdx b/docs/user-guide/tutorials/prowler-app-findings-triage.mdx index bbceaef992..ce6632dd66 100644 --- a/docs/user-guide/tutorials/prowler-app-findings-triage.mdx +++ b/docs/user-guide/tutorials/prowler-app-findings-triage.mdx @@ -42,12 +42,12 @@ The status selector includes manual statuses. Prowler also sets automatic status | **Remediating** | Manual | Work is in progress to fix the finding. | | **Risk Accepted** | Manual | The team accepts the risk and wants to mute the finding. | | **False Positive** | Manual | The finding does not apply and should be muted. | -| **Resolved** | Automatic | A finding changed from `FAIL` to `PASS` in a later scan. A passed finding with no saved triage state also appears as **Resolved**. | +| **Resolved** | Automatic / Manual | A finding changed from `FAIL` to `PASS` in a later scan. A passed finding with no saved triage state also appears as **Resolved**. On `MANUAL` findings, select it to verify the finding as passing (see [Verify a MANUAL Finding as Pass](#verify-a-manual-finding-as-pass)). | | **Reopened** | Automatic | A finding changed from `PASS` to `FAIL` in a later scan. | ![Findings Triage Status Selector](/images/prowler-app/findings-triage/findings-triage-status-dropdown.png) -Resolved and Reopened are not manual selector options. +**Reopened** is never a manual selector option. **Resolved** appears in the selector only on `MANUAL` findings, where it starts the [Manual Pass verification](#verify-a-manual-finding-as-pass). These automatic states keep triage tied to the finding UID across scans, even when each scan creates a new finding snapshot. @@ -93,6 +93,39 @@ Triage notes are visible only to the team in the current organization. Each note To remove an existing note, clear the note text and save the change. +## Verify a MANUAL Finding as Pass + + + +Checks that Prowler cannot judge automatically report `MANUAL` findings. When a team verifies such a control outside Prowler, the triage selector on that finding offers **Resolved**: choosing it records a Manual Pass attestation, and the finding reports an effective `PASS` while keeping the raw `MANUAL` scan result. + +![Triage selector offering Resolved on a MANUAL finding](/images/prowler-app/findings-triage/findings-triage-manual-pass-selector.png) + + + + Go to **Findings** and filter by status **Manual**. + + + Expand a Finding Group and click the current status in the **Triage** column of an individual finding. + + + Select **Resolved**. Prowler opens the triage note modal with a required **Manual pass evidence** field. + + + Describe how the control was verified, then click **Save**. The evidence supports up to 500 characters. + + + +![Manual Pass attestation with required evidence](/images/prowler-app/findings-triage/findings-triage-manual-pass-note.png) + +After saving, the finding reports `PASS` in finding tables, finding groups, compliance reports, and scans. While the attestation is active, the triage status is managed automatically and cannot be changed. **View Manual Pass details** shows who verified the finding, the evidence, the attestation time, and its expiration. + +![Manual Pass details showing evidence, author, and validity](/images/prowler-app/findings-triage/findings-triage-manual-pass-details.png) + +### Attestation Expiration + +A Manual Pass attestation is valid for 90 days. It also ends early when a later scan reports a real failure for the finding. In both cases the finding returns to its raw `MANUAL` status for a new review. + ## Mutelist Behavior Findings Triage uses Mutelist when a status means the finding should be muted: @@ -108,18 +141,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 -This is expected. Prowler sets **Resolved** and **Reopened** automatically from scan result changes. +**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 c874a37164..f48e0f806b 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 3621fd514c..bc9fdeb504 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 4668a835fc..65b5a54fbc 100644 --- a/docs/user-guide/tutorials/prowler-app-rbac.mdx +++ b/docs/user-guide/tutorials/prowler-app-rbac.mdx @@ -69,7 +69,7 @@ To remove **another** user from your organization, use the [_Expel from organiza #### Inviting Users -Please be aware that at this time, an email address can only be associated with a single Prowler account. +Note that an email address can only be associated with a single Prowler account. Follow these steps to invite a user to your account: @@ -148,6 +148,12 @@ New roles have no provider visibility by default. Assign at least one Provider G Integrations follow the visibility of the providers attached to them: a role can see an integration when it can access at least one of its providers, and only the providers visible to that role are listed on the integration. Editing or deleting an integration attached to providers outside the visibility of the role is not allowed. Integrations that are not attached to any provider, such as Jira, are tenant-wide and remain available to every role with the **Manage Integrations** permission. +#### Task Visibility and Revocation + + + +Background tasks, such as provider deletions, connection checks and scans, follow the visibility of the provider they belong to: a role can see a task when it can access its provider. Tasks that carry no provider reference are treated as tenant-wide and are visible to every role. Revoking a pending task requires the same permission as the operation that queued it, for example **Manage Scans** for a scan. Provider deletions cannot be revoked. + #### Creating a Provider Group Follow these steps to create a provider group in your account: @@ -257,11 +263,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 new file mode 100644 index 0000000000..f9ae3f99b1 --- /dev/null +++ b/docs/user-guide/tutorials/prowler-app-slack-integration.mdx @@ -0,0 +1,197 @@ +--- +title: "Slack Integration" +sidebarTitle: 'Slack' +description: 'Connect a Slack workspace to Prowler Cloud or Prowler Private Cloud, authorize the channels Prowler posts to, and verify the connection.' +--- +import { VersionBadge } from "/snippets/version-badge.mdx" +import { SubscriptionBanner } from "/snippets/subscription-banner.mdx" + + + + + +Prowler Cloud and Prowler Private Cloud connect to a Slack workspace so security updates arrive where teams already work. Connecting takes one approval in Slack — there is no bot token to create, copy, or store by hand — and Prowler records the set of channels it is authorized to post to. + +Integrating Prowler Cloud or Prowler Private Cloud with Slack provides: + +* **Approval-based setup:** Approve Prowler once in Slack instead of building a Slack app and pasting a token. +* **Confirmed destinations:** The connection check verifies every authorized channel and confirms each new one in the channel itself, so a channel Prowler cannot reach is reported before anything depends on it. +* **Controlled reach:** Prowler posts only to the channels authorized on the integration, and private channels stay invisible until the Prowler app is invited to them. + + +This guide covers the Slack integration in Prowler Cloud and Prowler Private Cloud. It is unrelated to the Prowler CLI `--slack` flag, which posts a scan summary from the command line using a self-created Slack app and the `SLACK_API_TOKEN` and `SLACK_CHANNEL_NAME` environment variables — see [CLI Integrations](/user-guide/cli/tutorials/integrations) for that feature. + + +## How the Slack Integration Works + +When connected and configured: + +1. A Slack workspace is approved once through Slack's app install flow, and Prowler stores the resulting credential encrypted. +2. Prowler reads the channels it can post to: the workspace's public channels, plus the private channels the Prowler app has been invited to. +3. Several of those channels are selected and saved as the integration's authorized channels. +4. The connection check verifies the credential and every authorized channel, and posts a one-time confirmation message to each channel it has not confirmed yet. +5. Features that deliver to Slack, such as [Alerts](/user-guide/tutorials/prowler-alerts), choose their destinations from the confirmed channels. +6. Disconnecting removes the integration from Prowler and attempts to revoke Prowler's access at Slack. + +## Prerequisites + +The Slack integration is available only in **Prowler Cloud** and **Prowler Private Cloud**. Prowler Local Server does not serve the Slack endpoints at all, so the Slack card does not appear on the Integrations page and the management page redirects away. + +Configuring and using the Slack integration requires the **Manage Integrations** permission. The integration is tenant-wide, so it does not require **Unlimited Visibility** or any specific Provider Group. + +One Slack workspace connects per tenant. Approving Prowler again in the same workspace refreshes the stored credential and keeps the authorized channels, but it resets their confirmations and the connection state — the connection check has to be run again. Approving Prowler in a *different* workspace is refused until the current workspace is disconnected: a workspace is never swapped out silently. + +## Permissions Prowler Requests in Slack + +Slack shows a consent screen listing everything the Prowler app asks for. Prowler requests exactly four bot scopes: + +| Scope | Why Prowler Requests It | +|-------|-------------------------| +| `chat:write` | Post the confirmation message, and any later notification, to the authorized channels. | +| `chat:write.public` | Post to a public channel without first inviting the Prowler app to it. | +| `channels:read` | List public channels for the channel selection and resolve the chosen ones. | +| `groups:read` | List the private channels the Prowler app has been invited to, so they appear in the channel selection. | + +Two of these read more broadly than they behave, and both are worth understanding before approving the app. + +### What `chat:write.public` Does Not Grant + +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 Cloud` to it in Slack: + +```text +/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. + +## Connecting a Slack Workspace + +To connect a Slack workspace to Prowler Cloud or Prowler Private Cloud: + +1. In either product, navigate to **Integrations**. +2. Locate the **Slack** card and click **Manage**. + + ![Slack card on the Integrations page in Prowler Cloud or Prowler Private Cloud](/images/prowler-app/slack/integrations-tab.png) + +3. Click **Add to Slack**. + + ![Slack management page before a workspace is connected, showing the Add to Slack action](/images/prowler-app/slack/no-workspace-connected.png) + +4. In Slack, select the workspace to connect and approve the permissions listed on the consent screen. +5. Slack returns to Prowler Cloud or Prowler Private Cloud, which completes the install and shows the connected workspace. + + ![Connected Slack workspace with no channels authorized yet](/images/prowler-app/slack/connected-workspace.png) + +The connected card reports the workspace name and a **Not checked yet** status: the connection is checked against the authorized channels, and none are authorized at this point. Authorizing them is the next step. Once at least one channel is authorized, **Test connection** verifies the credential and every authorized channel, and confirms the ones not confirmed yet. + + +Declining the consent screen creates nothing. Prowler reports that the workspace was not connected and offers to start again. + + +## Authorizing Destination Channels + +Prowler posts to the channels authorized on the integration. Several channels can be authorized at once, and once the connection check has confirmed them they are the pool every consumer of the integration draws from: an [Alert](/user-guide/tutorials/prowler-alerts) picks its Slack destinations from the confirmed channels, never from the whole workspace. + +1. Open the **Destination channels** selection. It lists the workspace's public channels, plus the private channels the Prowler app has been invited to, each marked **Private**. + + ![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 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 Cloud` to a private one, then click **Refresh channels**. + +A workspace can hold more channels than Prowler reads in one go. When that happens, the selection says so and lists what was read: every listed channel is usable, and a channel missing from a partial list is not necessarily one `@Prowler Cloud` has to be invited to. Only listed channels can be selected: **Refresh channels** repeats the same bounded read rather than reading further, and the selection's search filters what was already read, so neither surfaces a channel the read left out. + +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. + + +Removing a channel from the authorized set also removes it from every Alert that targeted it. Those Alerts keep their filters, frequency, and email recipients, and future delivery to that channel simply stops: nothing is posted to announce the removal, and the notifications already delivered stay in the channel. Disconnecting the integration has the same effect on every channel it had authorized. Restoring delivery means authorizing and confirming the channel again here, then selecting it again on each Alert. + + +### Confirming the Authorized Channels + +A channel becomes usable as a destination once the connection check has confirmed it. Click **Test connection**: it verifies the stored credential and every authorized channel, and posts a one-time message to each channel it has not confirmed yet. + +```text +✅ Prowler connection verified. Notifications will be delivered to this channel. +``` + +Later checks never post that message again to a channel that is already confirmed, so it arrives once per channel. The integration reports as connected only when every check and every required confirmation succeeded; a failure names the channel that failed. The check needs at least one authorized channel — with none authorized, it cannot be run yet. + +Confirmation is what makes a channel selectable elsewhere in Prowler Cloud. A channel authorized a moment ago is missing from an Alert's channel list until a connection check confirms it. + +## Disconnecting a Slack Workspace + +Disconnecting removes the integration from Prowler **and** attempts to revoke Prowler's access at Slack. + +1. On the Slack management page, click **Disconnect**. +2. Review the confirmation, then click **Disconnect workspace**. + + ![Disconnect Slack workspace confirmation dialog](/images/prowler-app/slack/disconnect-confirmation.png) + +The page returns to its unconnected state, ready for a new install. + +### What Revocation Means + +Revocation is attempted at Slack, and it is best-effort: + +* **Revocation succeeded:** The stored credential no longer grants Prowler anything, and the integration is gone from Prowler. +* **Revocation failed:** The integration and the stored credential are gone from Prowler either way, so there is nothing to retry. Slack did not confirm the revocation, which means the Prowler app may still be installed in the workspace. Remove it from that workspace's Slack app settings. +* **Revocation unreported:** Slack's answer carried no outcome either way. The integration is gone from Prowler, and the disconnect is reported without any claim about revocation. When certainty matters, check the workspace's Slack app settings and remove the Prowler app if it is still installed. + +Prowler reports the outcome it received: a failed revocation always names the manual cleanup step, and an unreported one is never presented as revoked. + + +Disconnecting cannot be undone, and it removes the Slack channels from every Alert that targeted them. Reconnecting means approving Prowler in Slack again, authorizing the destination channels again, confirming them with a connection check, and selecting them again on each Alert that posts to Slack. + + +## Integration Status + +The Slack management page reports the state of the connection and offers these actions: + +| 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 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 | + +## Troubleshooting + +### Slack Is Not Available in This Environment Yet + +The Prowler Slack app is not configured for the deployment being used, so no workspace can be connected. This resolves without any action on the tenant's side — the page starts working as soon as the app is configured. + +### A Private Channel Does Not Appear in the Channel List + +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 + +* Confirm every authorized channel still exists and has not been archived. A failure names the channel Slack refused, and the integration reports as connected only when every authorized channel passes. +* For a private authorized channel, confirm the Prowler app is still a member of it. +* Confirm the Prowler app is still installed in the workspace. + +### A Channel Is Missing From an Alert's Channel List + +The channel is authorized here but not confirmed yet. Click **Test connection**: it confirms every authorized channel it has not confirmed, and confirmed channels become selectable on Alerts. + +### Prowler's Access Has Been Revoked + +When Slack stops accepting the stored credential — because a workspace administrator revoked it, or the app was removed from the workspace — Prowler reports the workspace as disconnected and offers **Reconnect to Slack**. Approving Prowler in Slack again restores access. + +### The Connection Check Fails on a Channel + +* Check the outcome reported on the page: it names the channel Slack refused and the reason Slack gave — an archived or deleted channel surfaces here rather than failing silently. +* Confirm that channel is still one of the intended destinations, and that it has not been archived or deleted in Slack. +* For a private channel, confirm the Prowler app is still a member of it. +* One unreachable channel is enough to report the integration as not connected, so removing a retired channel from the authorized set clears the failure — bearing in mind that removing it also removes it from every Alert that targeted it. diff --git a/docs/user-guide/tutorials/prowler-app.mdx b/docs/user-guide/tutorials/prowler-app.mdx index 84095c7da2..f42a4db8f8 100644 --- a/docs/user-guide/tutorials/prowler-app.mdx +++ b/docs/user-guide/tutorials/prowler-app.mdx @@ -89,6 +89,14 @@ After adding your cloud account credentials, click the `Check connection` button Test Connection + +For a single AWS account, Prowler tests the connection as part of the `Connect account` step, so the wizard moves straight to launching the scan. + + + +To delegate the AWS connection, select `I don't have access, invite a teammate` on the same step when you cannot create the IAM role or do not have the account credentials. Prowler App sends the invitation to the tenant and shows the link to share. Prowler Cloud also emails it. This option is available to users who can manage the account. + + ## Step 6: Scan Started After the connection check succeeds, save the provider and start your first scan with the `Launch Scan` button. The `Scans` section shows the scan in progress: @@ -166,6 +174,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-cloud-aws-organizations.mdx b/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx index 720ca0cda5..9454929360 100644 --- a/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx +++ b/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx @@ -10,6 +10,8 @@ import { SubscriptionBanner } from "/snippets/subscription-banner.mdx" Prowler Cloud onboards every AWS account in your Organization through a single guided wizard. Instead of connecting accounts one by one, you can discover every account in your AWS Organization, select the ones you want to monitor, test connectivity, and launch scans — all from the Prowler Cloud UI. +For the cross-provider organization lifecycle and terminology, see [Organizations Across Cloud Providers](/user-guide/organizations). + For CLI-based multi-account scanning, see [AWS Organizations in Prowler CLI](/user-guide/providers/aws/organizations). @@ -66,7 +68,7 @@ The Prowler wizard walks you through the entire flow: deploying both roles from AWS Organizations Console showing the Organization ID in the left sidebar -- **Name** (optional): A display name for the organization. If left blank, Prowler uses the name stored in AWS. +- **Name** (optional): A display name for the organization. If left blank, Prowler uses the AWS organization ID. Organization Details form with Organization ID and Name fields @@ -266,7 +268,7 @@ Click **Save**, **Save and launch scan**, or **Launch scan**, depending on the s After launching: - Scans appear in the **Scans** page as they start and complete. - Results populate the **Overview** and **Findings** pages. -- Prowler runs an **automatic sync every 6 hours** to detect accounts added to or removed from your Organization. New accounts under the targeted OU or root are onboarded automatically. +- To detect accounts added to or removed from the AWS Organization, repeat the discovery flow described in [Add or Remove Organization Accounts](#add-or-remove-organization-accounts). ## Manage Your Organization After Onboarding @@ -280,7 +282,7 @@ Open the row actions menu on the organization row on the **Providers** page. | Action | What it does | |--------|--------------| -| **Edit Organization Name** | Renames the organization in Prowler. Leave it blank to fall back to the name stored in AWS. | +| **Edit Organization Name** | Renames the organization in Prowler. Leave it blank to fall back to the AWS organization ID. | | **Update Credentials** | Reopens the Authentication Details step to store a new Role ARN. | | **Edit Scan Schedule** | Applies one schedule to every connected account in the organization. | | **Test Connections (N)** | Re-tests every account in the organization. | @@ -288,6 +290,24 @@ Open the row actions menu on the organization row on the **Providers** page. Organizational unit rows carry the same **Test Connections** and **Delete Organizational Unit** actions, scoped to the accounts beneath them. +### Add or Remove Organization Accounts + +To refresh the account membership of an existing AWS Organization, repeat the same discovery flow used during onboarding: + +1. Navigate to **Providers**, click **Add Provider**, and select **Amazon Web Services**. +2. Choose **Add Multiple Accounts With AWS Organizations**. +3. Enter the existing **Organization ID**, proceed to **Authentication Details**, and use the existing deployment account **Role ARN**. +4. Confirm that the stack is deployed and click **Authenticate**. Prowler reuses the existing organization and starts a new discovery instead of creating a duplicate. + +The refreshed tree shows the accounts currently returned by AWS Organizations: + +- **New accounts** appear in the tree. Select them, test their connections, and save the configuration to connect them as providers. Existing providers and their historical data are preserved. +- **Accounts that left the Organization** no longer appear in the tree. Discovery does not automatically delete their existing providers. To remove one, return to the **Providers** page, open the account provider actions, and select **Delete Provider**. + + +Deleting a provider permanently removes its scans, findings, resources, and other stored data. Confirm that the account has left the AWS Organization and that its historical data is no longer required before deleting it. + + ### Update Organization Credentials Choosing **Update Credentials** re-enters the Authentication Details step. Because the organization already holds a credential, Prowler warns before overwriting it and names how many providers re-authenticate with the new one: @@ -458,16 +478,17 @@ Deploy the ProwlerScan role to every member account with a [CloudFormation Stack 1. In your management account, navigate to **CloudFormation > StackSets > Create StackSet** ([open directly](https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacksets/create)). -2. Choose **Service-managed permissions** so AWS Organizations deploys the role automatically across current and future member accounts. -3. Select **Amazon S3 URL** as the template source and paste: +2. Choose **Service-managed permissions**. +3. Enable **Automatic deployment** so CloudFormation deploys the role to accounts added to the targeted root or OUs. Configure the account removal behavior based on whether the stack and its resources should be retained when an account leaves the target. +4. Select **Amazon S3 URL** as the template source and paste: ``` https://prowler-cloud-public.s3.eu-west-1.amazonaws.com/permissions/templates/aws/cloudformation/prowler-scan-role.yml ``` -4. Set the **ExternalId** parameter to the External ID shown in the Prowler wizard. -5. Choose your deployment targets (entire organization or specific OUs) and regions, then click **Create StackSet**. -6. Open the **Stack instances** tab and confirm every instance shows **Status: CURRENT** and **Stack status: CREATE_COMPLETE**. Deployment typically takes **2–5 minutes**; large organizations (500+ accounts) may take longer. +5. Set the **ExternalId** parameter to the External ID shown in the Prowler wizard. +6. Choose your deployment targets (entire organization or specific OUs) and regions, then click **Create StackSet**. +7. Open the **Stack instances** tab and confirm every instance shows **Status: CURRENT** and **Stack status: CREATE_COMPLETE**. Deployment typically takes **2–5 minutes**; large organizations (500+ accounts) may take longer. -The StackSet role uses read-only access only (`SecurityAudit`, `ViewOnlyAccess`, plus a small set of additional read-only permissions). Prowler makes no changes to your accounts. See the [CloudFormation template](https://prowler-cloud-public.s3.eu-west-1.amazonaws.com/permissions/templates/aws/cloudformation/prowler-scan-role.yml) for the full list. When you add new accounts under the targeted OU or root, the StackSet deploys the role automatically, and Prowler's 6-hour sync onboards them end-to-end. +The StackSet role uses read-only access only (`SecurityAudit`, `ViewOnlyAccess`, plus a small set of additional read-only permissions). Prowler makes no changes to your accounts. See the [CloudFormation template](https://prowler-cloud-public.s3.eu-west-1.amazonaws.com/permissions/templates/aws/cloudformation/prowler-scan-role.yml) for the full list. When **Automatic deployment** is enabled, the StackSet deploys the role to new accounts under the targeted OU or root. Repeat the [organization discovery flow](#add-or-remove-organization-accounts) to connect those accounts in Prowler Cloud. ## Key Concepts diff --git a/docs/user-guide/tutorials/prowler-cloud-azure-management-groups.mdx b/docs/user-guide/tutorials/prowler-cloud-azure-management-groups.mdx index 98cfe095ce..f3dc55d709 100644 --- a/docs/user-guide/tutorials/prowler-cloud-azure-management-groups.mdx +++ b/docs/user-guide/tutorials/prowler-cloud-azure-management-groups.mdx @@ -1,11 +1,467 @@ --- title: 'Azure Management Groups' -description: 'Onboard all Azure subscriptions in your management groups through a single guided wizard' -tag: "Coming Soon" +description: 'Discover the Azure subscriptions in your management groups and onboard the ones you select through a single guided wizard' --- -Onboarding Azure management groups through a single guided wizard is coming soon to Prowler Cloud. +import { VersionBadge } from "/snippets/version-badge.mdx" +import { SubscriptionBanner } from "/snippets/subscription-banner.mdx" -Today, Azure subscriptions are onboarded individually. See [Getting Started with Azure](/user-guide/providers/azure/getting-started-azure) and [Bulk Provider Provisioning](/user-guide/tutorials/bulk-provider-provisioning) to automate onboarding multiple subscriptions. + -Keep an eye on the [changelog](https://github.com/prowler-cloud/prowler/releases) for updates. +Prowler Cloud discovers every Azure subscription in your Microsoft Entra tenant and onboards the eligible ones you select, through a single guided wizard. Instead of connecting subscriptions one by one, you can discover every Management Group and subscription under the tenant root Management Group, select the ones you want to monitor, test connectivity, and launch scans — all from the Prowler Cloud UI. + + +For Command-Line Interface (CLI) scanning of several subscriptions, see [Configuring Specific Subscription Scans in Prowler](/user-guide/providers/azure/subscriptions). + + +To follow this guide you need an active [Prowler Cloud](https://cloud.prowler.com) account and an Azure service principal with read access granted **at the tenant root Management Group**. + +## Overview + +### Individual Subscriptions vs Azure Management Groups + +| Approach | Best for | How it works | +|----------|----------|--------------| +| **Individual subscriptions** | A few Azure subscriptions | Connect each subscription one by one with its own service principal credential. | +| **Azure Management Groups** | 10+ subscriptions, or any tenant organized into Management Groups | Connect once with a tenant-wide credential, discover every Management Group and subscription automatically, and scan them in bulk. | + +### How It Works + +Onboarding runs in four stages: + +1. **Grant read access** to one service principal at the tenant root Management Group. +2. **Discover** — Prowler walks the Management Group hierarchy and returns every Management Group and subscription under the tenant root. +3. **Select and connect** — choose the subscriptions to monitor. Prowler creates one provider per subscription and tests every connection. +4. **Launch scans** — apply a scan schedule across the connected subscriptions. + + +**Nothing is deployed into your tenant.** Unlike AWS Organizations onboarding, Azure onboarding creates no resources in Azure. Prowler reuses the service principal credential you provide as the credential of every subscription it onboards, so one credential covers discovery and scanning once all the required permissions are granted. + + + +**Onboarding always covers the whole tenant.** Prowler scopes the organization to the tenant root Management Group, which it derives from the Microsoft Entra tenant ID. The wizard therefore asks for the tenant, never for a Management Group — there is no partial onboarding from a Management Group further down the hierarchy. + + +## Before You Start + +### Create a Service Principal + +Prowler Cloud authenticates against Azure with a service principal application. To create one, follow [Creating a Prowler Service Principal Application](/user-guide/providers/azure/create-prowler-service-principal). Keep the **client ID** and the **client secret** — the wizard asks for both. + +### Grant Read Access at the Tenant Root Management Group + +Discovery reads the Management Group hierarchy and the subscriptions inside it. Assign the built-in **Reader** role to the service principal **on the tenant root Management Group**, so the grant covers every Management Group and subscription beneath it: + +```bash +TENANT_ID= +CLIENT_ID= + +# Resolve the exact service principal from its client ID. A display-name search +# is a prefix match, so it can return a different principal than intended. +SP_OBJECT_ID=$(az ad sp show --id "$CLIENT_ID" --query id -o tsv) + +az role assignment create \ + --role "Reader" \ + --assignee-object-id "$SP_OBJECT_ID" \ + --assignee-principal-type ServicePrincipal \ + --scope "/providers/Microsoft.Management/managementGroups/$TENANT_ID" +``` + + +The tenant root Management Group is named after the tenant ID, so its resource identifier is always `/providers/Microsoft.Management/managementGroups/`. Creating a role assignment there requires **Owner** or **User Access Administrator** at that scope; a Global Administrator who holds neither can grant themselves access through the elevated-access toggle in Microsoft Entra ID. + + +### Grant the Scanning Permissions + +Scanning each subscription additionally needs the permissions described in [Azure Authentication in Prowler](/user-guide/providers/azure/authentication#required-permissions): + +| Permission | Scope | Used for | +|------------|-------|----------| +| **Reader** | Subscription, or inherited from the Management Group | Reading Azure resources during a scan. | +| **ProwlerRole** | Subscription, or inherited from the Management Group | The read-only actions the Reader role does not cover. | +| `AuditLog.Read.All`, `Directory.Read.All`, `Policy.Read.All` | Microsoft Entra ID (Microsoft Graph) | The Microsoft Entra ID checks. | + +Role assignments made at the tenant root Management Group are inherited by every subscription beneath it, so assigning **Reader** and **ProwlerRole** there means every subscription you onboard is scannable without a per-subscription grant. To create the custom role, see [Assigning "ProwlerRole" Permissions at the Subscription Level](/user-guide/providers/azure/authentication#assigning-prowlerrole-permissions-at-the-subscription-level) — and set its `assignableScopes` to the Management Group, `/providers/Microsoft.Management/managementGroups/`, rather than to a single subscription, so it can be assigned once for the whole tenant. + +### Find Your Microsoft Entra Tenant ID + +Prowler identifies the organization by the Microsoft Entra tenant ID, a Universally Unique Identifier (UUID): + +```bash +az account show --query tenantId -o tsv +``` + +In the Azure portal, the same value sits on your service principal's **App registrations** > **Overview** blade as **Directory (tenant) ID**, next to the **Application (client) ID** the wizard also asks for. [Adding Azure credentials to Prowler Cloud](/user-guide/providers/azure/getting-started-azure#step-3-add-credentials-to-prowler-cloud) shows both on the annotated blade. + +## Step 1: Start the Organization Wizard + +### Open the Wizard + +1. Navigate to **Providers** and click **Add Provider**. + + + Providers page showing the Add Provider button + + +2. Select **Microsoft Azure** as the provider. + + + Provider selection modal with Microsoft Azure highlighted + + +3. Choose **Add Multiple Subscriptions With Azure Management Group**. + + + Method selector showing the Add Multiple Subscriptions With Azure Management Group option highlighted + + + +In Prowler Local Server the Management Group option is marked **Cloud** and opens an upgrade panel instead of the wizard. Management Group onboarding is a Prowler Cloud feature; the single-subscription method remains available. + + +### Enter Organization Details + +- **Tenant ID**: the Microsoft Entra tenant ID (for example, `8b3c9a41-5f27-4d6e-9c18-7ae204f5b6d2`). Values that are not valid UUIDs are rejected before submission. +- **Name** (optional): a display name for the organization in Prowler. If left blank, Prowler uses the tenant ID. + + + Organization Details form with the Microsoft Entra tenant ID and Name fields + + +Click **Next** to proceed to the authentication phase. Prowler matches the organization by tenant ID, so submitting a tenant that is already onboarded reuses it instead of creating a duplicate. + +## Step 2: Authenticate With Azure + +The **Authentication Details** step collects the service principal Prowler uses to read the Management Group hierarchy and, later, to scan each subscription: + +- **Client ID**: the application (client) ID of the service principal, a UUID. +- **Client Secret**: a client secret of that service principal. + + + Authentication Details step showing the Client ID and Client Secret fields + + +The tenant is not repeated here: Prowler takes it from the organization created in the previous step. + + +Every subscription you onboard inherits this credential. Revoking it, rotating the client secret, or deleting the service principal stops the scans of every subscription in the organization. + + +### Authenticate and Discover + +Click **Authenticate**. Prowler then: + +- Creates the organization and stores the credential securely. +- Triggers an asynchronous discovery that walks the Management Group hierarchy. +- Shows a **"Gathering Azure Subscriptions..."** spinner while it waits. + + + Gathering Azure Subscriptions spinner shown while discovery runs + + +Discovery usually takes seconds to a couple of minutes, depending on how many Management Groups and subscriptions the tenant holds. + +#### When Discovery Takes Too Long + +Prowler waits up to **3 minutes** for a result. Past that, the wizard stops waiting — but the discovery keeps running in Azure — and offers two actions: + +- **Keep waiting** — resume the same discovery. Nothing is re-read from Azure. +- **Retry** — start a fresh discovery, which reads the hierarchy again. + + + Discovery timeout notice offering Keep waiting and Retry + + +If discovery fails outright, the wizard explains why and offers **Retry discovery**. See [Troubleshooting](#troubleshooting) for each message. + +## Step 3: Select Subscriptions to Scan + +### Understanding the Tree View + +Once discovery completes, the wizard renders the tenant as a hierarchical tree: + + + Hierarchical tree view showing Management Groups and subscriptions with selection checkboxes + + +- **Management Groups** nest under the tenant root; subscriptions that sit directly under the tenant root appear at the top level. The tenant root itself is not shown as a row — the whole tree is its content. +- Each Management Group row shows its Management Group name, with the Azure display name beside it. Hover the name to read the full Azure Resource Manager (ARM) resource identifier. +- **Selecting a Management Group** selects every selectable subscription beneath it. A Management Group whose subscriptions are only partly selected renders in an indeterminate state. +- **Individual overrides**: deselect single subscriptions even when the parent Management Group is selected. +- The header tracks the selection as **"X of Y subscriptions selected"**. +- Management Group hierarchies are read up to **six levels** below the tenant root Management Group, which is Azure's own platform limit. Deeper tenants report an error at discovery — see [Troubleshooting](#troubleshooting). + +### Blocked Subscriptions + +A subscription is shown grayed out and cannot be selected when Azure reports it as inactive, or when onboarding it would conflict with something Prowler already stores. Hover the subscription to see the reason: + +| Reason | What it means | +|--------|---------------| +| `subscription_not_enabled` | The subscription is not in the **Enabled** state in Azure — it is disabled, expired, or otherwise inactive. Nothing in Prowler conflicts with it: resolve whatever Azure reports against the subscription, billing included, then run discovery again. | +| `organization_conflict` | The subscription is already connected under a **different** Prowler organization. | +| `organization_node_conflict` | The subscription is already grouped under a different Management Group in Prowler — for example, it moved in Azure after it was onboarded. | +| `provider_type_conflict` | A provider with the same identifier exists in Prowler for another cloud provider. | + + + Blocked subscription row with the reason shown in a tooltip + + +### Management Groups With Nothing to Select + +A Management Group that holds no subscriptions, or whose subscriptions are all blocked, is shown disabled with the note *"No subscriptions available to select in this management group."* The Management Group still expands, so you can see the blocked subscriptions it holds and why they are blocked. + + + Disabled Management Group row noting that no subscriptions are available to select + + +### Custom Aliases + +Each subscription row carries an editable name, prefilled with the subscription's display name. The alias is used only inside Prowler — it does not rename anything in Azure. Management Group names are read-only: Prowler stores the display name from Azure. + +### Subscriptions That Already Have Credentials + +Applying your selection stores the organization credential on every selected subscription. When a selected subscription is already connected to Prowler with its own credential, that credential is **overwritten** — so the wizard asks first, naming the affected subscriptions: + + + Replace existing credentials modal listing the subscriptions whose credentials will be replaced + + +Click **Replace and continue** to proceed, or **Cancel** to adjust your selection. + + +**Your existing data is safe.** A subscription already connected as an individual provider is **linked** to the organization, never duplicated: its historical scans and findings are preserved, and it does not count twice toward your subscription. + + +## Step 4: Test Connections + +Click **Test Connections** to verify that Prowler can authenticate against each selected subscription. Prowler creates one provider per subscription — identified by its Azure subscription ID — and then tests every connection. + + + Connection testing in progress with status icons on each subscription + + +Each subscription shows a real-time status indicator: + +- **Spinner** — test in progress +- **Green checkmark (✓)** — connection successful +- **Red icon (✗)** — connection failed (hover to see the error) + +If every subscription connects successfully, you advance to the next step automatically. + +### When Some Tests Fail + +An error banner appears: **"There was a problem connecting to some subscriptions. Hover each subscription to check the error."** You have two options: + +**a) Fix and retry:** + +1. Confirm the service principal holds **Reader** and **ProwlerRole** on the failing subscriptions, or on a Management Group above them. +2. Confirm the Microsoft Graph permissions have been granted admin consent, as described in [Azure Authentication in Prowler](/user-guide/providers/azure/authentication#assigning-required-api-permissions). +3. Click **Test Connections** again — only the **failed subscriptions are re-tested**. Subscriptions that already passed are not tested again. + +**b) Skip and continue:** + +Click **Skip Connection Validation** to proceed with the subscriptions that connected successfully. Failed subscriptions stay onboarded and visible on the Providers page, but they are not scanned. This option appears only when at least one subscription connected. + +If **no subscription** connects, the banner instead reads *"No subscriptions connected successfully. Fix the connection errors and retry before launching scans."* and you cannot proceed. Fix the underlying problem — see [Troubleshooting](#troubleshooting) — and retry. + +## Step 5: Launch Scans + +The Organizations wizard uses the same schedule controls described in [Scan Scheduling](/user-guide/tutorials/prowler-scan-scheduling#schedule-options). + +Click **Save**, **Save and launch scan**, or **Launch scan**, depending on the selected schedule option. A toast notification confirms whether the schedule was saved, scans were launched, or both, and links to the **Scans** page. Prowler then redirects to the **Providers** page. Scans launch only for subscriptions that passed connection testing. + + + Launch Scan step showing the Subscriptions Connected confirmation and the scan schedule selector + + +After launching: + +- Scans appear on the **Scans** page as they start and complete. +- Results populate the **Overview** and **Findings** pages. +- On the **Providers** page, your subscriptions are grouped under the organization and, when they live in a Management Group, under that Management Group. + + + Providers page showing subscriptions grouped under Azure Management Groups and the organization + + +## Manage Your Organization After Onboarding + +Open the row actions menu on the organization row on the **Providers** page. + + + Row actions menu on an Azure organization row + + +| Action | What it does | +|--------|--------------| +| **Edit Organization Name** | Renames the organization in Prowler. Leave it blank to fall back to the tenant ID. | +| **Update Credentials** | Reopens the Authentication Details step to store a new service principal credential. | +| **Edit Scan Schedule** | Applies one schedule to every connected subscription in the organization. | +| **Test Connections (N)** | Re-tests every subscription in the organization. | +| **Delete Organization** | Deletes the organization and cascades to its providers. | + +### Onboard Subscriptions Created Later + +Subscriptions added to the tenant after onboarding are not picked up automatically. Run the wizard again with the same tenant ID: discovery returns the current hierarchy, already-connected subscriptions come back preselected, and the new ones are ready to select. + +### Update Organization Credentials + +Choosing **Update Credentials** re-enters the Authentication Details step. Because the organization already holds a credential, Prowler warns before overwriting it and names how many providers re-authenticate with the new one: + + + Replace existing credentials modal showing how many providers re-authenticate + + +Storing a new credential runs a fresh discovery, so any discovery already in progress is discarded — discovery authenticates with the credential it started from. + +### Delete an Organization or Management Group + +Deleting an organization or a Management Group **cascades to every provider grouped under it**, along with their scans and findings. Both dialogs state how many providers are affected before you confirm. + + + Delete organization dialog showing how many providers are deleted with it + + +Deletion runs in the background. Prowler confirms with a **"Deletion started"** notification; if any part of it fails, the affected rows reappear on a later refresh. + + +Deleting an organization **permanently deletes every subscription provider grouped under it**, including their historical scans and findings. This action cannot be undone. + + +### When Grouping Is Unavailable + +If Prowler cannot read the hierarchy while loading the Providers page, a notice reads *"Organization grouping is incomplete. Some providers may appear ungrouped."* Your providers are still listed, just flat. Reload the page to try again. + +## Billing Impact + +Each Azure subscription you connect through the Organizations wizard counts as one **provider** in your Prowler Cloud subscription. + +- **Already-connected subscriptions**: linking an existing provider to the organization does **not** add billing. The existing provider is reused. +- **Large tenants**: connecting a 500-subscription tenant results in up to 500 providers on your subscription. Review your plan limits before proceeding. +- **Deleted providers**: a subscription you later remove no longer counts toward your subscription. + +For pricing details, see [Prowler Cloud Pricing](https://prowler.com/pricing). + +## Troubleshooting + +### Those Service Principal Credentials Were Rejected + +*"Those service principal credentials were rejected. Check the client ID and client secret, then try again."* + +Azure refused the sign-in. Confirm the client ID belongs to the service principal you granted access to, and that the client secret is current — secrets expire, and a rotated one invalidates the old value immediately. + +```bash +az ad app credential list --id --query "[].{name:displayName, expires:endDateTime}" -o table +``` + +### The Service Principal Cannot Read the Complete Management Group Hierarchy + +*"The service principal cannot read the complete Management Group hierarchy. Grant it the Reader role at the Management Group level, then try again."* + +The credential authenticated but lacks read access to part of the hierarchy. Grant **Reader** at the tenant root Management Group, as described in [Grant Read Access at the Tenant Root Management Group](#grant-read-access-at-the-tenant-root-management-group), and confirm the assignment landed on the Management Group and not on a single subscription: + +```bash +az role assignment list \ + --assignee \ + --scope "/providers/Microsoft.Management/managementGroups/" \ + --query "[].{role:roleDefinitionName, scope:scope}" -o table +``` + +### Azure Returned an Incomplete Management Group Hierarchy + +*"Azure returned an incomplete Management Group hierarchy. This usually clears on a retry; if it does not, check that the service principal can read every Management Group in the tenant."* + +Azure answered, but the hierarchy it returned was missing Management Groups that Prowler expected to find. This is usually a transient inconsistency in Azure Resource Manager, so click **Retry discovery** first. + +If it repeats, treat it as a permissions problem. Unlike the error above, Azure did not refuse the read — a partial grant can surface as missing data rather than as an outright denial. Confirm that the **Reader** assignment sits on the tenant root Management Group and not on individual Management Groups beneath it, using the command in [The Service Principal Cannot Read the Complete Management Group Hierarchy](#the-service-principal-cannot-read-the-complete-management-group-hierarchy). + +### The Tenant Root Management Group Could Not Be Found + +*"The tenant root Management Group could not be found. Check the tenant ID, and that the service principal has been granted access at the tenant root."* + +Prowler derives the tenant root Management Group from the tenant ID you entered, and could not read it. Either the tenant ID is wrong, or the service principal cannot see the root. Confirm the tenant ID and list what the credential can reach: + +```bash +az account management-group list --query "[].{name:name, displayName:displayName}" -o table +``` + +### Those Credentials Belong to a Different Microsoft Entra Tenant + +*"Those credentials belong to a different Microsoft Entra tenant. Use a service principal from the tenant you entered."* + +The service principal is registered in another tenant. Register the application in the tenant being onboarded, or correct the tenant ID in the first step of the wizard. + +### Azure Did Not Respond + +*"Azure did not respond while reading the Management Group hierarchy. Nothing is wrong with your credentials — try again in a few minutes."* + +A transient Azure Resource Manager error. Click **Retry discovery**. + +### Azure Rate Limited the Hierarchy Read + +*"Azure rate limited the hierarchy read. Nothing is wrong with your credentials — try again in a few minutes."* + +Azure Resource Manager throttled Prowler's requests while it walked the hierarchy. Large tenants and back-to-back discoveries are the common causes. Wait a few minutes, then click **Retry discovery**. + +### The Hierarchy Is Too Deep + +*"This tenant's Management Group hierarchy is deeper than Prowler can read. Contact support so we can help you onboard it."* + +The tenant nests Management Groups deeper than Prowler reads in one pass. Contact [Prowler Support](mailto:support@prowler.com). + +### Authentication Failed + +*"Authentication failed. Please verify the service principal permissions or credentials, then try again."* + +The generic authentication failure, shown when Azure reports no more specific reason. Confirm that the service principal still exists, that its client secret has not expired, and that the Reader role assignment at the tenant root Management Group is still in place. + +### Azure Rejected the Hierarchy Read + +*"Azure rejected the hierarchy read. Try again, and contact support if it keeps failing."* + +Azure refused the request for a reason Prowler could not narrow down to credentials, permissions, or throttling. Click **Retry discovery**, and contact [Prowler Support](mailto:support@prowler.com) if the failure repeats. This message names Azure as the source of the failure; the one below is shown when the failure cannot be attributed to Azure at all. + +### Discovery Could Not Be Completed + +*"Discovery could not be completed. Try again, and contact support if it keeps failing."* + +Discovery failed for a reason Prowler cannot attribute to the credential or to Azure. Click **Retry discovery**, and contact [Prowler Support](mailto:support@prowler.com) if the failure repeats. + +### Discovery Never Finishes + +The wizard stops waiting after 3 minutes, but the discovery keeps running in Azure. Click **Keep waiting** to resume the same discovery rather than **Retry**, which starts over and re-reads the whole hierarchy. + +## Key Concepts + +### How Subscriptions Map to Prowler Providers + +Each selected subscription becomes one Prowler provider: + +| Prowler field | Comes from | +|---------------|------------| +| Provider identifier | The Azure subscription ID (for example, `22222222-2222-4222-8222-222222222222`). | +| Alias | The name you typed in the tree, or the subscription's display name. | +| Credential | A copy of the organization credential. | + +Management Groups that hold selected subscriptions become grouping rows on the Providers page. You select subscriptions only — Prowler derives the Management Group ancestors itself. + +### Organization Credential vs Subscription Credential + +One credential, stored twice: on the organization, where discovery reads it, and on each subscription provider, where scans read it. That is why replacing the organization credential re-authenticates every subscription under it, and why the wizard asks before overwriting a subscription's own credential. + +## What's Next + + + + Full guide to using Prowler Cloud features. + + + CLI-based scanning of specific Azure subscriptions. + + + Credential types and the permissions Prowler needs in Azure. + + + Script-based bulk provisioning for advanced automation. + + diff --git a/docs/user-guide/tutorials/prowler-cloud-gcp-organizations.mdx b/docs/user-guide/tutorials/prowler-cloud-gcp-organizations.mdx index b7cef38c62..f2943f39a7 100644 --- a/docs/user-guide/tutorials/prowler-cloud-gcp-organizations.mdx +++ b/docs/user-guide/tutorials/prowler-cloud-gcp-organizations.mdx @@ -120,7 +120,7 @@ In Prowler Local Server the organization option is marked **Cloud** and opens an ### Enter Organization Details - **Organization ID**: the numeric ID of your Google Cloud organization (for example, `123456789012`). Non-numeric values are rejected before submission. -- **Name** (optional): a display name for the organization in Prowler. If left blank, Prowler uses the name stored in Google Cloud. +- **Name** (optional): a display name for the organization in Prowler. If left blank, Prowler uses the organization ID. Organization Details form with the Google Cloud organization ID and Name fields @@ -307,7 +307,7 @@ Open the row actions menu on the organization row on the **Providers** page. | Action | What it does | |--------|--------------| -| **Edit Organization Name** | Renames the organization in Prowler. Leave it blank to fall back to the name stored in Google Cloud. | +| **Edit Organization Name** | Renames the organization in Prowler. Leave it blank to fall back to the organization ID. | | **Update Credentials** | Reopens the Authentication Details step to store a new credential. | | **Edit Scan Schedule** | Applies one schedule to every connected project in the organization. | | **Test Connections (N)** | Re-tests every project in the organization. | diff --git a/docs/user-guide/tutorials/prowler-import-findings.mdx b/docs/user-guide/tutorials/prowler-import-findings.mdx index 530cd160ee..253fa0d895 100644 --- a/docs/user-guide/tutorials/prowler-import-findings.mdx +++ b/docs/user-guide/tutorials/prowler-import-findings.mdx @@ -1,7 +1,7 @@ --- title: 'Import Findings' sidebarTitle: 'Import Findings' -description: 'Upload OCSF scan results to Prowler Cloud from external sources or the CLI' +description: 'Upload OCSF scan results to Prowler Cloud from the UI, the CLI or the API' --- import { VersionBadge } from "/snippets/version-badge.mdx" @@ -9,7 +9,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. @@ -132,10 +132,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. @@ -153,6 +175,18 @@ export PROWLER_CLOUD_API_KEY="pk_your_api_key_here" prowler aws --push-to-cloud ``` +### TLS Certificate Trust + +For `--push-to-cloud` uploads, Prowler CLI creates one ingestion-scoped TLS context and validates HTTPS certificates with one handshake and one POST request. The upload does not retry or fall back to another TLS configuration. Redirect responses are rejected. + +The ingestion context combines the operating system roots with the default certificate authority (CA) roots bundled with Requests. If `REQUESTS_CA_BUNDLE` is configured, Prowler CLI also loads that file or directory into the ingestion context. Otherwise, Prowler CLI loads `CURL_CA_BUNDLE` when configured. + +`REQUESTS_CA_BUNDLE` and `CURL_CA_BUNDLE` can also affect other Requests-based connections throughout the Prowler CLI process, including provider authentication. A bundle containing only a private CA can cause connections to public services to fail before the upload starts. Installing the private CA in the operating system or container trust store is recommended. If a custom bundle is required, it must include both the public CA roots, such as the certifi bundle, and the required private CA certificates. + +For Prowler Private Cloud deployments that use an organization CA or a TLS-intercepting corporate proxy, installing the required root CA in the operating system or container store remains the recommended approach. Containers have an isolated system CA store, so add the organization or proxy CA to the container image or runtime, then run the operating system's CA update command, such as `update-ca-certificates`, before starting Prowler CLI. Installing a CA on the container host does not automatically install it inside the container. + +Prowler CLI does not create, modify, or remove these environment variables. The custom TLS context created by `--push-to-cloud` applies only to the temporary ingestion session and does not change API, provider, integration, global SSL, or unrelated Requests session behavior. + ### Combining with Output Formats When using `--push-to-cloud` with custom output formats that exclude OCSF, Prowler generates a temporary OCSF file for upload: @@ -432,7 +466,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/AGENTS.md b/mcp_server/AGENTS.md index e786f9a5b7..713155bffe 100644 --- a/mcp_server/AGENTS.md +++ b/mcp_server/AGENTS.md @@ -113,7 +113,8 @@ make test-mcp # Run the MCP test suite exactly as CI does - [ ] Models use `MinimalSerializerMixin` - [ ] API responses transformed to simplified models - [ ] No hardcoded secrets -- [ ] Error handling returns structured responses +- [ ] Failures are raised, not returned (see `prowler_mcp_server/lib/errors.py`); + a returned error dict is reported to the client as a success - [ ] Parameter descriptions use Pydantic `Field()` - [ ] Tests added under `mcp_server/tests/`, mirroring the source path below the package root (`prowler_mcp_server/prowler_app/tools/` -> `tests/prowler_app/tools/`), diff --git a/mcp_server/CHANGELOG.md b/mcp_server/CHANGELOG.md index c040aa17e7..07e92992fc 100644 --- a/mcp_server/CHANGELOG.md +++ b/mcp_server/CHANGELOG.md @@ -4,6 +4,57 @@ All notable changes to the **Prowler MCP Server** are documented in this file. +## [0.12.2] (Prowler v5.43.0) + +### 🔐 Security + +- Bumped `anyio` to 4.14.2 to resolve CVE-2026-63374 [(#12848)](https://github.com/prowler-cloud/prowler/pull/12848) + +--- + +## [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 + +- Failures shared by every tool - a rejected credential, a missing permission, a rate limit, an outage, an unreachable API, a bad argument - are now explained with a message that says what went wrong and what to do about it [(#12531)](https://github.com/prowler-cloud/prowler/pull/12531) + +### 🐞 Fixed + +- `prowler_docs_search` returns results again: it calls the search endpoint docs.prowler.com moved to, since the one it used no longer exists, and each result now names the page's title, the section it matched and a URL anchored at that section [(#12578)](https://github.com/prowler-cloud/prowler/pull/12578) + +### 🔐 Security + +- Stop relaying upstream response bodies to agents: a failed request now reaches the caller as a sentence this server wrote, with the full body kept to the logs, so a gateway error page or a debug traceback can no longer be replayed into a model's context [(#12531)](https://github.com/prowler-cloud/prowler/pull/12531) +- `sqlite-libs` upgraded to 3.53.4-r0 in the container image, patching CVE-2026-11822 and CVE-2026-11824 [(#12537)](https://github.com/prowler-cloud/prowler/pull/12537) +- `libcrypto3` and `libssl3` upgraded to 3.5.8-r0 in the container image, patching CVE-2026-14456 [(#12547)](https://github.com/prowler-cloud/prowler/pull/12547) + +--- + ## [0.10.0] (Prowler v5.38.0) ### 🚀 Added diff --git a/mcp_server/Dockerfile b/mcp_server/Dockerfile index 195bfe1eae..db57306dfb 100644 --- a/mcp_server/Dockerfile +++ b/mcp_server/Dockerfile @@ -25,7 +25,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \ # ============================================================================= # Final stage - Minimal runtime environment # ============================================================================= -FROM python:3.13.14-alpine3.23@sha256:9fdbf2e3e82628351513560b121e2ee6ce31cac212be9e070c5a5e2769fb5e76 +FROM python:3.13.16-alpine3.23@sha256:07b20c774a247152e74f622034c1482495d299e070eec620ddff13e01264290b LABEL maintainer="https://github.com/prowler-cloud" @@ -34,6 +34,11 @@ LABEL maintainer="https://github.com/prowler-cloud" RUN addgroup -g 1001 prowler && \ adduser -D -u 1001 -G prowler prowler +# pip is build-only; the entrypoint runs the venv directly. +RUN rm -rf /usr/local/lib/python3.13/site-packages/pip \ + /usr/local/lib/python3.13/site-packages/pip-*.dist-info \ + /usr/local/bin/pip /usr/local/bin/pip3 /usr/local/bin/pip3.13 + WORKDIR /app USER prowler diff --git a/mcp_server/changelog.d/mcp-image-python-3-13-16.security.md b/mcp_server/changelog.d/mcp-image-python-3-13-16.security.md new file mode 100644 index 0000000000..c926794e3e --- /dev/null +++ b/mcp_server/changelog.d/mcp-image-python-3-13-16.security.md @@ -0,0 +1 @@ +Python from 3.13.14 to 3.13.16 in the MCP container image, patching CVE-2026-19445 and CVE-2026-19553 diff --git a/mcp_server/changelog.d/pyjwt-2-14-0.security.md b/mcp_server/changelog.d/pyjwt-2-14-0.security.md new file mode 100644 index 0000000000..fab78f2937 --- /dev/null +++ b/mcp_server/changelog.d/pyjwt-2-14-0.security.md @@ -0,0 +1 @@ +`pyjwt` from 2.13.0 to 2.14.0, patching GHSA-ffc3-869f-jxw9 diff --git a/mcp_server/prowler_mcp_server/lib/errors.py b/mcp_server/prowler_mcp_server/lib/errors.py new file mode 100644 index 0000000000..1aa3a25607 --- /dev/null +++ b/mcp_server/prowler_mcp_server/lib/errors.py @@ -0,0 +1,329 @@ +"""Shared failure classification for every tool in this server.""" + +import json +from typing import Any + +import httpx +from fastmcp.exceptions import McpError, NotFoundError, ToolError +from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext +from pydantic import ValidationError + +from prowler_mcp_server.lib.logger import logger + +# ------------------------------------------------------------- failure types + + +class ProwlerAPIError(Exception): + """An error response returned by the Prowler API. + + 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, + payload: dict[str, Any] | None = None, + ) -> None: + super().__init__(message) + self.status_code: int = status_code + # Prowler's own JSON:API `errors[0].detail`, which our API writes for a + # caller and we therefore trust. None when the body was not JSON:API -- + # a gateway HTML page or a debug traceback, which is exactly the case + # 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): + """The request never got an answer, so whether it was applied is unknown.""" + + +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. + + Args: + response: Error response returned by the Prowler API + + Returns: + `errors[0].detail`, or None if the status is 5xx or the body is not + JSON:API + """ + if response.status_code >= 500: + return None + + try: + errors = response.json().get("errors") + except Exception: + return None + + if not isinstance(errors, list) or not errors: + return None + + detail = errors[0].get("detail") if isinstance(errors[0], dict) else None + return detail if isinstance(detail, str) and detail.strip() else None + + +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 + + +def _describe_prowler_api_error(exc: ProwlerAPIError) -> str: + """Describe a request the Prowler API answered with an error status.""" + status = exc.status_code + + # The fallback: a status this server has nothing specific to say about. + message = ( + f"Prowler rejected the request with status {status} and gave no reason. " + "Check the arguments against the tool description." + ) + + if status == 401: + message = ( + "Prowler rejected this server's credential: it is missing, malformed " + "or expired. In HTTP mode the request needs an 'Authorization: Bearer " + "' header; in STDIO mode PROWLER_API_KEY must hold a valid key." + ) + elif status == 403: + message = ( + "The credential is valid but not allowed to do this. Use " + "prowler_get_current_user to see which role it holds." + ) + elif status == 429: + message = ( + "Prowler is rate limiting this credential. Wait before retrying, and " + "narrow the request with tighter filters or a smaller page_size." + ) + elif status >= 500: + message = ( + f"Prowler answered {status}: the request failed on Prowler's side, " + "not because of anything in the call." + ) + elif exc.detail: + # Written by the Prowler API for a caller to read, so it is ours to relay. + message = f"Prowler rejected the request ({status}): {exc.detail}" + + return message + + +def _describe_upstream_http_error(exc: httpx.HTTPError) -> str: + """Describe a failure from an upstream this server reads directly.""" + # `.request` raises rather than returning None when it was never set, and + # this runs inside an exception handler, so it is read defensively. + request = getattr(exc, "_request", None) + host = request.url.host if request is not None else "the upstream service" + + if not isinstance(exc, httpx.HTTPStatusError): + return f"{host} could not be reached: {type(exc).__name__}." + + status = exc.response.status_code + if status == 429: + return f"{host} is rate limiting this server. Wait before retrying." + if status >= 500: + return ( + f"{host} answered {status}: the request failed on its side, not " + "because of anything in the call." + ) + return ( + f"{host} rejected the request with status {status}. Check the arguments " + "against the tool description." + ) + + +def _describe_failure(exc: BaseException) -> str | None: + """Describe a failure for a model to read, or return None to leave it masked.""" + # InvalidArgument first: it is the only ValueError here whose message this + # server wrote. The ones below quote the input they rejected, so they are + # matched by type and answered with a message of our own. + if isinstance(exc, InvalidArgument): + return str(exc) + + if isinstance(exc, ProwlerAPIError): + return _describe_prowler_api_error(exc) + + if isinstance(exc, ProwlerAPIInvalidResponse): + return ( + "Prowler answered with a body this server could not read, so the " + "outcome of the call is unknown. If it changes anything, check the " + "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 ( + f"Prowler could not be reached: {exc}. Whether the request was " + "applied is unknown, so check the current state before sending it again." + ) + + if isinstance(exc, ValidationError): + problems = [ + f"{'.'.join(str(part) for part in error['loc']) or '(argument)'}: {error['msg']}" + for error in exc.errors(include_url=False) + ] + # Field and expectation only: pydantic quotes the rejected value back. + return f"Invalid arguments -- {'; '.join(problems)}." + + if isinstance(exc, json.JSONDecodeError): + return ( + "An argument that had to be a JSON object could not be parsed. Send " + "it as a real object rather than as a quoted or escaped string." + ) + + # Only the Hub and documentation tools reach here: the Prowler API client + # converts its own httpx failures into the two types matched above. + if isinstance(exc, (httpx.HTTPStatusError, httpx.RequestError)): + return _describe_upstream_http_error(exc) + + return None + + +# ----------------------------------------------------------------- middleware + + +class SharedFailureMiddleware(Middleware): + """Replace the failures many tools share with a message a model can act on.""" + + async def on_call_tool( + self, + context: MiddlewareContext[Any], + call_next: CallNext[Any, Any], + ) -> Any: + """Replace a shared tool failure with the message that describes it. + + Args: + context: Tool call being handled + call_next: Rest of the middleware chain + + Returns: + The tool result when the call succeeded + + Raises: + ToolError: With the classified message when the failure is one this + module recognises + """ + try: + return await call_next(context) + except (NotFoundError, McpError): + # Protocol-level, not a tool failure. Must stay exactly as it is. + raise + except Exception as exc: + # FastMCP wraps whatever the tool raised and records it as __cause__. + # An absent cause means the message is already the final word: a + # ToolError raised deliberately without a `from` clause. + original = exc.__cause__ + message = _describe_failure(original) if original is not None else None + + if message is None: + if original is not None: + logger.warning( + "Tool %s failed with an unclassified error: %s: %s", + getattr(context.message, "name", ""), + type(original).__name__, + original, + ) + raise + + logger.warning( + "Tool %s failed: %s: %s", + getattr(context.message, "name", ""), + type(original).__name__, + original, + ) + raise ToolError(message) from exc 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 5717a0a3b5..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 @@ -7,28 +7,22 @@ from typing import Any from urllib.parse import urlparse import httpx +from fastmcp.exceptions import ToolError from prowler_mcp_server import __version__ +from prowler_mcp_server.lib.errors import ( + InvalidArgument, + ProwlerAPIError, + ProwlerAPIInvalidResponse, + ProwlerAPIUnreachable, + jsonapi_detail, +) from prowler_mcp_server.lib.logger import logger from prowler_mcp_server.prowler_app.utils.auth import ProwlerAppAuth ALLOWED_EXTERNAL_DOMAINS: frozenset[str] = frozenset({"raw.githubusercontent.com"}) -class ProwlerAPIError(Exception): - """An error response returned by the Prowler API. - - Raised only when the API answered with an error status, which tells a caller - something no plain exception can: the request reached Prowler and was - rejected, so it changed nothing. A timeout or a dropped connection stays a - bare exception because the request may well have been processed. - """ - - def __init__(self, message: str, status_code: int) -> None: - super().__init__(message) - self.status_code: int = status_code - - class HTTPMethod(StrEnum): """HTTP methods enum.""" @@ -88,7 +82,8 @@ class ProwlerAPIClient(metaclass=SingletonMeta): Raises: ProwlerAPIError: If the API answered with an error status - Exception: If the request could not be completed + ProwlerAPIUnreachable: If the request got no answer + ProwlerAPIInvalidResponse: If the answer was not readable as JSON """ try: token: str = await self.auth_manager.get_valid_token() @@ -103,31 +98,75 @@ class ProwlerAPIClient(metaclass=SingletonMeta): json=json_data, ) response.raise_for_status() - - if not response.content: - return { - "success": True, - "status_code": response.status_code, - } - else: - return response.json() except httpx.HTTPStatusError as e: - logger.error(f"HTTP error during {method.value} {path}: {e}") - error_detail: str = "" + status: int = e.response.status_code + # `jsonapi_detail` returns nothing for a 5xx, so a server error never + # puts upstream text into the exception message either. + detail: str | None = jsonapi_detail(e.response) + # The full body goes to the log and nowhere else. A body that is not + # JSON:API is upstream text of unknown provenance, and the exception + # message is read by a model. + logger.error( + "HTTP error during %s %s: %s %s", + method.value, + path, + status, + (e.response.text or "")[:500], + ) + + message = f"API request failed: {status}" + if detail: + message = f"{message} - {detail}" + + # 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: - error_data: dict[str, any] = e.response.json() - error_detail = error_data.get("errors", [{}])[0].get("detail", "") - except Exception: - error_detail = e.response.text + body = e.response.json() + except ValueError: + body = None raise ProwlerAPIError( - f"API request failed: {e.response.status_code} - {error_detail}", - e.response.status_code, - ) + 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}") + raise ProwlerAPIUnreachable( + f"{method.value} {path} got no answer: {type(e).__name__}" + ) from e except Exception as e: logger.error(f"Error during {method.value} {path}: {e}") raise + if not response.content: + return { + "success": True, + "status_code": response.status_code, + } + + # Parsed outside the block above so that a body we cannot read is told + # apart from an argument a tool could not parse: both are a + # `JSONDecodeError`, and only the second one is the caller's doing. + try: + return response.json() + except ValueError as e: + logger.error( + "Unreadable response body during %s %s: %s %s", + method.value, + path, + response.status_code, + (response.text or "")[:500], + ) + raise ProwlerAPIInvalidResponse( + f"{method.value} {path} answered {response.status_code} with a " + "body that is not JSON" + ) from e + async def get( self, path: str, params: dict[str, any] | None = None ) -> dict[str, any]: @@ -229,14 +268,14 @@ class ProwlerAPIClient(metaclass=SingletonMeta): Raw text content from the URL Raises: - ValueError: If the URL domain is not in the allowlist - Exception: If the HTTP request fails + InvalidArgument: If the URL scheme or domain is not allowed + ToolError: If the fetch failed """ parsed = urlparse(url) if parsed.scheme != "https": - raise ValueError(f"Only HTTPS URLs are allowed, got '{parsed.scheme}'") + raise InvalidArgument(f"Only HTTPS URLs are allowed, got '{parsed.scheme}'") if parsed.hostname not in ALLOWED_EXTERNAL_DOMAINS: - raise ValueError( + raise InvalidArgument( f"Domain '{parsed.hostname}' is not allowed. " f"Allowed domains: {', '.join(sorted(ALLOWED_EXTERNAL_DOMAINS))}" ) @@ -249,13 +288,20 @@ class ProwlerAPIClient(metaclass=SingletonMeta): response.raise_for_status() return response.text except httpx.HTTPStatusError as e: - logger.error(f"HTTP error fetching external URL {url}: {e}") - raise Exception( - f"Failed to fetch external URL: {e.response.status_code}" - ) from e - except Exception as e: + # The status is ours to report; the body is upstream text and stays + # in the log. No `from` clause: this sentence is the final word. + logger.error( + "HTTP error fetching external URL %s: %s %s", + url, + e.response.status_code, + (e.response.text or "")[:500], + ) + raise ToolError( + f"Fetching {url} failed with status {e.response.status_code}." + ) + except httpx.RequestError as e: logger.error(f"Error fetching external URL {url}: {e}") - raise + raise ToolError(f"Fetching {url} got no answer: {type(e).__name__}.") async def poll_task_until_complete( self, @@ -278,7 +324,7 @@ class ProwlerAPIClient(metaclass=SingletonMeta): The complete task response when terminal state is reached Raises: - Exception: If task fails, is cancelled, or timeout is exceeded + ToolError: If the task fails, is cancelled, or the timeout is exceeded """ terminal_states = {"completed", "failed", "cancelled"} start_time = asyncio.get_event_loop().time() @@ -292,7 +338,7 @@ class ProwlerAPIClient(metaclass=SingletonMeta): # Check if we've exceeded the timeout current_time = asyncio.get_event_loop().time() if current_time >= max_time: - raise Exception( + raise ToolError( f"Task {task_id} polling timed out after {timeout} seconds. " f"The task may still be running. Try increasing the timeout or check task status manually." ) @@ -311,10 +357,14 @@ class ProwlerAPIClient(metaclass=SingletonMeta): logger.info(f"Task {task_id} completed successfully") return response elif state == "failed": - error_msg = task_attrs.get("error", "Unknown error") - raise Exception(f"Task {task_id} failed: {error_msg}") + # The task's own failure text is an upstream body: a celery + # traceback, a provider message. Log it, never relay it. + logger.error( + f"Task {task_id} failed: {task_attrs.get('error', 'no error reported')}" + ) + raise ToolError(f"Task {task_id} failed.") elif state == "cancelled": - raise Exception(f"Task {task_id} was cancelled") + raise ToolError(f"Task {task_id} was cancelled") # Wait before next poll await asyncio.sleep(poll_interval) @@ -330,12 +380,12 @@ class ProwlerAPIClient(metaclass=SingletonMeta): Parsed datetime object Raises: - ValueError: If date format is invalid + InvalidArgument: If date format is invalid """ try: return datetime.strptime(date_str, "%Y-%m-%d") except ValueError: - raise ValueError( + raise InvalidArgument( f"Invalid date format for {param_name}. Expected YYYY-MM-DD (e.g., '2025-01-15'), got '{date_str}'. " f"Full date required - partial dates like '2025' or '2025-01' are not accepted." ) @@ -347,10 +397,10 @@ class ProwlerAPIClient(metaclass=SingletonMeta): page_size: Page size to validate Raises: - ValueError: If page size is out of valid range (1-1000) + InvalidArgument: If page size is out of valid range (1-1000) """ if page_size < 1 or page_size > 1000: - raise ValueError( + raise InvalidArgument( f"Invalid page_size: {page_size}. Must be between 1 and 1000 (inclusive)." ) @@ -373,7 +423,7 @@ class ProwlerAPIClient(metaclass=SingletonMeta): None if no dates provided, otherwise tuple of (date_from, date_to) as strings Raises: - ValueError: If date range exceeds max_days or date format is invalid + InvalidArgument: If date range exceeds max_days or date format is invalid """ if not date_from and not date_to: return None @@ -394,7 +444,7 @@ class ProwlerAPIClient(metaclass=SingletonMeta): # Validate that date_from is before or equal to date_to if from_date > to_date: - raise ValueError( + raise InvalidArgument( f"Invalid date range: date_from must be before or equal to date_to. " f"Got date_from='{from_date.date()}' and date_to='{to_date.date()}'. " f"Please swap the dates or use the correct order." @@ -403,7 +453,7 @@ class ProwlerAPIClient(metaclass=SingletonMeta): # Validate range doesn't exceed max_days delta: int = (to_date - from_date).days + 1 if delta > max_days: - raise ValueError( + raise InvalidArgument( f"Date range cannot exceed {max_days} days. " f"Requested range: {from_date.date()} to {to_date.date()} ({delta} days)" ) 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 ba6f2a12e7..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,18 +2,28 @@ 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): """Search result model.""" path: str = Field(description="Document path") - title: str = Field(description="Document title") - url: str = Field(description="Documentation URL") - highlights: list[str] = Field( - description="Highlighted content snippets showing query matches with tags", + title: str = Field(description="Title of the page the match is on") + section: str = Field( + description="Heading of the section the match is in", default="" + ) + breadcrumbs: list[str] = Field( + description="Where the page sits in the documentation, from the top-level group down to the page itself", default_factory=list, ) + url: str = Field( + description="Documentation URL, anchored at the matching section when it has an anchor" + ) + excerpt: str = Field( + description="Text of the matching section, which is a part of the page and not the whole of it", + default="", + ) score: float = Field( description="Relevance score for the search result", default=0.0 ) @@ -24,12 +34,10 @@ class ProwlerDocsSearchEngine: def __init__(self): """Initialize the search engine.""" - self.api_base_url = ( - "https://api.mintlifytrieve.com/api/chunk_group/group_oriented_autocomplete" - ) - self.dataset_id = "0096ba11-3f72-463b-9d95-b788495ac392" - self.api_key = "tr-T6JLeTkFXeNbNPyhijtI9XhIncydQQ3O" - self.docs_base_url = "https://prowler.mintlify.app" + # The endpoint docs.prowler.com itself calls, with the site's Mintlify + # project name as the last segment. + self.api_base_url = "https://leaves.mintlify.com/api/search/prowler" + self.docs_base_url = "https://docs.prowler.com" # HTTP client for Mintlify API self.mintlify_client = httpx.Client( @@ -38,9 +46,6 @@ class ProwlerDocsSearchEngine: "Content-Type": "application/json", "Accept": "application/json", "User-Agent": f"prowler-mcp-server/{__version__}", - "TR-Dataset": self.dataset_id, - "Authorization": self.api_key, - "X-API-Version": "V2", }, ) @@ -54,117 +59,95 @@ 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 - page_size: Maximum number of results to return + page_size: Maximum number of results to return. The API decides how + many matches it answers with and takes no size of its own, so + this only trims the list it returned. 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: - # Construct request body - payload = { - "query": query, - "search_type": "fulltext", - "extend_results": True, - "highlight_options": { - "highlight_window": 10, - "highlight_max_num": 1, - "highlight_max_length": 2, - "highlight_strategy": "exactmatch", - "highlight_delimiters": ["?", ",", ".", "!", "\n"], - }, - "score_threshold": 0.2, - "filters": {"must_not": [{"field": "tag_set", "match": ["code"]}]}, - "page_size": page_size, - "group_size": 3, - } + # 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) - # Make request to Mintlify API - response = self.mintlify_client.post( - self.api_base_url, - json=payload, - ) - response.raise_for_status() - data = response.json() + # 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 result in data.get("results", []): - group = result.get("group", {}) - chunks = result.get("chunks", []) + # 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 - # Get document path and title from group - doc_path = group.get("name", "") - group_title = group.get("name", "").replace("/", " / ").title() + # 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}" - # If chunks exist, use the first chunk's title from metadata - title = group_title - if chunks: - first_chunk = chunks[0].get("chunk", {}) - metadata = first_chunk.get("metadata", {}) - title = metadata.get("title", group_title) - - # Construct full URL to docs - full_url = f"{self.docs_base_url}/{doc_path}" - - # Extract highlights and scores from chunks - highlights = [] - max_score = 0.0 - for chunk_data in chunks: - chunk_highlights = chunk_data.get("highlights", []) - highlights.extend(chunk_highlights) - # Track the highest score among all chunks in this group - chunk_score = chunk_data.get("score", 0.0) - max_score = max(max_score, chunk_score) - - results.append( - SearchResult( - path=doc_path, - title=title, - url=full_url, - highlights=highlights, - score=max_score, - ) + 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 7cd8825e4b..1121d7c419 100644 --- a/mcp_server/prowler_mcp_server/prowler_documentation/server.py +++ b/mcp_server/prowler_mcp_server/prowler_documentation/server.py @@ -1,25 +1,29 @@ 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 to return. It must be between 1 and 20.", - gt=1, - lt=20, + description="Number of top results to return. It must be between 1 and 20.", + ge=1, + le=20, ), ) -> list[dict[str, Any]]: """Search in Prowler documentation. @@ -27,18 +31,19 @@ def search( This tool searches through the official Prowler documentation to find relevant information about everything related to Prowler. - Uses fulltext search to find the most relevant documentation pages - based on your query. + A result is one section of a documentation page, not the page itself: its + 'excerpt' is that section alone. Read the whole page with + `prowler_docs_get_document`, passing the result's 'path'. Returns: - List of search results with highlights showing matched terms (in tags) + List of matching documentation sections, most relevant first """ return prowler_docs_search_engine.search(term, page_size) # type: ignore In the hint we cannot put SearchResult type because JSON API MCP Generator cannot handle Pydantic models yet @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]: @@ -52,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/prowler_mcp_server/server.py b/mcp_server/prowler_mcp_server/server.py index 1b3271be38..3815047d55 100644 --- a/mcp_server/prowler_mcp_server/server.py +++ b/mcp_server/prowler_mcp_server/server.py @@ -2,9 +2,17 @@ from fastmcp import FastMCP from starlette.responses import JSONResponse from prowler_mcp_server import __version__ +from prowler_mcp_server.lib.errors import SharedFailureMiddleware from prowler_mcp_server.lib.logger import logger -prowler_mcp_server = FastMCP("prowler-mcp-server") +# `mask_error_details` keeps an unhandled failure from relaying text this server +# does not control. It is set on every sub-server as well, because it does not +# reach mounted children -- FastMCP warns about exactly that at mount time. +prowler_mcp_server = FastMCP("prowler-mcp-server", mask_error_details=True) + +# Middleware, unlike masking, does reach mounted children, so the classifier that +# gives the masked failures a sentence back is wired here once. +prowler_mcp_server.add_middleware(SharedFailureMiddleware()) def setup_main_server(): diff --git a/mcp_server/pyproject.toml b/mcp_server/pyproject.toml index 30016bf5cb..e6ae26fe32 100644 --- a/mcp_server/pyproject.toml +++ b/mcp_server/pyproject.toml @@ -81,5 +81,6 @@ constraint-dependencies = [ "cryptography==50.0.0", "joserfc==1.6.8", "mcp==1.28.1", + "pyjwt==2.14.0", "python-multipart==0.0.30" ] diff --git a/mcp_server/tests/lib/__init__.py b/mcp_server/tests/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/mcp_server/tests/lib/test_errors.py b/mcp_server/tests/lib/test_errors.py new file mode 100644 index 0000000000..a682034bc8 --- /dev/null +++ b/mcp_server/tests/lib/test_errors.py @@ -0,0 +1,307 @@ +"""Tests for the shared failure classifier. + +Two properties are pinned here: a failed tool call answers with ``isError: true`` +rather than a result object the client reads as a success, and the only text that +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 ( + CredentialError, + InvalidArgument, + UpstreamInvalidResponse, + _describe_failure, + parse_json_response, +) +from prowler_mcp_server.prowler_app.utils.api_client import ( + ProwlerAPIError, + ProwlerAPIInvalidResponse, + ProwlerAPIUnreachable, +) +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 + + +@pytest.mark.parametrize( + ("status", "expected"), + [ + (401, "missing, malformed or expired"), + (403, "prowler_get_current_user"), + (429, "rate limiting"), + (503, "failed on Prowler's side"), + ], + ids=["unauthorized", "forbidden", "rate-limited", "unavailable"], +) +def test_the_failures_every_authenticated_tool_shares_get_one_message(status, expected): + """These four mean the same thing whichever tool hit them.""" + message = _describe_failure(ProwlerAPIError("failed", status)) + + assert expected in message + + +def test_a_rejection_relays_the_apis_own_reason(): + """`errors[].detail` is written by Prowler for a caller, so it is ours to relay.""" + message = _describe_failure( + ProwlerAPIError("failed", 400, detail="scan_id is not a valid UUID.") + ) + + assert "scan_id is not a valid UUID." in message + + +def test_a_rejection_with_no_trustworthy_reason_says_so_instead_of_guessing(): + """A body that was not JSON:API leaves `detail` unset, and it stays unrelayed.""" + message = _describe_failure(ProwlerAPIError("failed", 400)) + + assert "gave no reason" in message + + +def test_a_request_that_got_no_answer_says_the_outcome_is_unknown(): + """An unanswered write may well have landed, so repeating it can duplicate it.""" + message = _describe_failure(ProwlerAPIUnreachable("POST /providers got no answer")) + + assert "could not be reached" in message + assert "unknown" in message + + +def test_an_unreadable_api_answer_is_not_blamed_on_the_arguments(): + """The same `JSONDecodeError` means opposite things on the two sides.""" + message = _describe_failure( + ProwlerAPIInvalidResponse( + "GET /findings answered 200 with a body that is not JSON" + ) + ) + + assert "could not read" in message + assert "argument" not in message + + +def test_an_unreadable_api_answer_is_never_called_safe_to_repeat(): + """`post`, `patch` and `delete` reach this too, and a write may have landed.""" + message = _describe_failure( + ProwlerAPIInvalidResponse( + "POST /providers answered 201 with a body that is not JSON" + ) + ) + + assert "unknown" in message + 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( + InvalidArgument("page_size must be between 1 and 1000.") + ) + + 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.""" + + class Credentials(BaseModel): + api_token: int + + with pytest.raises(ValidationError) as raised: + Credentials(api_token="hunter2-the-real-secret") + + message = _describe_failure(raised.value) + + assert "api_token" in message + assert "hunter2-the-real-secret" not in message + + +def test_unparseable_json_is_reported_without_quoting_the_input(): + """`JSONDecodeError` is a ValueError whose message quotes what it was given.""" + with pytest.raises(json.JSONDecodeError) as raised: + json.loads('{"api_token": "hunter2-the-real-secret"') + + message = _describe_failure(raised.value) + + assert "could not be parsed" in message + assert "hunter2" not in message + + +def test_an_unrecognised_failure_is_left_masked(): + """Saying nothing is the safe default; the alternative is relaying anything.""" + assert ( + _describe_failure(RuntimeError("connection pool exhausted at 10.0.0.4:5432")) + is None + ) + + +# --------------------------------------------------------- through the server + + +async def test_a_failing_tool_answers_with_is_error_not_a_result( + mcp_root_server, mock_api_client, mock_router +): + """An error dict would arrive as `isError: false` and read as a success.""" + mock_router.add("GET", LATEST, status=403, json=jsonapi_error(403, "Denied.")) + + async with Client(mcp_root_server) as client: + result = await client.call_tool_mcp("prowler_search_security_findings", {}) + + assert result.isError is True + assert result.structuredContent is None + assert "prowler_get_current_user" in result.content[0].text + + +async def test_an_upstream_body_never_reaches_the_agent( + mcp_root_server, mock_api_client, mock_router +): + """A body this server did not write is logged and replaced, never relayed.""" + mock_router.add( + "GET", + LATEST, + status=500, + text="Traceback: psycopg2 could not connect to internal-db:5432", + ) + + async with Client(mcp_root_server) as client: + result = await client.call_tool_mcp("prowler_search_security_findings", {}) + + assert result.isError is True + assert "internal-db" not in result.content[0].text + assert "failed on Prowler's side" in result.content[0].text + + +async def test_a_bad_argument_is_rejected_before_any_request_goes_out( + mcp_root_server, mock_api_client, mock_router +): + """Local validation saves a round trip, and its message is safe to repeat.""" + async with Client(mcp_root_server) as client: + result = await client.call_tool_mcp( + "prowler_search_security_findings", {"page_size": 5000} + ) + + assert result.isError is True + assert "Must be between 1 and 1000" in result.content[0].text + assert mock_router.requests == [] + + +async def test_an_unreadable_api_answer_does_not_reach_the_agent_as_a_bad_argument( + mcp_root_server, mock_api_client, mock_router +): + """Told apart by type, so the agent is not sent to fix an argument that is fine.""" + mock_router.add("GET", LATEST, text="gateway timeout") + + async with Client(mcp_root_server) as client: + result = await client.call_tool_mcp("prowler_search_security_findings", {}) + + 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_api_client.py b/mcp_server/tests/prowler_app/utils/test_api_client.py index 41aceedcab..f3ef313bf0 100644 --- a/mcp_server/tests/prowler_app/utils/test_api_client.py +++ b/mcp_server/tests/prowler_app/utils/test_api_client.py @@ -7,9 +7,19 @@ query encoding and header assembly stay covered. import httpx import pytest +from fastmcp.exceptions import ToolError -from prowler_mcp_server.prowler_app.utils.api_client import ProwlerAPIError -from tests.helpers.jsonapi import jsonapi_collection, jsonapi_error, jsonapi_resource +from prowler_mcp_server.prowler_app.utils.api_client import ( + ProwlerAPIError, + ProwlerAPIInvalidResponse, + ProwlerAPIUnreachable, +) +from tests.helpers.jsonapi import ( + jsonapi_collection, + jsonapi_error, + jsonapi_resource, + task_document, +) from tests.helpers.tokens import FAKE_API_KEY @@ -46,11 +56,7 @@ async def test_get_forwards_query_parameters(mock_api_client, mock_router): async def test_error_response_surfaces_the_jsonapi_detail(mock_api_client, mock_router): - """A failed request is raised with the API's own `errors[].detail` message. - - Tools relay this text straight to the model, so losing it turns an actionable - error into an opaque one. - """ + """A failed request carries the API's own `errors[].detail`.""" mock_router.add( "GET", "/api/v1/findings/nope", @@ -64,26 +70,112 @@ async def test_error_response_surfaces_the_jsonapi_detail(mock_api_client, mock_ await mock_api_client.get("/findings/nope") assert raised.value.status_code == 404 + assert raised.value.detail == "Not found." + + +async def test_a_body_that_is_not_jsonapi_is_never_repeated( + mock_api_client, mock_router +): + """A body that is not JSON:API leaves `detail` unset, so nothing is relayed.""" + mock_router.add( + "GET", + "/api/v1/findings", + status=502, + text="Traceback: secret-internal-host:5432", + ) + + with pytest.raises(ProwlerAPIError) as raised: + await mock_api_client.get("/findings") + + assert raised.value.detail is None + assert "secret-internal-host" not in str(raised.value) + + +async def test_a_server_error_detail_never_reaches_the_exception_text( + mock_api_client, mock_router +): + """On a 5xx `errors[].detail` carries the failure, not a reason for a caller. + + Tools that answer with `str(exc)` bypass the shared classifier, so the check + is on the exception itself rather than on the message the classifier builds. + """ + mock_router.add( + "GET", + "/api/v1/findings", + status=500, + json=jsonapi_error( + 500, "OperationalError: could not connect to secret-internal-host:5432" + ), + ) + + with pytest.raises(ProwlerAPIError) as raised: + await mock_api_client.get("/findings") + + assert raised.value.detail is None + assert "secret-internal-host" not in str(raised.value) + + +async def test_an_unreadable_body_is_not_an_argument_failure( + mock_api_client, mock_router +): + """A `JSONDecodeError` here is the API's doing, and must not read as ours.""" + mock_router.add("GET", "/api/v1/findings", text="gateway timeout") + + with pytest.raises(ProwlerAPIInvalidResponse) as raised: + await mock_api_client.get("/findings") + + assert not isinstance(raised.value, ValueError) + assert "gateway timeout" not in str(raised.value) + + +async def test_a_mutation_with_an_unreadable_answer_still_raises( + mock_api_client, mock_router +): + """`post` shares the parse, so a write cannot answer with an unread body.""" + mock_router.add( + "POST", "/api/v1/providers", status=201, text="accepted" + ) + + with pytest.raises(ProwlerAPIInvalidResponse): + await mock_api_client.post("/providers", json_data={"data": {}}) async def test_a_request_that_got_no_answer_is_not_an_api_error( mock_api_client, mock_router ): - """`ProwlerAPIError` means the API answered, and callers act on that. - - A write tool tells a rejected request -- which changed nothing -- from one - that may have been processed by the type of the failure, so a timeout must - not be dressed up as a rejection. - """ + """`ProwlerAPIError` means the API answered, so a timeout must not use it.""" def timed_out(request): raise httpx.ReadTimeout("Timed out reading the response", request=request) mock_router.add_handler("GET", "/api/v1/findings", timed_out) - with pytest.raises(httpx.ReadTimeout): + with pytest.raises(ProwlerAPIUnreachable) as raised: await mock_api_client.get("/findings") + assert not isinstance(raised.value, ProwlerAPIError) + + +async def test_a_failed_task_does_not_relay_its_own_error_text( + mock_api_client, mock_router +): + """A failed task's error is an upstream body, so polling must not repeat it.""" + mock_router.add( + "GET", + "/api/v1/tasks/t1", + json=task_document( + "t1", + "failed", + error="Traceback: connection to secret-internal-host:5432 refused", + ), + ) + + with pytest.raises(ToolError) as raised: + await mock_api_client.poll_task_until_complete(task_id="t1", timeout=5) + + assert "secret-internal-host" not in str(raised.value) + assert "t1" in str(raised.value) + def test_build_filter_params_normalises_types_for_the_api(mock_api_client): """Booleans become lowercase strings, sequences become CSV, `None` is dropped.""" 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 new file mode 100644 index 0000000000..67ee57decf --- /dev/null +++ b/mcp_server/tests/prowler_documentation/test_server.py @@ -0,0 +1,174 @@ +"""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. 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 + +from fastmcp import Client + +SEARCH = "/api/search/prowler" +DOC = "/getting-started/installation.md" + + +def search_match( + path: str = "getting-started/installation", + *, + header: str = "Requirements", + breadcrumbs: tuple[str, ...] = ("Get Started", "Installation"), + anchor: str | None = "requirements", + score: float = 4.9, +): + """One match as Mintlify answers with it: a section of a page, not the page.""" + return { + "page": path, + "header": header, + "content": "Prowler runs on Python 3.9 or later.", + "metadata": { + "title": header, + "breadcrumbs": list(breadcrumbs), + "icon": "", + "hash": anchor, + "openapi": "", + }, + "score": score, + } + + +def stub_search_hit(docs_router, *matches): + """Serve the search endpoint, with one default match when none are given.""" + if not matches: + matches = (search_match(),) + return docs_router.add("POST", SEARCH, json={"results": list(matches)}) + + +async def test_search_returns_the_matching_sections(mcp_root_server, docs_router): + """Every field of a result, since the shape of one changed with the endpoint.""" + stub_search_hit(docs_router) + + async with Client(mcp_root_server) as client: + result = await client.call_tool("prowler_docs_search", {"term": "install"}) + + match = result.data[0] + assert match["path"] == "getting-started/installation" + # The page's title, so a result reads as more than the heading it matched. + assert match["title"] == "Installation" + assert match["section"] == "Requirements" + assert match["breadcrumbs"] == ["Get Started", "Installation"] + assert match["excerpt"] == "Prowler runs on Python 3.9 or later." + assert match["score"] == 4.9 + # Anchored: a match is a section, and the page it is on can be a long one. + assert match["url"] == ( + "https://docs.prowler.com/getting-started/installation#requirements" + ) + + +async def test_the_search_query_is_sent_as_the_api_expects_it( + mcp_root_server, docs_router +): + """The endpoint takes a POST body, not the payload the old one took.""" + stub_search_hit(docs_router) + + async with Client(mcp_root_server) as client: + await client.call_tool("prowler_docs_search", {"term": "install"}) + + request = docs_router.request_for("POST", SEARCH) + assert json.loads(request.content) == {"query": "install", "filters": {}} + + +async def test_a_section_with_no_anchor_links_to_the_page(mcp_root_server, docs_router): + """The API sends "" for a page's first section and null for pages without anchors.""" + stub_search_hit( + docs_router, + search_match(anchor=""), + search_match(path="getting-started/requirements", anchor=None), + ) + + async with Client(mcp_root_server) as client: + result = await client.call_tool("prowler_docs_search", {"term": "install"}) + + assert result.data[0]["url"] == ( + "https://docs.prowler.com/getting-started/installation" + ) + assert result.data[1]["url"] == ( + "https://docs.prowler.com/getting-started/requirements" + ) + + +async def test_page_size_caps_a_response_the_api_did_not_size( + mcp_root_server, docs_router +): + """The endpoint takes no size argument, so the cap has to be applied here.""" + stub_search_hit(docs_router, *(search_match() for _ in range(6))) + + async with Client(mcp_root_server) as client: + result = await client.call_tool( + "prowler_docs_search", {"term": "install", "page_size": 2} + ) + + 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/mcp_server/uv.lock b/mcp_server/uv.lock index 849847e3c8..e529c9c3c4 100644 --- a/mcp_server/uv.lock +++ b/mcp_server/uv.lock @@ -13,6 +13,7 @@ constraints = [ { name = "cryptography", specifier = "==50.0.0" }, { name = "joserfc", specifier = "==1.6.8" }, { name = "mcp", specifier = "==1.28.1" }, + { name = "pyjwt", specifier = "==2.14.0" }, { name = "python-multipart", specifier = "==0.0.30" }, ] @@ -39,15 +40,15 @@ wheels = [ [[package]] name = "anyio" -version = "4.13.0" +version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] [[package]] @@ -977,11 +978,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.13.0" +version = "2.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" }, ] [package.optional-dependencies] @@ -1292,8 +1293,8 @@ name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography" }, - { name = "jeepney" }, + { name = "cryptography", marker = "sys_platform != 'win32'" }, + { name = "jeepney", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ diff --git a/permissions/prowler-additions-policy.json b/permissions/prowler-additions-policy.json index 13aff68168..be17979d2e 100644 --- a/permissions/prowler-additions-policy.json +++ b/permissions/prowler-additions-policy.json @@ -31,17 +31,21 @@ "ec2:GetInstanceMetadataDefaults", "ecr:Describe*", "ecr:GetRegistryScanningConfiguration", + "ecr:BatchGetImage", + "ecr:GetDownloadUrlForLayer", "elasticfilesystem:DescribeBackupPolicy", "glue:GetConnections", "glue:GetSecurityConfiguration*", "glue:SearchTables", "glue:GetMLTransforms", + "inspector2:BatchGetFindingDetails", "lambda:GetFunction*", "lambda:GetLayerVersion", "logs:FilterLogEvents", "lightsail:GetRelationalDatabases", "macie2:GetMacieSession", "macie2:GetAutomatedDiscoveryConfiguration", + "rolesanywhere:ListProfiles", "rolesanywhere:ListTagsForResource", "rolesanywhere:ListTrustAnchors", "s3:GetAccountPublicAccessBlock", diff --git a/permissions/templates/cloudformation/prowler-scan-role.yml b/permissions/templates/cloudformation/prowler-scan-role.yml index c9eee71950..d31dea9846 100644 --- a/permissions/templates/cloudformation/prowler-scan-role.yml +++ b/permissions/templates/cloudformation/prowler-scan-role.yml @@ -203,16 +203,20 @@ Resources: - "ec2:GetInstanceMetadataDefaults" - "ecr:Describe*" - "ecr:GetRegistryScanningConfiguration" + - "ecr:BatchGetImage" + - "ecr:GetDownloadUrlForLayer" - "elasticfilesystem:DescribeBackupPolicy" - "glue:GetConnections" - "glue:GetSecurityConfiguration*" - "glue:SearchTables" - "glue:GetMLTransforms" + - "inspector2:BatchGetFindingDetails" - "lambda:GetFunction*" - "logs:FilterLogEvents" - "lightsail:GetRelationalDatabases" - "macie2:GetMacieSession" - "macie2:GetAutomatedDiscoveryConfiguration" + - "rolesanywhere:ListProfiles" - "rolesanywhere:ListTagsForResource" - "rolesanywhere:ListTrustAnchors" - "s3:GetAccountPublicAccessBlock" @@ -469,11 +473,14 @@ Resources: - "ec2:GetInstanceMetadataDefaults" - "ecr:Describe*" - "ecr:GetRegistryScanningConfiguration" + - "ecr:BatchGetImage" + - "ecr:GetDownloadUrlForLayer" - "elasticfilesystem:DescribeBackupPolicy" - "glue:GetConnections" - "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 c019e8d087..d424abdd01 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -4,6 +4,194 @@ All notable changes to the **Prowler SDK** are documented in this file. +## [5.44.0] (Prowler v5.44.0) + +### 🚀 Added + +- `PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS` environment variable to set the Boto3 retries for deployments without CLI flags [(#12870)](https://github.com/prowler-cloud/prowler/pull/12870) + +### 🐞 Fixed + +- STS calls after role assumption use the answering region, avoiding a second wait for an unreachable partition region [(#12870)](https://github.com/prowler-cloud/prowler/pull/12870) + +### 🔐 Security + +- Pass the E2E AWS credentials to the UI E2E workflow through environment variables instead of template expansion [(#12864)](https://github.com/prowler-cloud/prowler/pull/12864) + +--- + +## [5.43.0] (Prowler v5.43.0) + +### 🚀 Added + +- `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 [(#11701)](https://github.com/prowler-cloud/prowler/pull/11701) +- `smn_topic_subscriptions` check for Huawei Cloud provider: SMN topics have at least one subscription configured [(#12186)](https://github.com/prowler-cloud/prowler/pull/12186) +- `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 [(#12808)](https://github.com/prowler-cloud/prowler/pull/12808) +- `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 [(#12808)](https://github.com/prowler-cloud/prowler/pull/12808) + +### 🔄 Changed + +- FedRAMP 20x Phase One pilot frameworks `fedramp_20x_ksi_low_aws`, `fedramp_20x_ksi_low_azure` and `fedramp_20x_ksi_low_gcp` replaced by `fedramp_20x_ksi_2026` [(#12855)](https://github.com/prowler-cloud/prowler/pull/12855) + +### 🐞 Fixed + +- `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 [(#12700)](https://github.com/prowler-cloud/prowler/pull/12700) +- 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 [(#12799)](https://github.com/prowler-cloud/prowler/pull/12799) +- `KeyError` in M365 Defender malware, anti-phishing and inbound anti-spam checks when the tenant has Standard or Strict preset security policies [(#12809)](https://github.com/prowler-cloud/prowler/pull/12809) +- 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` [(#12813)](https://github.com/prowler-cloud/prowler/pull/12813) + +### 🔐 Security + +- `libsqlite3-0`, `gzip`, `perl-base`, `libssh2-1t64` and `libpcre2-8-0` upgraded in the SDK container image, patching nine high Debian CVEs [(#12804)](https://github.com/prowler-cloud/prowler/pull/12804) +- 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 [(#12811)](https://github.com/prowler-cloud/prowler/pull/12811) +- Bumped `anyio` to 4.14.2 to resolve CVE-2026-63374 [(#12848)](https://github.com/prowler-cloud/prowler/pull/12848) + +--- + +## [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 + +- NCSC Cyber Essentials 3.3 compliance framework with Azure provider coverage across the five Cyber Essentials themes [(#11588)](https://github.com/prowler-cloud/prowler/pull/11588) +- `oss_bucket_versioning_enabled` check for Alibaba Cloud provider, verifying that OSS buckets have versioning enabled to allow recovery from accidental or malicious object overwrite and deletion [(#11913)](https://github.com/prowler-cloud/prowler/pull/11913) +- `defender_domain_dmarc_records_published` checks that every Exchange Online domain publishes a DMARC record with an enforcing policy (`p=quarantine` or `p=reject`) [(#11936)](https://github.com/prowler-cloud/prowler/pull/11936) +- `ske_cluster_no_public_endpoint` check for STACKIT provider, flagging SKE clusters whose Kubernetes API endpoint is reachable from the whole internet because the ACL extension is disabled or its allowed CIDR list contains `0.0.0.0/0` or `::/0` [(#11943)](https://github.com/prowler-cloud/prowler/pull/11943) +- `oss_bucket_server_side_encryption_enabled` check for Alibaba Cloud provider, verifying that OSS buckets have a default server-side encryption rule (AES256 or KMS) [(#11981)](https://github.com/prowler-cloud/prowler/pull/11981) +- `organization_default_workflow_permissions_read_only` check for GitHub provider, verifying that organizations grant GitHub Actions workflows a read-only default `GITHUB_TOKEN` [(#12122)](https://github.com/prowler-cloud/prowler/pull/12122) +- `ecr_repository_image_no_secrets` check for AWS provider, scanning the latest ECR repository image's configuration and filesystem layers for hardcoded secrets [(#12123)](https://github.com/prowler-cloud/prowler/pull/12123) +- `repository_default_workflow_permissions_read_only` check for GitHub provider, verifying that repositories grant GitHub Actions workflows a read-only default `GITHUB_TOKEN` [(#12143)](https://github.com/prowler-cloud/prowler/pull/12143) +- `vpc_security_group_open_egress` check for Huawei Cloud provider: VPC security groups do not allow open egress to the internet [(#12209)](https://github.com/prowler-cloud/prowler/pull/12209) +- `organization_actions_pull_request_approval_disabled` check for GitHub provider, verifying that organizations prevent GitHub Actions from creating and approving pull requests [(#12394)](https://github.com/prowler-cloud/prowler/pull/12394) +- Add the `iam_workload_identity_pool_provider_attribute_condition` check to flag GCP Workload Identity Federation providers that trust a multi-tenant issuer without an attribute condition restricting which external identities can impersonate federated principals [(#12416)](https://github.com/prowler-cloud/prowler/pull/12416) +- Add the `rolesanywhere_profile_restricts_session_permissions` check to flag AWS IAM Roles Anywhere profiles that reference an administrative role without scoping down the vended session with a session policy or managed policies [(#12416)](https://github.com/prowler-cloud/prowler/pull/12416) +- `bedrock_guardrail_contextual_grounding_filter_enabled`, `bedrock_custom_model_encrypted_with_cmk`, `bedrock_knowledge_base_encrypted_with_cmk` and `bedrock_agent_role_not_shared_across_agents` are four new AWS Bedrock checks covering guardrail contextual grounding, custom model encryption, knowledge-base data-source encryption, and non-shared agent execution roles. [(#12459)](https://github.com/prowler-cloud/prowler/pull/12459) +- `Cluster` column in Kubernetes CIS, ISO27001, Prowler ThreatScore, and universal compliance outputs, populated with the resolved cluster name so multi-cluster scans can be told apart in the output [(#12506)](https://github.com/prowler-cloud/prowler/pull/12506) + +### 🐞 Fixed + +- Kubernetes `kubelet` checks no longer disappear from the scan with `TypeError: 'NoneType' object is not iterable` when a `kubelet-config` ConfigMap is broken: one with malformed YAML is logged and skipped while the valid ones are still evaluated, one without kubelet data is evaluated with an empty configuration instead of crashing the checks, and the `apiserver`, `controllermanager`, `etcd` and `scheduler` pod gatherers now always return a list [(#12225)](https://github.com/prowler-cloud/prowler/pull/12225) +- IaC provider now raises typed `IacBaseException` errors (repository clone, Trivy missing, scan and output processing failures) instead of calling `sys.exit(1)`; the CLI still stops with the logged message, and API scans fail as regular task errors instead of a `SystemExit` escaping the worker [(#12227)](https://github.com/prowler-cloud/prowler/pull/12227) +- CLI Slack integration (`--slack`) no longer fails when a scan produces no findings: the pass and fail percentages are guarded against a `findings_count` of 0, which previously raised `ZeroDivisionError` and sent `blocks=None` to Slack instead of the summary [(#12229)](https://github.com/prowler-cloud/prowler/pull/12229) +- AWS FSBP compliance mapping for `IAM.9` and `EKS.1` referenced missing/renamed checks; both now point to their real, existing check IDs [(#12372)](https://github.com/prowler-cloud/prowler/pull/12372) +- `ec2_securitygroup_not_used` no longer reports a false positive for security groups attached only to an AWS Batch compute environment, which holds them in configuration without creating a network interface while scaled down to zero instances [(#12458)](https://github.com/prowler-cloud/prowler/pull/12458) +- Bedrock Agent ARNs are now built from the audited partition instead of a hardcoded `arn:aws:`, so findings in GovCloud and China carry a resolvable ARN and `--resource-arn` scoping matches agents in those partitions. [(#12459)](https://github.com/prowler-cloud/prowler/pull/12459) +- `push-to-cloud` now validates Private Cloud TLS certificates with the operating system trust store without changing provider HTTP clients [(#12485)](https://github.com/prowler-cloud/prowler/pull/12485) +- `prowler.compliance.universal` entry point directories are resolved through a single shared helper and deduplicated by resolved path, so a directory reached through two entry points is parsed once and a package that fails to import no longer hides the rest [(#12536)](https://github.com/prowler-cloud/prowler/pull/12536) +- OSS bucket logging, versioning, default encryption and ACL configurations are now read correctly from the Alibaba Cloud SDK, so `oss_bucket_logging_enabled`, `oss_bucket_versioning_enabled`, `oss_bucket_server_side_encryption_enabled` and `oss_bucket_not_publicly_accessible` no longer report every bucket as unconfigured [(#12546)](https://github.com/prowler-cloud/prowler/pull/12546) + +### 🔐 Security + +- `openssl`, `libssl3t64` and `openssl-provider-legacy` upgraded to 3.5.7-1~deb13u2 in the SDK container image, patching ten high OpenSSL CVEs [(#12549)](https://github.com/prowler-cloud/prowler/pull/12549) + +--- + +## [5.39.1] (Prowler v5.39.1) + +### 🐞 Fixed + +- Bump alibabacloud-tea-openapi to 0.4.6, oci to 2.184.1 and pyopenssl to 26.4.0 so the published wheel installs with cryptography 50.0.0; 5.38.0 declared cryptography 50.0.0 while those packages capped it below 50, so pip could not install it and `pip install prowler` silently fell back to 5.37.1 [(#12477)](https://github.com/prowler-cloud/prowler/pull/12477) +- Pin zstd to 1.5.7.2; 1.5.7.3 was yanked from PyPI as not thread safe [(#12477)](https://github.com/prowler-cloud/prowler/pull/12477) +- ECS task-definition checks no longer report PASS when `DescribeTaskDefinition` fails before container evidence is gathered [(#12478)](https://github.com/prowler-cloud/prowler/pull/12478) +- `ses_identity_not_publicly_accessible` now evaluates every SES identity authorization policy and marks mixed public Allow and Deny statements for manual review [(#12480)](https://github.com/prowler-cloud/prowler/pull/12480) + +### 🔐 Security + +- Trivy from v0.72.0 to v0.73.0 in the container image, fixing HIGH CVE-2026-46600 in the bundled `golang.org/x/net` [(#12445)](https://github.com/prowler-cloud/prowler/pull/12445) +- Trivy v0.74.0 and Debian util-linux 2.41.5-0+deb13u1 in the SDK container image, patching Go standard library vulnerabilities and CVE-2026-53615 [(#12470)](https://github.com/prowler-cloud/prowler/pull/12470) + +--- + +## [5.39.0] (Prowler v5.39.0) + +### 🚀 Added + +- `batch_job_definition_no_secrets` check for AWS provider, scanning Batch job definition environment variables and command parameters for hardcoded secrets [(#12117)](https://github.com/prowler-cloud/prowler/pull/12117) +- 7 M365 Entra checks covering CIS Microsoft 365 Foundations Benchmark v7.0.0 password protection, default user permissions, and guest invitation domain restrictions [(#12153)](https://github.com/prowler-cloud/prowler/pull/12153) +- 7 M365 entra checks covering CIS Microsoft 365 Foundations Benchmark v7.0.0 Conditional Access (5.2.2.x) and idle session timeout controls [(#12154)](https://github.com/prowler-cloud/prowler/pull/12154) +- `entra_authentication_method_email_otp_disabled`, `entra_authentication_method_authenticator_show_context`, `entra_pim_global_administrator_approval_required`, `entra_pim_privileged_role_administrator_approval_required`, `entra_access_review_guest_users_configured` and `entra_access_review_privileged_roles_configured` checks for M365 provider covering CIS Microsoft 365 Foundations Benchmark v7.0.0 authentication method, PIM approval and access review controls [(#12155)](https://github.com/prowler-cloud/prowler/pull/12155) +- `awslambda_layer_no_secrets_in_content` check for AWS provider, scanning Lambda layer package content for hardcoded secrets [(#12233)](https://github.com/prowler-cloud/prowler/pull/12233) +- CMMC 2.0 universal compliance framework (`cmmc_2.0`) with the 149 official requirements from 32 CFR Part 170 — Level 1 (15, 48 CFR 52.204-21), Level 2 (110, NIST SP 800-171 Rev 2) and Level 3 (24, NIST SP 800-172) — with AWS, Azure, GCP, Alibaba Cloud, Oracle Cloud and M365 check mappings and config guardrails [(#12401)](https://github.com/prowler-cloud/prowler/pull/12401) + +### 🔄 Changed + +- GitHub `organization_repository_creation_limited` check now reports low severity for FAIL findings when repository creation is provably limited to private/internal visibility, instead of always reporting high [(#12164)](https://github.com/prowler-cloud/prowler/pull/12164) + +### 🔐 Security + +- HTML report header now HTML-escapes every provider identity field across all 23 providers, closing a stored XSS in the header block (Secur0, CWE-79) that was left unaddressed by the earlier finding-row fix in #12221 [(#12424)](https://github.com/prowler-cloud/prowler/pull/12424) + +--- + ## [5.38.0] (Prowler v5.38.0) ### 🚀 Added diff --git a/prowler/__main__.py b/prowler/__main__.py index 2d77068704..b9de47c07b 100644 --- a/prowler/__main__.py +++ b/prowler/__main__.py @@ -126,7 +126,10 @@ from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_m365 from prowler.lib.outputs.csv.csv import CSV from prowler.lib.outputs.finding import Finding from prowler.lib.outputs.html.html import HTML -from prowler.lib.outputs.ocsf.ingestion import send_ocsf_to_api +from prowler.lib.outputs.ocsf.ingestion import ( + SystemTrustStoreError, + send_ocsf_to_api, +) from prowler.lib.outputs.ocsf.ocsf import OCSF from prowler.lib.outputs.outputs import extract_findings_statistics, report from prowler.lib.outputs.sarif.sarif import SARIF @@ -145,6 +148,7 @@ from prowler.providers.gcp.models import GCPOutputOptions from prowler.providers.github.models import GithubOutputOptions from prowler.providers.googleworkspace.models import GoogleWorkspaceOutputOptions from prowler.providers.huaweicloud.models import HuaweiCloudOutputOptions +from prowler.providers.iac.exceptions.exceptions import IacBaseException from prowler.providers.iac.models import IACOutputOptions from prowler.providers.image.exceptions.exceptions import ImageBaseException from prowler.providers.image.models import ImageOutputOptions @@ -162,6 +166,75 @@ from prowler.providers.stackit.models import StackITOutputOptions from prowler.providers.vercel.models import VercelOutputOptions +def _send_ocsf_to_cloud(file_path: str) -> dict | None: + """Upload OCSF findings and report safe, actionable CLI failures.""" + try: + return send_ocsf_to_api(file_path) + except requests.exceptions.JSONDecodeError: + print( + f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: " + "the API returned an invalid JSON response. " + f"Scan results were saved to {file_path}{Style.RESET_ALL}" + ) + except ValueError: + print( + f"{Style.BRIGHT}{Fore.YELLOW}\nPush to Prowler Cloud skipped: no API key configured. " + "Set the PROWLER_CLOUD_API_KEY environment variable to enable it. " + f"Scan results were saved to {file_path}{Style.RESET_ALL}" + ) + except SystemTrustStoreError: + print( + f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: the operating system trust store " + "could not be initialized. Check the configured CA bundle paths, verify the host certificate " + "hostname, validity period, and certificate chain, and install the organization or TLS-intercepting " + "proxy CA in the operating system trust store. In containers, configure and update the container " + "system CA store. " + f"Scan results were saved to {file_path}{Style.RESET_ALL}" + ) + except requests.exceptions.SSLError: + print( + f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: TLS certificate validation failed. " + "Verify the hostname, validity period, and certificate chain, and install the organization or " + "TLS-intercepting proxy CA in the operating system trust store. In containers, configure and update " + "the container system CA store. " + f"Scan results were saved to {file_path}{Style.RESET_ALL}" + ) + except requests.ConnectionError: + print( + f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: could not reach the Prowler Cloud API at " + f"{cloud_api_base_url}. Check the URL and your network connection. " + f"Scan results were saved to {file_path}{Style.RESET_ALL}" + ) + except requests.HTTPError as http_err: + status_code = ( + http_err.response.status_code if http_err.response is not None else None + ) + if status_code == 402: + print( + f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: " + "this feature is only available with a Prowler Cloud subscription. " + f"Scan results were saved to {file_path}{Style.RESET_ALL}" + ) + elif status_code is None: + print( + f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: " + "the API request failed without a response status. " + f"Scan results were saved to {file_path}{Style.RESET_ALL}" + ) + else: + print( + f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: the API returned HTTP " + f"{status_code}. Verify your API key is valid and has the right permissions. " + f"Scan results were saved to {file_path}{Style.RESET_ALL}" + ) + except Exception: + print( + f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed unexpectedly. " + f"Scan results were saved to {file_path}{Style.RESET_ALL}" + ) + return None + + def prowler(): # Parse Arguments # Refactor(CLI) @@ -493,12 +566,16 @@ def prowler(): except ImageBaseException as error: logger.critical(f"{error}") sys.exit(1) + elif provider == "iac": + try: + findings = global_provider.run() + except IacBaseException as error: + logger.critical(f"{error}") + sys.exit(1) else: - # IAC and external tool-wrapper providers registered via entry - # points. Unexpected failures propagate to the outer except - # Exception backstop further down in this file — keeping the - # branch free of an Image-specific catch that would otherwise - # mislead plug-in authors reading this code. + # External tool-wrapper providers registered via entry points. + # Unexpected failures propagate to the outer except Exception + # backstop further down in this file. findings = global_provider.run() # Note: External tool providers don't support granular progress tracking since # they run external tools as a black box and return all findings at once. @@ -641,39 +718,8 @@ def prowler(): print( f"{Style.BRIGHT}\nPushing findings to Prowler Cloud, please wait...{Style.RESET_ALL}" ) - try: - response = send_ocsf_to_api(ocsf_output.file_path) - except ValueError: - print( - f"{Style.BRIGHT}{Fore.YELLOW}\nPush to Prowler Cloud skipped: no API key configured. " - "Set the PROWLER_CLOUD_API_KEY environment variable to enable it. " - f"Scan results were saved to {ocsf_output.file_path}{Style.RESET_ALL}" - ) - except requests.ConnectionError: - print( - f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: could not reach the Prowler Cloud API at " - f"{cloud_api_base_url}. Check the URL and your network connection. " - f"Scan results were saved to {ocsf_output.file_path}{Style.RESET_ALL}" - ) - except requests.HTTPError as http_err: - if http_err.response.status_code == 402: - print( - f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: " - "this feature is only available with a Prowler Cloud subscription. " - f"Scan results were saved to {ocsf_output.file_path}{Style.RESET_ALL}" - ) - else: - print( - f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: the API returned HTTP {http_err.response.status_code}. " - "Verify your API key is valid and has the right permissions. " - f"Scan results were saved to {ocsf_output.file_path}{Style.RESET_ALL}" - ) - except Exception as error: - print( - f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed unexpectedly: {error}. " - f"Scan results were saved to {ocsf_output.file_path}{Style.RESET_ALL}" - ) - else: + response = _send_ocsf_to_cloud(ocsf_output.file_path) + if response is not None: job_id = response.get("data", {}).get("id") if response else None if job_id: print( diff --git a/prowler/changelog.d/aws-organizations-partition-region.fixed.md b/prowler/changelog.d/aws-organizations-partition-region.fixed.md new file mode 100644 index 0000000000..580a69ec05 --- /dev/null +++ b/prowler/changelog.d/aws-organizations-partition-region.fixed.md @@ -0,0 +1 @@ +AWS Organizations metadata is fetched from the partition of the audited account, using the region that answered the STS bootstrap, instead of the commercial `us-east-1` endpoint a session without a region resolves to, which timed out in GovCloud diff --git a/prowler/changelog.d/awslambda-layer-no-secrets-in-content.added.md b/prowler/changelog.d/awslambda-layer-no-secrets-in-content.added.md deleted file mode 100644 index 56a192b088..0000000000 --- a/prowler/changelog.d/awslambda-layer-no-secrets-in-content.added.md +++ /dev/null @@ -1 +0,0 @@ -`awslambda_layer_no_secrets_in_content` check for AWS provider, scanning Lambda layer package content for hardcoded secrets diff --git a/prowler/changelog.d/batch-job-definition-no-secrets.added.md b/prowler/changelog.d/batch-job-definition-no-secrets.added.md deleted file mode 100644 index 797dc06a12..0000000000 --- a/prowler/changelog.d/batch-job-definition-no-secrets.added.md +++ /dev/null @@ -1 +0,0 @@ -`batch_job_definition_no_secrets` check for AWS provider, scanning Batch job definition environment variables and command parameters for hardcoded secrets diff --git a/prowler/changelog.d/cmmc-2.0-universal.added.md b/prowler/changelog.d/cmmc-2.0-universal.added.md deleted file mode 100644 index 2c57c05c93..0000000000 --- a/prowler/changelog.d/cmmc-2.0-universal.added.md +++ /dev/null @@ -1 +0,0 @@ -CMMC 2.0 universal compliance framework (`cmmc_2.0`) with the 149 official requirements from 32 CFR Part 170 — Level 1 (15, 48 CFR 52.204-21), Level 2 (110, NIST SP 800-171 Rev 2) and Level 3 (24, NIST SP 800-172) — with AWS, Azure, GCP, Alibaba Cloud, Oracle Cloud and M365 check mappings and config guardrails diff --git a/prowler/changelog.d/dulwich-1-2-9.security.md b/prowler/changelog.d/dulwich-1-2-9.security.md new file mode 100644 index 0000000000..8f1dd24253 --- /dev/null +++ b/prowler/changelog.d/dulwich-1-2-9.security.md @@ -0,0 +1 @@ +`dulwich` from 1.2.5 to 1.2.9, patching GHSA-5fqc-mrg8-w798, GHSA-8mcx-5rqc-vhmf, GHSA-8w8g-wq8h-fq33 and GHSA-cm62-gvxx-vmxx diff --git a/prowler/changelog.d/entra-admin-sspr-disabled.added.md b/prowler/changelog.d/entra-admin-sspr-disabled.added.md new file mode 100644 index 0000000000..9f46f08bde --- /dev/null +++ b/prowler/changelog.d/entra-admin-sspr-disabled.added.md @@ -0,0 +1 @@ +`entra_policy_admin_self_service_password_reset_disabled` check for M365 provider, verifying that Self-Service Password Reset is disabled for administrators diff --git a/prowler/changelog.d/entra-app-redirect-uris-secure.added.md b/prowler/changelog.d/entra-app-redirect-uris-secure.added.md new file mode 100644 index 0000000000..0301b96983 --- /dev/null +++ b/prowler/changelog.d/entra-app-redirect-uris-secure.added.md @@ -0,0 +1 @@ +`entra_app_registration_redirect_uris_secure` check for M365 provider, flagging app registrations with HTTP, wildcard, malformed or `azurewebsites.net` redirect URIs diff --git a/prowler/changelog.d/entra-cross-tenant-default-outbound.added.md b/prowler/changelog.d/entra-cross-tenant-default-outbound.added.md new file mode 100644 index 0000000000..504d06509d --- /dev/null +++ b/prowler/changelog.d/entra-cross-tenant-default-outbound.added.md @@ -0,0 +1 @@ +`entra_cross_tenant_access_default_outbound_restricted` check for M365 provider, verifying that default cross-tenant access does not let all users reach all external applications through B2B collaboration or B2B direct connect diff --git a/prowler/changelog.d/entra-dynamic-group-member-of-rule.added.md b/prowler/changelog.d/entra-dynamic-group-member-of-rule.added.md new file mode 100644 index 0000000000..a2b32f6177 --- /dev/null +++ b/prowler/changelog.d/entra-dynamic-group-member-of-rule.added.md @@ -0,0 +1 @@ +`entra_dynamic_group_member_of_rule_not_used` check for M365 provider, flagging dynamic groups whose membership rule uses the `memberOf` operator that Microsoft retires on November 3, 2026 diff --git a/prowler/changelog.d/entra-msol-powershell-blocked.added.md b/prowler/changelog.d/entra-msol-powershell-blocked.added.md new file mode 100644 index 0000000000..f8e12e10e4 --- /dev/null +++ b/prowler/changelog.d/entra-msol-powershell-blocked.added.md @@ -0,0 +1 @@ +`entra_policy_msol_powershell_blocked` check for M365 provider, verifying that the tenant authorization policy blocks the legacy MSOnline PowerShell module diff --git a/prowler/changelog.d/github-organization-repository-creation-severity.changed.md b/prowler/changelog.d/github-organization-repository-creation-severity.changed.md deleted file mode 100644 index 6e8f831447..0000000000 --- a/prowler/changelog.d/github-organization-repository-creation-severity.changed.md +++ /dev/null @@ -1 +0,0 @@ -GitHub `organization_repository_creation_limited` check now reports low severity for FAIL findings when repository creation is provably limited to private/internal visibility, instead of always reporting high diff --git a/prowler/changelog.d/m365-cis7-entra-authn-pim.added.md b/prowler/changelog.d/m365-cis7-entra-authn-pim.added.md deleted file mode 100644 index ac633fb58d..0000000000 --- a/prowler/changelog.d/m365-cis7-entra-authn-pim.added.md +++ /dev/null @@ -1 +0,0 @@ -`entra_authentication_method_email_otp_disabled`, `entra_authentication_method_authenticator_show_context`, `entra_pim_global_administrator_approval_required`, `entra_pim_privileged_role_administrator_approval_required`, `entra_access_review_guest_users_configured` and `entra_access_review_privileged_roles_configured` checks for M365 provider covering CIS Microsoft 365 Foundations Benchmark v7.0.0 authentication method, PIM approval and access review controls diff --git a/prowler/changelog.d/m365-cis7-entra-conditional-access.added.md b/prowler/changelog.d/m365-cis7-entra-conditional-access.added.md deleted file mode 100644 index 66856e5e20..0000000000 --- a/prowler/changelog.d/m365-cis7-entra-conditional-access.added.md +++ /dev/null @@ -1 +0,0 @@ -7 M365 entra checks covering CIS Microsoft 365 Foundations Benchmark v7.0.0 Conditional Access (5.2.2.x) and idle session timeout controls diff --git a/prowler/changelog.d/m365-cis7-entra-directory-settings.added.md b/prowler/changelog.d/m365-cis7-entra-directory-settings.added.md deleted file mode 100644 index 4fc76e535f..0000000000 --- a/prowler/changelog.d/m365-cis7-entra-directory-settings.added.md +++ /dev/null @@ -1 +0,0 @@ -7 M365 Entra checks covering CIS Microsoft 365 Foundations Benchmark v7.0.0 password protection, default user permissions, and guest invitation domain restrictions diff --git a/prowler/changelog.d/oci-home-region-bootstrap.fixed.md b/prowler/changelog.d/oci-home-region-bootstrap.fixed.md new file mode 100644 index 0000000000..de2997773d --- /dev/null +++ b/prowler/changelog.d/oci-home-region-bootstrap.fixed.md @@ -0,0 +1 @@ +OCI API key credentials accept a home region to bootstrap identity calls, so tenancies not subscribed to us-ashburn-1 can connect diff --git a/prowler/changelog.d/pyjwt-2-14-0.security.md b/prowler/changelog.d/pyjwt-2-14-0.security.md new file mode 100644 index 0000000000..fab78f2937 --- /dev/null +++ b/prowler/changelog.d/pyjwt-2-14-0.security.md @@ -0,0 +1 @@ +`pyjwt` from 2.13.0 to 2.14.0, patching GHSA-ffc3-869f-jxw9 diff --git a/prowler/changelog.d/pymongo-4-18-2.security.md b/prowler/changelog.d/pymongo-4-18-2.security.md new file mode 100644 index 0000000000..b621313d25 --- /dev/null +++ b/prowler/changelog.d/pymongo-4-18-2.security.md @@ -0,0 +1 @@ +`pymongo` from 4.15.1 to 4.18.2, patching CVE-2026-96748 and CVE-2026-96749 diff --git a/prowler/changelog.d/sdk-image-trivy-0-75-0.security.md b/prowler/changelog.d/sdk-image-trivy-0-75-0.security.md new file mode 100644 index 0000000000..0a54879ed0 --- /dev/null +++ b/prowler/changelog.d/sdk-image-trivy-0-75-0.security.md @@ -0,0 +1 @@ +Python base image from 3.12.13 to 3.12.15 and Trivy from 0.74.0 to 0.75.0 in the SDK container image, patching CVE-2026-19445, CVE-2026-19553 and the grpc-go and x/crypto CVEs bundled in Trivy diff --git a/prowler/changelog.d/urllib3-2-8-0.security.md b/prowler/changelog.d/urllib3-2-8-0.security.md new file mode 100644 index 0000000000..4645bb5942 --- /dev/null +++ b/prowler/changelog.d/urllib3-2-8-0.security.md @@ -0,0 +1 @@ +`urllib3` from 2.7.0 to 2.8.0, patching CVE-2026-97687 and CVE-2026-97689 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 f20baf4735..ceac752ff8 100644 --- a/prowler/compliance/aws/aws_ai_security_framework_aws.json +++ b/prowler/compliance/aws/aws_ai_security_framework_aws.json @@ -110,7 +110,9 @@ "eks_cluster_kms_cmk_encryption_in_secrets_enabled", "dynamodb_tables_kms_cmk_encryption_enabled", "sns_topics_kms_encryption_at_rest_enabled", - "sqs_queues_server_side_encryption_enabled" + "sqs_queues_server_side_encryption_enabled", + "bedrock_custom_model_encrypted_with_cmk", + "bedrock_knowledge_base_encrypted_with_cmk" ] }, { @@ -239,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", @@ -267,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" ] }, @@ -303,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" @@ -482,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" ] }, { @@ -638,10 +644,12 @@ "Section": "AI Application Security", "SubSection": "Output Validation", "Service": "bedrock", - "Type": "Manual" + "Type": "Automated" } ], - "Checks": [] + "Checks": [ + "bedrock_guardrail_contextual_grounding_filter_enabled" + ] }, { "Id": "AISF-AI-10", @@ -652,10 +660,12 @@ "Section": "AI Application Security", "SubSection": "RAG Security", "Service": "bedrock", - "Type": "Manual" + "Type": "Automated" } ], - "Checks": [] + "Checks": [ + "bedrock_knowledge_base_encrypted_with_cmk" + ] }, { "Id": "AISF-AI-11", @@ -680,10 +690,12 @@ "Section": "AI Application Security", "SubSection": "Agent Governance", "Service": "bedrock", - "Type": "Manual" + "Type": "Automated" } ], - "Checks": [] + "Checks": [ + "bedrock_agent_role_not_shared_across_agents" + ] }, { "Id": "AISF-AGENT-02", @@ -870,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": [ { @@ -1120,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" ] }, { @@ -1146,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_security_best_practices_aws.json b/prowler/compliance/aws/aws_foundational_security_best_practices_aws.json index b58a74bcaa..c09f6821df 100644 --- a/prowler/compliance/aws/aws_foundational_security_best_practices_aws.json +++ b/prowler/compliance/aws/aws_foundational_security_best_practices_aws.json @@ -1782,7 +1782,7 @@ "Name": "EKS cluster endpoints should not be publicly accessible", "Description": "This control checks whether an Amazon EKS cluster endpoint is publicly accessible. The control fails if an EKS cluster has an endpoint that is publicly accessible.", "Checks": [ - "eks_endpoints_not_publicly_accessible" + "eks_cluster_not_publicly_accessible" ], "Attributes": [ { @@ -2634,7 +2634,9 @@ "Id": "IAM.9", "Name": "MFA should be enabled for the root user", "Description": "The root user has complete access to all the services and resources in an AWS account. MFA adds an extra layer of protection on top of a user name and password. With MFA enabled, when a user signs in to the AWS Management Console, they're prompted for their user name and password and for an authentication code from their AWS MFA device.", - "Checks": [], + "Checks": [ + "iam_root_mfa_enabled" + ], "Attributes": [ { "ItemId": "IAM.9", 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/cmmc_2.0.json b/prowler/compliance/cmmc_2.0.json index a3d7b67ea6..7c5783af13 100644 --- a/prowler/compliance/cmmc_2.0.json +++ b/prowler/compliance/cmmc_2.0.json @@ -265,20 +265,6 @@ "Operator": "lte", "Value": 90, "Provider": "aws" - }, - { - "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" } ] }, @@ -1064,20 +1050,6 @@ "Operator": "lte", "Value": 90, "Provider": "aws" - }, - { - "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" } ] }, @@ -2008,23 +1980,7 @@ "alibabacloud": [], "oraclecloud": [], "m365": [] - }, - "config_requirements": [ - { - "Check": "guardduty_is_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": "AU.L2-3.3.5", diff --git a/prowler/compliance/cyber_essentials_3.3.json b/prowler/compliance/cyber_essentials_3.3.json new file mode 100644 index 0000000000..7b29a8477a --- /dev/null +++ b/prowler/compliance/cyber_essentials_3.3.json @@ -0,0 +1,601 @@ +{ + "framework": "Cyber-Essentials", + "name": "NCSC Cyber Essentials: Requirements for IT Infrastructure", + "version": "3.3", + "description": "The UK National Cyber Security Centre (NCSC) Cyber Essentials scheme - Requirements for IT Infrastructure v3.3 (April 2026). Cyber Essentials organises its technical requirements into five control themes: Firewalls, Secure Configuration, Security Update Management, User Access Control, and Malware Protection. Cloud services are explicitly in scope for Cyber Essentials and cannot be excluded, so under the shared responsibility model this framework covers the controls the applicant organisation is responsible for implementing on its own cloud infrastructure (Infrastructure/Platform as a Service). Requirements that apply to end-user devices, on-premises network appliances, or organisational process and are not observable from cloud control-plane evidence are included for completeness with an empty check list and 'non-applicable' or 'Manual' attributes.", + "icon": "cyber-essentials", + "attributes_metadata": [ + { + "key": "Theme", + "label": "Technical Control Theme", + "type": "str", + "required": true, + "enum": [ + "Firewalls", + "Secure Configuration", + "Security Update Management", + "User Access Control", + "Malware Protection" + ], + "output_formats": { + "csv": true, + "ocsf": true + } + }, + { + "key": "AssessmentStatus", + "label": "Assessment Status", + "type": "str", + "required": true, + "enum": [ + "Automated", + "Manual" + ], + "output_formats": { + "csv": true, + "ocsf": true + } + }, + { + "key": "CloudApplicability", + "label": "Cloud Applicability", + "type": "str", + "required": true, + "enum": [ + "full", + "partial", + "non-applicable" + ], + "output_formats": { + "csv": true, + "ocsf": true + } + }, + { + "key": "RemediationProcedure", + "label": "Remediation Procedure", + "type": "str", + "required": true, + "output_formats": { + "csv": true, + "ocsf": false + } + }, + { + "key": "References", + "label": "References", + "type": "str", + "required": true, + "output_formats": { + "csv": true, + "ocsf": false + } + } + ], + "outputs": { + "table_config": { + "group_by": "Theme" + }, + "pdf_config": { + "language": "en", + "primary_color": "#003D54", + "secondary_color": "#0072CE", + "bg_color": "#F0F4FA", + "group_by_field": "Theme", + "sections": [ + "Firewalls", + "Secure Configuration", + "Security Update Management", + "User Access Control", + "Malware Protection" + ], + "section_short_names": { + "Firewalls": "Firewalls", + "Secure Configuration": "Secure Config", + "Security Update Management": "Update Mgmt", + "User Access Control": "Access Control", + "Malware Protection": "Malware" + }, + "charts": [ + { + "id": "theme_compliance", + "type": "horizontal_bar", + "group_by": "Theme", + "title": "Compliance Score by Cyber Essentials Theme", + "y_label": "Theme", + "x_label": "Compliance %", + "value_source": "compliance_percent", + "color_mode": "by_value" + } + ], + "filter": { + "only_failed": true, + "include_manual": false + } + } + }, + "requirements": [ + { + "id": "CE-FW-01", + "name": "Boundary firewall on every in-scope device", + "description": "Every device in scope must be protected by a correctly configured firewall or network device with firewall functionality, restricting inbound and outbound network services to those that are secure and necessary.", + "attributes": { + "Theme": "Firewalls", + "AssessmentStatus": "Automated", + "CloudApplicability": "partial", + "RemediationProcedure": "Restrict inbound access from the internet to management and data services (RDP, SSH, database ports) using the provider's network firewall, security group or access-control-list controls, and remove any rule that allows unrestricted inbound access.", + "References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E" + }, + "checks": { + "azure": [ + "network_rdp_internet_access_restricted", + "network_ssh_internet_access_restricted", + "network_udp_internet_access_restricted", + "network_http_internet_access_restricted" + ] + } + }, + { + "id": "CE-FW-02", + "name": "Change default administrative passwords or disable remote admin access", + "description": "Default administrative passwords on firewalls and network devices must be changed to a strong, unique password, or remote administrative access must be disabled entirely.", + "attributes": { + "Theme": "Firewalls", + "AssessmentStatus": "Manual", + "CloudApplicability": "non-applicable", + "RemediationProcedure": "This requirement applies to on-premises network appliances and home/remote routers, which are outside the scope of cloud control-plane evidence. Manage and document this control as part of your organisation's device estate.", + "References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E" + }, + "checks": { + "azure": [] + } + }, + { + "id": "CE-FW-03", + "name": "Restrict access to firewall/network device administrative interfaces", + "description": "Access to the administrative interface used to manage firewall or network device configuration must not be possible from the internet, unless there is a documented business need and the interface is protected by MFA or an IP allow list combined with a managed password approach.", + "attributes": { + "Theme": "Firewalls", + "AssessmentStatus": "Automated", + "CloudApplicability": "partial", + "RemediationProcedure": "Disable public network access on management-plane resources (object storage, secret and key management services) or restrict access to trusted networks and IP ranges, and require MFA for any administrative access exposed to the internet.", + "References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E" + }, + "checks": { + "azure": [ + "storage_account_public_network_access_disabled", + "storage_default_network_access_rule_is_denied", + "keyvault_access_only_through_private_endpoints", + "network_rdp_internet_access_restricted", + "network_ssh_internet_access_restricted" + ] + } + }, + { + "id": "CE-FW-04", + "name": "Block unauthenticated inbound connections by default", + "description": "Firewalls and network devices must block unauthenticated inbound connections by default.", + "attributes": { + "Theme": "Firewalls", + "AssessmentStatus": "Automated", + "CloudApplicability": "full", + "RemediationProcedure": "Configure network access-control rules with a default-deny inbound posture and only allow specific, documented inbound services. Disable public network access on managed services that do not require it.", + "References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E" + }, + "checks": { + "azure": [ + "network_rdp_internet_access_restricted", + "network_ssh_internet_access_restricted", + "network_udp_internet_access_restricted", + "network_http_internet_access_restricted", + "storage_account_public_network_access_disabled" + ] + } + }, + { + "id": "CE-FW-05", + "name": "Inbound firewall rules approved and documented", + "description": "Inbound firewall rules must be approved and documented by an authorised person, including the business need for the rule.", + "attributes": { + "Theme": "Firewalls", + "AssessmentStatus": "Manual", + "CloudApplicability": "non-applicable", + "RemediationProcedure": "Maintain a change-approval record (e.g. change tickets or a network rule register) for every inbound network access-control rule, including the business justification and approver.", + "References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E" + }, + "checks": { + "azure": [] + } + }, + { + "id": "CE-FW-06", + "name": "Remove or disable unnecessary firewall rules", + "description": "Firewall rules that are no longer needed must be removed or disabled.", + "attributes": { + "Theme": "Firewalls", + "AssessmentStatus": "Manual", + "CloudApplicability": "non-applicable", + "RemediationProcedure": "Periodically review firewall and network access-control rules, removing or disabling any rule that no longer has a documented business need.", + "References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E" + }, + "checks": { + "azure": [] + } + }, + { + "id": "CE-FW-07", + "name": "Software firewall on devices used on untrusted networks", + "description": "Devices that connect to untrusted networks, such as public Wi-Fi hotspots, must use a software firewall.", + "attributes": { + "Theme": "Firewalls", + "AssessmentStatus": "Manual", + "CloudApplicability": "non-applicable", + "RemediationProcedure": "This is an end-user device control (host-based firewall) and has no cloud control-plane equivalent. Enforce via endpoint management policy.", + "References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E" + }, + "checks": { + "azure": [] + } + }, + { + "id": "CE-SC-01", + "name": "Remove or disable unnecessary user accounts", + "description": "Unnecessary user accounts, such as guest accounts and unused administrative accounts, must be removed or disabled.", + "attributes": { + "Theme": "Secure Configuration", + "AssessmentStatus": "Automated", + "CloudApplicability": "partial", + "RemediationProcedure": "Restrict guest and external user invitations and access, and review identity-provider and cloud role assignments to remove unused guest or administrative accounts.", + "References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E" + }, + "checks": { + "azure": [ + "entra_policy_guest_users_access_restrictions", + "entra_policy_guest_invite_only_for_admin_roles" + ] + } + }, + { + "id": "CE-SC-02", + "name": "Change default or guessable account passwords", + "description": "Default or guessable account passwords must be changed before a device or service is used.", + "attributes": { + "Theme": "Secure Configuration", + "AssessmentStatus": "Automated", + "CloudApplicability": "partial", + "RemediationProcedure": "Enable identity-provider security defaults (or an equivalent sign-in protection baseline) so that default or weak credentials cannot be used for sign-in.", + "References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E" + }, + "checks": { + "azure": [ + "entra_security_defaults_enabled" + ] + } + }, + { + "id": "CE-SC-03", + "name": "Remove or disable unnecessary software", + "description": "Unnecessary software, including applications, system utilities and network services, must be removed or disabled.", + "attributes": { + "Theme": "Secure Configuration", + "AssessmentStatus": "Manual", + "CloudApplicability": "non-applicable", + "RemediationProcedure": "This is an operating-system level control for devices and servers and has no direct cloud control-plane equivalent. Maintain an approved software baseline and image hardening process for VM images.", + "References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E" + }, + "checks": { + "azure": [] + } + }, + { + "id": "CE-SC-04", + "name": "Disable auto-run features", + "description": "Auto-run features that allow file execution without user authorisation must be disabled.", + "attributes": { + "Theme": "Secure Configuration", + "AssessmentStatus": "Manual", + "CloudApplicability": "non-applicable", + "RemediationProcedure": "This is an operating-system level control and has no cloud control-plane equivalent. Disable AutoRun/AutoPlay via OS configuration or group policy on VM images.", + "References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E" + }, + "checks": { + "azure": [] + } + }, + { + "id": "CE-SC-05", + "name": "Authenticate users before granting access to organisational data or services", + "description": "Users must be authenticated before being allowed access to organisational data or services, including cloud services.", + "attributes": { + "Theme": "Secure Configuration", + "AssessmentStatus": "Automated", + "CloudApplicability": "full", + "RemediationProcedure": "Disable anonymous or public access to object storage and require authenticated, encrypted (TLS 1.2+) access. Use role-based access control for secret and key management data-plane access instead of policies that allow unauthenticated retrieval.", + "References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E" + }, + "checks": { + "azure": [ + "storage_secure_transfer_required_is_enabled", + "storage_blob_public_access_level_is_disabled", + "storage_account_public_network_access_disabled", + "storage_ensure_minimum_tls_version_12", + "keyvault_rbac_enabled" + ] + } + }, + { + "id": "CE-SC-06", + "name": "Device locking and brute-force protection for unlocking credentials", + "description": "Devices that require a user's physical presence must use an unlocking credential (biometric, password or PIN) of at least 6 characters, protected against brute-force guessing by throttling or lockout after no more than 10 attempts.", + "attributes": { + "Theme": "Secure Configuration", + "AssessmentStatus": "Manual", + "CloudApplicability": "non-applicable", + "RemediationProcedure": "This is an end-user device control (screen lock credential length and brute-force lockout) and has no cloud control-plane equivalent. Enforce a minimum unlock credential length and a lockout threshold of no more than 10 attempts through your device management policy.", + "References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E" + }, + "checks": { + "azure": [] + } + }, + { + "id": "CE-SUM-01", + "name": "All software licensed and supported", + "description": "All software on in-scope devices must be licensed and supported by the vendor.", + "attributes": { + "Theme": "Security Update Management", + "AssessmentStatus": "Manual", + "CloudApplicability": "non-applicable", + "RemediationProcedure": "Maintain a software asset inventory confirming each product is licensed and has an active vendor support commitment, including operating systems and any third-party software running on cloud compute resources.", + "References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E" + }, + "checks": { + "azure": [] + } + }, + { + "id": "CE-SUM-02", + "name": "Remove unsupported software", + "description": "Software must be removed from devices when it becomes unsupported, or isolated into a defined sub-set that prevents all internet traffic.", + "attributes": { + "Theme": "Security Update Management", + "AssessmentStatus": "Manual", + "CloudApplicability": "non-applicable", + "RemediationProcedure": "Track vendor end-of-support dates for operating systems and applications running on cloud compute resources, and decommission or isolate workloads before support ends.", + "References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E" + }, + "checks": { + "azure": [] + } + }, + { + "id": "CE-SUM-03", + "name": "Automatic updates enabled where possible", + "description": "Automatic updates must be enabled on in-scope software where this is possible.", + "attributes": { + "Theme": "Security Update Management", + "AssessmentStatus": "Manual", + "CloudApplicability": "partial", + "RemediationProcedure": "Enable automatic updates on in-scope software where possible: configure platform-managed guest patching on cloud virtual machines and instance groups, and enable vendor auto-update mechanisms for any third-party software running on those workloads. Note: no check currently observes whether automatic updates are enabled (vulnerability assessment and security-posture provisioning only prove monitoring and assessment coverage), so this requirement must be verified manually until a dedicated check validating update automation exists.", + "References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E" + }, + "checks": { + "azure": [] + } + }, + { + "id": "CE-SUM-04", + "name": "Critical and high-risk updates applied within 14 days", + "description": "Updates that fix vulnerabilities described by the vendor as critical or high risk, or that address a CVSS v3 base score of 7 or above (or where severity is unspecified), must be applied within 14 days of release.", + "attributes": { + "Theme": "Security Update Management", + "AssessmentStatus": "Automated", + "CloudApplicability": "partial", + "RemediationProcedure": "Enable the provider's security-posture service for system update recommendations and vulnerability assessment, and remediate flagged virtual machines within 14 days of a critical or high-risk update being released. Note: these checks evidence that update monitoring and vulnerability assessment coverage is enabled, not that a given update was applied within the 14-day window, which must be verified from your patch management records.", + "References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E" + }, + "checks": { + "azure": [ + "defender_ensure_system_updates_are_applied", + "defender_auto_provisioning_vulnerabilty_assessments_machines_on" + ] + } + }, + { + "id": "CE-UAC-01", + "name": "Process to create and approve user accounts", + "description": "A documented process must be in place to create and approve user accounts before access is granted.", + "attributes": { + "Theme": "User Access Control", + "AssessmentStatus": "Manual", + "CloudApplicability": "non-applicable", + "RemediationProcedure": "Maintain a joiner/mover/leaver process with documented approval steps for creating identity-provider accounts and assigning cloud roles.", + "References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E" + }, + "checks": { + "azure": [] + } + }, + { + "id": "CE-UAC-02", + "name": "Authenticate users with unique credentials", + "description": "Users must be authenticated with unique credentials before being granted access to applications or devices.", + "attributes": { + "Theme": "User Access Control", + "AssessmentStatus": "Automated", + "CloudApplicability": "partial", + "RemediationProcedure": "Enable identity-provider security defaults and require every user to authenticate with their own directory-backed identity rather than shared account keys or long-lived access keys when accessing cloud resources such as object storage.", + "References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E" + }, + "checks": { + "azure": [ + "entra_security_defaults_enabled", + "storage_default_to_entra_authorization_enabled" + ] + } + }, + { + "id": "CE-UAC-03", + "name": "Remove or disable user accounts when no longer required", + "description": "User accounts must be removed or disabled when they are no longer required, for example when a user leaves the organisation or after a defined period of inactivity.", + "attributes": { + "Theme": "User Access Control", + "AssessmentStatus": "Manual", + "CloudApplicability": "non-applicable", + "RemediationProcedure": "Implement a leaver process and periodic identity-provider access reviews to disable or remove accounts that are no longer required.", + "References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E" + }, + "checks": { + "azure": [] + } + }, + { + "id": "CE-UAC-04", + "name": "Multi-factor authentication for cloud services", + "description": "Multi-factor authentication must be implemented where available, and authentication to cloud services must always use MFA.", + "attributes": { + "Theme": "User Access Control", + "AssessmentStatus": "Automated", + "CloudApplicability": "full", + "RemediationProcedure": "Require multi-factor authentication for all users through an enforced sign-in policy, covering administrative consoles, management APIs, and users with access to virtual machines.", + "References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E" + }, + "checks": { + "azure": [ + "entra_privileged_user_has_mfa", + "entra_non_privileged_user_has_mfa", + "entra_conditional_access_policy_require_mfa_for_admin_portals", + "entra_conditional_access_policy_require_mfa_for_management_api", + "entra_user_with_vm_access_has_mfa" + ] + } + }, + { + "id": "CE-UAC-05", + "name": "Separate accounts for administrative activities", + "description": "Separate accounts must be used to perform administrative activities only, with no email, web browsing or other standard user activity that could expose administrative privileges to avoidable risk.", + "attributes": { + "Theme": "User Access Control", + "AssessmentStatus": "Automated", + "CloudApplicability": "partial", + "RemediationProcedure": "Limit the number of highly privileged role assignments (global or organisation administrator), avoid granting owner or access-administrator roles at the account, subscription or project scope, and require named administrators to use dedicated privileged accounts for administrative tasks.", + "References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E" + }, + "checks": { + "azure": [ + "entra_global_admin_in_less_than_five_users", + "iam_role_user_access_admin_restricted", + "iam_subscription_roles_owner_custom_not_created" + ] + } + }, + { + "id": "CE-UAC-06", + "name": "Remove or disable special access privileges when no longer required", + "description": "Special access privileges must be removed or disabled when they are no longer required, for example when a member of staff changes role.", + "attributes": { + "Theme": "User Access Control", + "AssessmentStatus": "Automated", + "CloudApplicability": "partial", + "RemediationProcedure": "Periodically review identity-provider directory role assignments and cloud role-based access control assignments, removing privileged roles that are no longer needed for a user's current role.", + "References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E" + }, + "checks": { + "azure": [ + "entra_global_admin_in_less_than_five_users", + "iam_role_user_access_admin_restricted" + ] + } + }, + { + "id": "CE-UAC-07", + "name": "Protect password-based authentication against brute-force attacks", + "description": "Where authentication is carried out using a password, accounts must be protected against brute-force guessing using MFA, attempt throttling, or account lockout.", + "attributes": { + "Theme": "User Access Control", + "AssessmentStatus": "Automated", + "CloudApplicability": "partial", + "RemediationProcedure": "Enable identity-provider security defaults (which include account lockout on repeated failed sign-ins) and require MFA, particularly for privileged accounts, to mitigate brute-force password attacks.", + "References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E" + }, + "checks": { + "azure": [ + "entra_security_defaults_enabled", + "entra_privileged_user_has_mfa" + ] + } + }, + { + "id": "CE-UAC-08", + "name": "Technical controls to manage password quality", + "description": "Technical controls must be used to manage the quality of passwords, using MFA, a minimum password length of at least 12 characters with no maximum length, or a minimum of 8 characters combined with a common-password deny list. Regular forced password expiry and complexity requirements should not be enforced.", + "attributes": { + "Theme": "User Access Control", + "AssessmentStatus": "Automated", + "CloudApplicability": "partial", + "RemediationProcedure": "Enable identity-provider security defaults and password protection (banned or breached password lists), and require MFA so that password length alone is not the only protection.", + "References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E" + }, + "checks": { + "azure": [ + "entra_security_defaults_enabled", + "entra_privileged_user_has_mfa", + "entra_non_privileged_user_has_mfa" + ] + } + }, + { + "id": "CE-MP-01", + "name": "Malware protection mechanism active on all in-scope devices", + "description": "A malware protection mechanism must be active on all devices in scope, using anti-malware software, application allow listing, or application sandboxing.", + "attributes": { + "Theme": "Malware Protection", + "AssessmentStatus": "Automated", + "CloudApplicability": "partial", + "RemediationProcedure": "Ensure endpoint protection is installed on all virtual machines and enable the provider's workload protection service for servers, including endpoint detection and response integration.", + "References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E" + }, + "checks": { + "azure": [ + "defender_assessments_vm_endpoint_protection_installed", + "defender_ensure_wdatp_is_enabled", + "defender_ensure_defender_for_server_is_on" + ] + } + }, + { + "id": "CE-MP-02", + "name": "Anti-malware software configuration", + "description": "Anti-malware software must be kept up to date in line with vendor recommendations, prevent malware from running, prevent execution of malicious code, and prevent connections to malicious websites.", + "attributes": { + "Theme": "Malware Protection", + "AssessmentStatus": "Automated", + "CloudApplicability": "partial", + "RemediationProcedure": "Enable the provider's workload protection services for endpoints, servers and object storage so that signatures stay current and malicious files, code execution and connections are blocked.", + "References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E" + }, + "checks": { + "azure": [ + "defender_ensure_wdatp_is_enabled", + "defender_ensure_defender_for_server_is_on", + "defender_ensure_defender_for_storage_is_on" + ] + } + }, + { + "id": "CE-MP-03", + "name": "Application allow listing", + "description": "Where used as an alternative to anti-malware software, only approved applications restricted by code signing must be allowed to execute, with a maintained list of approved applications and no execution of unsigned or invalidly signed applications.", + "attributes": { + "Theme": "Malware Protection", + "AssessmentStatus": "Manual", + "CloudApplicability": "non-applicable", + "RemediationProcedure": "This is an end-user device control implemented through application control or allow-listing policies and has no cloud control-plane equivalent.", + "References": "NCSC Cyber Essentials: Requirements for IT Infrastructure v3.3 (April 2026), Section E" + }, + "checks": { + "azure": [] + } + } + ] +} 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 35d64d358e..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": [ { @@ -526,7 +526,9 @@ { "Id": "2.1.10", "Description": "DMARC, or Domain-based Message Authentication, Reporting, and Conformance, assists recipient mail systems in determining the appropriate action to take when messages from a domain fail to meet SPF or DKIM authentication criteria.", - "Checks": [], + "Checks": [ + "defender_domain_dmarc_records_published" + ], "Attributes": [ { "Section": "2 Microsoft 365 Defender", @@ -1355,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/cis_6.0_m365.json b/prowler/compliance/m365/cis_6.0_m365.json index 5815c67ac9..33d902d6fa 100644 --- a/prowler/compliance/m365/cis_6.0_m365.json +++ b/prowler/compliance/m365/cis_6.0_m365.json @@ -543,7 +543,9 @@ { "Id": "2.1.10", "Description": "DMARC, or Domain-based Message Authentication, Reporting, and Conformance, assists recipient mail systems in determining the appropriate action to take when messages from a domain fail to meet SPF or DKIM authentication criteria.", - "Checks": [], + "Checks": [ + "defender_domain_dmarc_records_published" + ], "Attributes": [ { "Section": "2 Microsoft 365 Defender", diff --git a/prowler/compliance/m365/cis_7.0_m365.json b/prowler/compliance/m365/cis_7.0_m365.json index 7cc1315275..a39d30f54b 100644 --- a/prowler/compliance/m365/cis_7.0_m365.json +++ b/prowler/compliance/m365/cis_7.0_m365.json @@ -554,7 +554,9 @@ { "Id": "2.1.10", "Description": "DMARC, or Domain-based Message Authentication, Reporting, and Conformance, assists recipient mail systems in determining the appropriate action to take when messages from a domain fail to meet SPF or DKIM authentication criteria.", - "Checks": [], + "Checks": [ + "defender_domain_dmarc_records_published" + ], "Attributes": [ { "Section": "2 Microsoft Defender", diff --git a/prowler/compliance/m365/iso27001_2022_m365.json b/prowler/compliance/m365/iso27001_2022_m365.json index 238002d0b6..5d5f024d71 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", @@ -177,7 +174,9 @@ } ], "Checks": [ + "entra_app_registration_redirect_uris_secure", "entra_conditional_access_policy_app_enforced_restrictions", + "entra_cross_tenant_access_default_outbound_restricted", "exchange_transport_config_smtp_auth_disabled", "exchange_transport_rules_mail_forwarding_disabled", "exchange_transport_rules_whitelist_disabled", @@ -186,7 +185,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" ] }, @@ -211,6 +209,8 @@ "entra_conditional_access_policy_block_unknown_device_platforms", "entra_conditional_access_policy_directory_sync_account_excluded", "entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced", + "entra_cross_tenant_access_default_outbound_restricted", + "entra_dynamic_group_member_of_rule_not_used", "entra_policy_guest_users_access_restrictions", "entra_seamless_sso_disabled" ] @@ -260,6 +260,7 @@ "entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced", "entra_legacy_authentication_blocked", "entra_managed_device_required_for_authentication", + "entra_policy_admin_self_service_password_reset_disabled", "entra_seamless_sso_disabled", "entra_service_principal_no_secrets_for_permanent_tier0_roles", "entra_users_mfa_enabled", @@ -283,6 +284,7 @@ "Checks": [ "entra_admin_portals_access_restriction", "entra_app_registration_no_unused_privileged_permissions", + "entra_dynamic_group_member_of_rule_not_used", "entra_policy_guest_users_access_restrictions", "entra_service_principal_no_secrets_for_permanent_tier0_roles", "sharepoint_external_sharing_managed", @@ -676,8 +678,10 @@ "entra_break_glass_account_fido2_security_key_registered", "entra_emergency_access_exclusion", "entra_app_registration_no_unused_privileged_permissions", + "entra_policy_admin_self_service_password_reset_disabled", "entra_policy_ensure_default_user_cannot_create_tenants", "entra_policy_guest_invite_only_for_admin_roles", + "entra_policy_msol_powershell_blocked", "entra_seamless_sso_disabled", "entra_service_principal_no_secrets_for_permanent_tier0_roles" ] @@ -735,6 +739,7 @@ "entra_emergency_access_exclusion", "entra_identity_protection_sign_in_risk_enabled", "entra_managed_device_required_for_authentication", + "entra_policy_admin_self_service_password_reset_disabled", "entra_seamless_sso_disabled", "entra_service_principal_no_secrets_for_permanent_tier0_roles", "entra_users_mfa_enabled" @@ -780,7 +785,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" ] }, { @@ -834,6 +839,7 @@ ], "Checks": [ "entra_app_registration_no_unused_privileged_permissions", + "entra_policy_msol_powershell_blocked", "entra_policy_restricts_user_consent_for_apps", "entra_thirdparty_integrated_apps_not_allowed", "teams_external_domains_restricted", @@ -929,6 +935,7 @@ "admincenter_users_admins_reduced_license_footprint", "defender_malware_policy_comprehensive_attachments_filter_applied", "entra_app_registration_no_unused_privileged_permissions", + "entra_app_registration_redirect_uris_secure", "entra_policy_restricts_user_consent_for_apps", "entra_thirdparty_integrated_apps_not_allowed", "sharepoint_modern_authentication_required" diff --git a/prowler/config/config.py b/prowler/config/config.py index e2732a90f1..aa85d98502 100644 --- a/prowler/config/config.py +++ b/prowler/config/config.py @@ -10,7 +10,10 @@ import requests import yaml from packaging import version -from prowler.lib.check.compliance_models import load_compliance_framework_universal +from prowler.lib.check.compliance_models import ( + get_universal_compliance_entry_point_dirs, + load_compliance_framework_universal, +) # Re-exported from a leaf module so prowler.lib.check.utils can import the # constant without participating in the config <-> compliance_models <-> utils @@ -49,7 +52,7 @@ class _MutableTimestamp: timestamp = _MutableTimestamp(datetime.today()) timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc)) -prowler_version = "5.39.0" +prowler_version = "5.45.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" @@ -170,21 +173,7 @@ def get_available_compliance_frameworks(provider=None): available_compliance_frameworks.append(name) # External multi-provider frameworks via the dedicated universal group; # filtered by supports_provider when a provider is given. - for ep in importlib.metadata.entry_points(group="prowler.compliance.universal"): - try: - module = ep.load() - path = ( - module.__path__[0] - if hasattr(module, "__path__") - else os.path.dirname(module.__file__) - ) - except Exception as error: - logger.warning( - f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" - ) - continue - if not os.path.isdir(path): - continue + for path in get_universal_compliance_entry_point_dirs(): for file in os.scandir(path): if file.is_file() and file.name.endswith(".json"): name = file.name.removesuffix(".json") 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/check/compliance_models.py b/prowler/lib/check/compliance_models.py index 80322a6929..fae5422e3d 100644 --- a/prowler/lib/check/compliance_models.py +++ b/prowler/lib/check/compliance_models.py @@ -1049,6 +1049,46 @@ def load_compliance_framework_universal(path: str) -> ComplianceFramework: return None +# Kept apart from the per-provider `prowler.compliance` group so the legacy +# loader never parses a universal JSON. +UNIVERSAL_COMPLIANCE_ENTRY_POINT_GROUP = "prowler.compliance.universal" + + +def get_universal_compliance_entry_point_dirs() -> list[str]: + """Existing directories contributed through the universal compliance entry + point group, in entry point order. + + Deduped by resolved path, so a directory reached through a symlink counts + once. A package that fails to import is logged and skipped: one broken + plugin must not hide the rest. + """ + dirs = [] + seen = set() + for ep in importlib.metadata.entry_points( + group=UNIVERSAL_COMPLIANCE_ENTRY_POINT_GROUP + ): + try: + module = ep.load() + path = ( + module.__path__[0] + if hasattr(module, "__path__") + else os.path.dirname(module.__file__) + ) + except Exception as error: + logger.warning( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + continue + if not os.path.isdir(path): + continue + resolved = os.path.realpath(path) + if resolved in seen: + continue + seen.add(resolved) + dirs.append(path) + return dirs + + def _load_jsons_from_dir(dir_path: str, provider: str, bulk: dict) -> None: """Scan *dir_path* for JSON files and add matching frameworks to *bulk*.""" for filename in os.listdir(dir_path): @@ -1109,20 +1149,10 @@ def get_bulk_compliance_frameworks_universal(provider: str) -> dict: if compliance_root and os.path.isdir(compliance_root): _load_jsons_from_dir(compliance_root, provider, bulk) - # External multi-provider frameworks via the dedicated universal entry - # point group, kept separate from the per-provider `prowler.compliance` - # group so the legacy loader never parses a universal JSON. Built-ins - # (already in bulk) win on a name collision. - for ep in importlib.metadata.entry_points(group="prowler.compliance.universal"): + # Built-ins are already in `bulk` and win on a name collision. + for ep_dir in get_universal_compliance_entry_point_dirs(): try: - module = ep.load() - ep_dir = ( - module.__path__[0] - if hasattr(module, "__path__") - else os.path.dirname(module.__file__) - ) - if os.path.isdir(ep_dir): - _load_jsons_from_dir(ep_dir, provider, bulk) + _load_jsons_from_dir(ep_dir, provider, bulk) except Exception as error: logger.warning( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" diff --git a/prowler/lib/outputs/compliance/cis/cis_kubernetes.py b/prowler/lib/outputs/compliance/cis/cis_kubernetes.py index 23c482310e..d3349bcb49 100644 --- a/prowler/lib/outputs/compliance/cis/cis_kubernetes.py +++ b/prowler/lib/outputs/compliance/cis/cis_kubernetes.py @@ -55,6 +55,7 @@ class KubernetesCIS(ComplianceOutput): Provider=finding.provider, Description=compliance.Description, Context=finding.account_name, + Cluster=finding.account_uid, Namespace=finding.region, AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, @@ -89,6 +90,7 @@ class KubernetesCIS(ComplianceOutput): Provider=compliance.Provider.lower(), Description=compliance.Description, Context="", + Cluster="", Namespace="", AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, diff --git a/prowler/lib/outputs/compliance/cis/models.py b/prowler/lib/outputs/compliance/cis/models.py index 0f4b320fd0..999c8ca12c 100644 --- a/prowler/lib/outputs/compliance/cis/models.py +++ b/prowler/lib/outputs/compliance/cis/models.py @@ -148,6 +148,7 @@ class KubernetesCISModel(BaseModel): Provider: str Description: str Context: str + Cluster: str Namespace: str AssessmentDate: str Requirements_Id: str diff --git a/prowler/lib/outputs/compliance/iso27001/iso27001_kubernetes.py b/prowler/lib/outputs/compliance/iso27001/iso27001_kubernetes.py index 5ca81e82d5..a3515d0728 100644 --- a/prowler/lib/outputs/compliance/iso27001/iso27001_kubernetes.py +++ b/prowler/lib/outputs/compliance/iso27001/iso27001_kubernetes.py @@ -55,6 +55,7 @@ class KubernetesISO27001(ComplianceOutput): Provider=finding.provider, Description=compliance.Description, Context=finding.account_name, + Cluster=finding.account_uid, Namespace=finding.region, AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, @@ -82,6 +83,7 @@ class KubernetesISO27001(ComplianceOutput): Provider=compliance.Provider.lower(), Description=compliance.Description, Context="", + Cluster="", Namespace="", AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, diff --git a/prowler/lib/outputs/compliance/iso27001/models.py b/prowler/lib/outputs/compliance/iso27001/models.py index a10fa3c610..5eea733a7f 100644 --- a/prowler/lib/outputs/compliance/iso27001/models.py +++ b/prowler/lib/outputs/compliance/iso27001/models.py @@ -90,6 +90,7 @@ class KubernetesISO27001Model(BaseModel): Provider: str Description: str Context: str + Cluster: str Namespace: str AssessmentDate: str Requirements_Id: str diff --git a/prowler/lib/outputs/compliance/prowler_threatscore/models.py b/prowler/lib/outputs/compliance/prowler_threatscore/models.py index ab84bb6079..8dd0f97400 100644 --- a/prowler/lib/outputs/compliance/prowler_threatscore/models.py +++ b/prowler/lib/outputs/compliance/prowler_threatscore/models.py @@ -127,6 +127,7 @@ class ProwlerThreatScoreKubernetesModel(BaseModel): Provider: str Description: str Context: str + Cluster: str Namespace: str AssessmentDate: str Requirements_Id: str diff --git a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_kubernetes.py b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_kubernetes.py index 88bf41582a..d44a964773 100644 --- a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_kubernetes.py +++ b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_kubernetes.py @@ -58,6 +58,7 @@ class ProwlerThreatScoreKubernetes(ComplianceOutput): Provider=finding.provider, Description=compliance.Description, Context=finding.account_name, + Cluster=finding.account_uid, Namespace=finding.region, AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, @@ -87,6 +88,7 @@ class ProwlerThreatScoreKubernetes(ComplianceOutput): Provider=compliance.Provider.lower(), Description=compliance.Description, Context="", + Cluster="", Namespace="", AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, diff --git a/prowler/lib/outputs/compliance/universal/universal_output.py b/prowler/lib/outputs/compliance/universal/universal_output.py index 5cca376482..9c7c76da47 100644 --- a/prowler/lib/outputs/compliance/universal/universal_output.py +++ b/prowler/lib/outputs/compliance/universal/universal_output.py @@ -30,6 +30,9 @@ PROVIDER_HEADER_MAP = { "e2enetworks": ("ProjectId", "account_uid", "Location", "region"), } _DEFAULT_HEADERS = ("AccountId", "account_uid", "Region", "region") +PROVIDER_EXTRA_HEADER_MAP = { + "kubernetes": (("Cluster", "account_uid"),), +} class UniversalComplianceOutput: @@ -88,11 +91,19 @@ class UniversalComplianceOutput: "Provider": (str, ...), "Description": (str, ...), acct_header: (str, ...), - loc_header: (str, ...), - "AssessmentDate": (str, ...), - "Requirements_Id": (str, ...), - "Requirements_Description": (str, ...), } + for header, _ in PROVIDER_EXTRA_HEADER_MAP.get( + (self._provider or "").lower(), () + ): + fields[header] = (str, ...) + fields.update( + { + loc_header: (str, ...), + "AssessmentDate": (str, ...), + "Requirements_Id": (str, ...), + "Requirements_Description": (str, ...), + } + ) # Dynamic attribute columns from metadata if framework.attributes_metadata: @@ -154,13 +165,17 @@ class UniversalComplianceOutput: self._acct_header: ( getattr(finding, self._acct_field, "") if not is_manual else "" ), - self._loc_header: ( - getattr(finding, self._loc_field, "") if not is_manual else "" - ), "AssessmentDate": str(timestamp), "Requirements_Id": requirement.id, "Requirements_Description": requirement.description, } + for header, field in PROVIDER_EXTRA_HEADER_MAP.get( + (self._provider or "").lower(), () + ): + row[header] = getattr(finding, field, "") if not is_manual else "" + row[self._loc_header] = ( + getattr(finding, self._loc_field, "") if not is_manual else "" + ) # Add dynamic attribute columns if framework.attributes_metadata: diff --git a/prowler/lib/outputs/html/html.py b/prowler/lib/outputs/html/html.py index 1dcbfb5416..60428bede7 100644 --- a/prowler/lib/outputs/html/html.py +++ b/prowler/lib/outputs/html/html.py @@ -463,6 +463,11 @@ class HTML(Output): audited_regions = "All Regions" else: audited_regions = ", ".join(provider.identity.audited_regions) + account = escape(str(provider.identity.account)) + profile = escape(str(profile)) + audited_regions = escape(str(audited_regions)) + user_id = escape(str(provider.identity.user_id)) + identity_arn = escape(str(provider.identity.identity_arn)) return f"""
@@ -471,7 +476,7 @@ class HTML(Output):
  • - AWS Account: {provider.identity.account} + AWS Account: {account}
  • AWS-CLI Profile: {profile} @@ -489,10 +494,10 @@ class HTML(Output):
  • - User Id: {provider.identity.user_id} + User Id: {user_id}
  • - Caller Identity ARN: {provider.identity.identity_arn} + Caller Identity ARN: {identity_arn}
@@ -530,6 +535,11 @@ class HTML(Output): ) else: html_identity = provider.identity.identity_id + tenant_ids = escape(" ".join(provider.identity.tenant_ids)) + tenant_domain = escape(str(provider.identity.tenant_domain)) + subscriptions = escape(" ".join(printed_subscriptions)) + identity_type = escape(str(provider.identity.identity_type)) + html_identity = escape(str(html_identity)) return f"""
@@ -538,13 +548,13 @@ class HTML(Output):
  • - Azure Tenant IDs: {" ".join(provider.identity.tenant_ids)} + Azure Tenant IDs: {tenant_ids}
  • - Azure Tenant Domain: {provider.identity.tenant_domain} + Azure Tenant Domain: {tenant_domain}
  • - Azure Subscriptions: {" ".join(printed_subscriptions)} + Azure Subscriptions: {subscriptions}
@@ -556,7 +566,7 @@ class HTML(Output):
  • - Azure Identity Type: {provider.identity.identity_type} + Azure Identity Type: {identity_type}
  • Azure Identity ID: {html_identity} @@ -591,6 +601,8 @@ class HTML(Output): ) except AttributeError: profile = "default" + project_ids = escape(", ".join(provider.project_ids)) + profile = escape(str(profile)) return f"""
    @@ -599,7 +611,7 @@ class HTML(Output):
    • - GCP Project IDs: {", ".join(provider.project_ids)} + GCP Project IDs: {project_ids}
    @@ -634,6 +646,8 @@ class HTML(Output): str: the HTML assessment summary """ try: + cluster = escape(str(provider.identity.cluster)) + context = escape(str(provider.identity.context)) return f"""
    @@ -643,7 +657,7 @@ class HTML(Output):
    • - Kubernetes Cluster: {provider.identity.cluster} + Kubernetes Cluster: {cluster}
    @@ -656,7 +670,7 @@ class HTML(Output):
    • - Kubernetes Context: {provider.identity.context} + Kubernetes Context: {context}
    @@ -679,11 +693,13 @@ class HTML(Output): str: the HTML assessment summary """ try: + auth_method = escape(str(provider.auth_method)) if hasattr(provider.identity, "account_name"): # GithubIdentityInfo (Personal Access Token, OAuth) + account_name = escape(str(provider.identity.account_name)) account_info_items = f"""
  • - GitHub account: {provider.identity.account_name} + GitHub account: {account_name}
  • """ # Add email if available @@ -691,23 +707,27 @@ class HTML(Output): hasattr(provider.identity, "account_email") and provider.identity.account_email ): + account_email = escape(str(provider.identity.account_email)) account_info_items += f"""
  • - GitHub account email: {provider.identity.account_email} + GitHub account email: {account_email}
  • """ elif hasattr(provider.identity, "app_id"): # GithubAppIdentityInfo (GitHub App) # Assessment items: App Name and Installations + app_name = escape(str(provider.identity.app_name)) account_info_items = f"""
  • - GitHub App Name: {provider.identity.app_name} + GitHub App Name: {app_name}
  • """ # Add installations if available if ( hasattr(provider.identity, "installations") and provider.identity.installations ): - installations_display = ", ".join(provider.identity.installations) + installations_display = escape( + ", ".join(provider.identity.installations) + ) account_info_items += f"""
  • Installations: {installations_display} @@ -719,26 +739,27 @@ class HTML(Output):
  • """ # Credentials items: Authentication method and App ID + app_id = escape(str(provider.identity.app_id)) credentials_items = f"""
  • - GitHub authentication method: {provider.auth_method} + GitHub authentication method: {auth_method}
  • - GitHub App ID: {provider.identity.app_id} + GitHub App ID: {app_id}
  • """ else: # Fallback for other identity types account_info_items = "" credentials_items = f"""
  • - GitHub authentication method: {provider.auth_method} + GitHub authentication method: {auth_method}
  • """ # For PAT/OAuth, use default credentials structure if hasattr(provider.identity, "account_name"): credentials_items = f"""
  • - GitHub authentication method: {provider.auth_method} + GitHub authentication method: {auth_method}
  • """ return f""" @@ -779,6 +800,18 @@ class HTML(Output): str: the HTML assessment summary """ try: + tenant_domain = escape(str(provider.identity.tenant_domain)) + identity_type = escape(str(provider.identity.identity_type)) + identity_id = escape(str(provider.identity.identity_id)) + user_item = "" + if ( + hasattr(provider.identity, "user") + and provider.identity.user is not None + ): + user = escape(str(provider.identity.user)) + user_item = f"""
  • + M365 User: {user} +
  • """ return f"""
    @@ -787,9 +820,7 @@ class HTML(Output):
    • - M365 Tenant Domain: { - provider.identity.tenant_domain - } + M365 Tenant Domain: {tenant_domain}
    @@ -801,19 +832,12 @@ class HTML(Output):
    • - M365 Identity Type: {provider.identity.identity_type} + M365 Identity Type: {identity_type}
    • - M365 Identity ID: {provider.identity.identity_id} + M365 Identity ID: {identity_id}
    • - { - f'''
    • - M365 User: {provider.identity.user} -
    • ''' - if hasattr(provider.identity, "user") - and provider.identity.user is not None - else "" - } + {user_item}
    """ @@ -834,6 +858,9 @@ class HTML(Output): str: the HTML assessment summary """ try: + tenant_domain = escape(str(provider.identity.tenant_domain)) + identity_type = escape(str(provider.identity.identity_type)) + identity_id = escape(str(provider.identity.identity_id)) return f"""
    @@ -842,7 +869,7 @@ class HTML(Output):
    • - NHN Tenant Domain: {provider.identity.tenant_domain} + NHN Tenant Domain: {tenant_domain}
    @@ -854,10 +881,10 @@ class HTML(Output):
    • - NHN Identity Type: {provider.identity.identity_type} + NHN Identity Type: {identity_type}
    • - NHN Identity ID: {provider.identity.identity_id} + NHN Identity ID: {identity_id}
    @@ -880,6 +907,7 @@ class HTML(Output): str: the HTML assessment summary """ try: + organization_name = escape(str(provider.identity.organization_name)) return f"""
    @@ -889,7 +917,7 @@ class HTML(Output):
    • - MongoDB Atlas organization: {provider.identity.organization_name} + MongoDB Atlas organization: {organization_name}
    @@ -925,6 +953,13 @@ class HTML(Output): str: the HTML assessment summary """ try: + if provider.scan_repository_url: + target_info = "IAC repository URL: " + str( + escape(str(provider.scan_repository_url)) + ) + else: + target_info = "IAC path: " + str(escape(str(provider.scan_path))) + auth_method = escape(str(provider.auth_method)) return f"""
    @@ -934,7 +969,7 @@ class HTML(Output):
    • - {"IAC repository URL: " + provider.scan_repository_url if provider.scan_repository_url else "IAC path: " + provider.scan_path} + {target_info}
    @@ -947,7 +982,7 @@ class HTML(Output):
    • - IAC authentication method: {provider.auth_method} + IAC authentication method: {auth_method}
    @@ -971,10 +1006,13 @@ class HTML(Output): """ try: if provider.registry: - target_info = f"Registry URL: {provider.registry}" + registry = escape(str(provider.registry)) + target_info = f"Registry URL: {registry}" else: - target_info = f'Images: {", ".join(provider.images)}' + images = escape(", ".join(provider.images)) + target_info = f"Images: {images}" + auth_method = escape(str(provider.auth_method)) return f"""
    @@ -997,7 +1035,7 @@ class HTML(Output):
    • - Image authentication method: {provider.auth_method} + Image authentication method: {auth_method}
    @@ -1020,6 +1058,14 @@ class HTML(Output): str: HTML assessment summary for the LLM provider """ try: + model = escape(str(provider.model)) + plugins = escape(", ".join(provider.plugins)) + max_concurrency = escape(str(provider.max_concurrency)) + config_file = escape( + str(provider.config_path) + if provider.config_path + else "Using promptfoo defaults" + ) return f"""
    @@ -1031,16 +1077,16 @@ class HTML(Output):
    • - Target LLM: {provider.model} + Target LLM: {model}
    • - Plugins: {", ".join(provider.plugins)} + Plugins: {plugins}
    • - Max concurrency: {provider.max_concurrency} + Max concurrency: {max_concurrency}
    • - Config file: {provider.config_path if provider.config_path else "Using promptfoo defaults"} + Config file: {config_file}
    @@ -1069,6 +1115,10 @@ class HTML(Output): tenancy_name = getattr(provider.identity, "tenancy_name", "unknown") tenancy_id = getattr(provider.identity, "tenancy_id", "unknown") + tenancy = escape( + str(tenancy_name if tenancy_name != "unknown" else tenancy_id) + ) + profile = escape(str(profile)) return f"""
    @@ -1077,7 +1127,7 @@ class HTML(Output):
    • - OracleCloud Tenancy: {tenancy_name if tenancy_name != "unknown" else tenancy_id} + OracleCloud Tenancy: {tenancy}
    @@ -1116,10 +1166,11 @@ class HTML(Output): project_name = getattr(provider.identity, "project_name", "") audited_regions = getattr(provider.identity, "audited_regions", set()) + project_id = escape(str(project_id)) project_name_item = ( f"""
  • - Project Name: {project_name} + Project Name: {escape(str(project_name))}
  • """ if project_name else "" @@ -1128,7 +1179,7 @@ class HTML(Output): regions_item = ( f"""
  • - Regions: {", ".join(sorted(audited_regions))} + Regions: {escape(", ".join(sorted(audited_regions)))}
  • """ if audited_regions else "" @@ -1182,7 +1233,7 @@ class HTML(Output): # Build assessment summary items (only non-None values) assessment_items = "" if provider.accounts: - accounts = ", ".join([acc.id for acc in provider.accounts]) + accounts = escape(", ".join([str(acc.id) for acc in provider.accounts])) assessment_items += f"""
  • Accounts: {accounts} @@ -1208,6 +1259,7 @@ class HTML(Output): provider.session, "api_email", None ) if email: + email = escape(str(email)) credentials_items += f"""
  • Email: {email} @@ -1261,11 +1313,15 @@ class HTML(Output): account_name_item = ( f"""
  • - Account Name: {account_name} + Account Name: {escape(str(account_name))}
  • """ if account_name else "" ) + account_id = escape(str(account_id)) + audited_regions = escape(str(audited_regions)) + user_name = escape(str(user_name)) + identity_arn = escape(str(identity_arn)) return f"""
    @@ -1326,7 +1382,7 @@ class HTML(Output): project_name_item = ( f"""
  • - Project Name: {project_name} + Project Name: {escape(str(project_name))}
  • """ if project_name else "" @@ -1335,11 +1391,14 @@ class HTML(Output): user_id_item = ( f"""
  • - User ID: {user_id} + User ID: {escape(str(user_id))}
  • """ if user_id else "" ) + project_id = escape(str(project_id)) + region_name = escape(str(region_name)) + username = escape(str(username)) return f"""
    @@ -1389,6 +1448,9 @@ class HTML(Output): str: HTML assessment summary for the Google Workspace provider """ try: + domain = escape(str(provider.identity.domain)) + customer_id = escape(str(provider.identity.customer_id)) + delegated_user = escape(str(provider.identity.delegated_user)) return f"""
    @@ -1397,10 +1459,10 @@ class HTML(Output):
    • - Domain: {provider.identity.domain} + Domain: {domain}
    • - Customer ID: {provider.identity.customer_id} + Customer ID: {customer_id}
    @@ -1412,7 +1474,7 @@ class HTML(Output):
    • - Delegated User: {provider.identity.delegated_user} + Delegated User: {delegated_user}
    • Authentication Method: Service Account with Domain-Wide Delegation @@ -1482,9 +1544,11 @@ class HTML(Output): team = getattr(provider.identity, "team", None) if team: + team_name = escape(str(team.name)) + team_id = escape(str(team.id)) assessment_items += f"""
    • - Team: {team.name} ({team.id}) + Team: {team_name} ({team_id})
    • """ credentials_items = """ @@ -1494,6 +1558,7 @@ class HTML(Output): email = getattr(provider.identity, "email", None) if email: + email = escape(str(email)) credentials_items += f"""
    • Email: {email} @@ -1501,6 +1566,7 @@ class HTML(Output): username = getattr(provider.identity, "username", None) if username: + username = escape(str(username)) credentials_items += f"""
    • Username: {username} @@ -1543,17 +1609,20 @@ class HTML(Output): str: HTML assessment summary for the Okta provider """ try: + org_domain = escape(str(provider.identity.org_domain)) + auth_method = escape(str(provider.auth_method)) + client_id = escape(str(provider.identity.client_id)) assessment_items = f"""
    • - Okta Domain: {provider.identity.org_domain} + Okta Domain: {org_domain}
    • """ credentials_items = f"""
    • - Authentication: {provider.auth_method} + Authentication: {auth_method}
    • - Client ID: {provider.identity.client_id} + Client ID: {client_id}
    • """ return f""" @@ -1593,9 +1662,10 @@ class HTML(Output): str: HTML assessment summary for the Scaleway provider """ try: + organization_id = escape(str(provider.identity.organization_id)) assessment_items = f"""
    • - Organization ID: {provider.identity.organization_id} + Organization ID: {organization_id}
    • """ credentials_items = """ @@ -1605,6 +1675,7 @@ class HTML(Output): access_key = getattr(provider.session, "access_key", None) if access_key: + access_key = escape(str(access_key)) credentials_items += f"""
    • Access Key: {access_key} @@ -1615,6 +1686,8 @@ class HTML(Output): bearer_id = getattr(provider.identity, "bearer_id", None) if bearer_type: bearer_label = bearer_email or bearer_id or "-" + bearer_type = escape(str(bearer_type)) + bearer_label = escape(str(bearer_label)) credentials_items += f"""
    • Bearer: {bearer_type} ({bearer_label}) @@ -1622,6 +1695,7 @@ class HTML(Output): region = getattr(provider.session, "default_region", None) if region: + region = escape(str(region)) credentials_items += f"""
    • Default Region: {region} @@ -1668,6 +1742,9 @@ class HTML(Output): email = getattr(provider.identity, "email", None) or "-" account_id = getattr(provider.identity, "account_id", None) or "-" + username = escape(str(username)) + email = escape(str(email)) + account_id = escape(str(account_id)) assessment_items = f"""
    • Account ID: {account_id} @@ -1732,6 +1809,14 @@ class HTML(Output): audited_regions = "All Regions" else: audited_regions = ", ".join(provider.identity.regions) + account_id = escape(str(provider.identity.account_id)) + account_name = escape(str(provider.identity.account_name)) + profile = escape(str(profile)) + audited_regions = escape(str(audited_regions)) + domain_id = escape(str(provider.identity.domain_id)) + user_id = escape(str(provider.identity.user_id)) + user_name = escape(str(provider.identity.user_name)) + identity_type = escape(str(provider.identity.identity_type)) return f"""
      @@ -1740,10 +1825,10 @@ class HTML(Output):
      • - Account ID: {provider.identity.account_id} + Account ID: {account_id}
      • - Account Name: {provider.identity.account_name} + Account Name: {account_name}
      • Profile: {profile} @@ -1761,16 +1846,16 @@ class HTML(Output):
      • - Domain ID: {provider.identity.domain_id} + Domain ID: {domain_id}
      • - User ID: {provider.identity.user_id} + User ID: {user_id}
      • - User Name: {provider.identity.user_name} + User Name: {user_name}
      • - Identity Type: {provider.identity.identity_type} + Identity Type: {identity_type}
    diff --git a/prowler/lib/outputs/jira/exceptions/exceptions.py b/prowler/lib/outputs/jira/exceptions/exceptions.py index 0f8aef1efd..adfbf2c183 100644 --- a/prowler/lib/outputs/jira/exceptions/exceptions.py +++ b/prowler/lib/outputs/jira/exceptions/exceptions.py @@ -94,6 +94,14 @@ class JiraBaseException(ProwlerException): "message": "Jira project requires custom fields that are not supported.", "remediation": "Please configure the Jira project to not require custom fields, or use a different project.", }, + (9022, "JiraGetIssuesStatusError"): { + "message": "Failed to get the issues status from Jira.", + "remediation": "Please check the connection settings and permissions and try again.", + }, + (9023, "JiraGetIssuesStatusResponseError"): { + "message": "Failed to get the issues status from Jira, response code did not match 200.", + "remediation": "Please check the connection settings and permissions and try again.", + }, } def __init__(self, code, file=None, original_exception=None, message=None): @@ -262,3 +270,17 @@ class JiraRequiredCustomFieldsError(JiraBaseException): super().__init__( 9021, file=file, original_exception=original_exception, message=message ) + + +class JiraGetIssuesStatusError(JiraBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 9022, file=file, original_exception=original_exception, message=message + ) + + +class JiraGetIssuesStatusResponseError(JiraBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 9023, file=file, original_exception=original_exception, message=message + ) diff --git a/prowler/lib/outputs/jira/jira.py b/prowler/lib/outputs/jira/jira.py index 9fb4c7d6ad..a6100b54e1 100644 --- a/prowler/lib/outputs/jira/jira.py +++ b/prowler/lib/outputs/jira/jira.py @@ -1,8 +1,12 @@ import base64 +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 @@ -36,6 +40,16 @@ from prowler.lib.outputs.jira.exceptions.exceptions import ( JiraSendFindingsResponseError, JiraTestConnectionError, ) +from prowler.lib.outputs.jira.models import ( + JiraCreationOutcome, + JiraCreationResult, + JiraIssueLookupOutcome, + JiraIssueReference, + JiraIssueSearchMatch, + JiraIssueSearchOutcome, + JiraIssueSearchResult, + JiraIssueStatusResult, +) from prowler.providers.common.models import Connection ATLASSIAN_SITE_NAME_REGEX = re.compile( @@ -368,6 +382,7 @@ class Jira: _refresh_token: str = None _expiration_date: int = None _cloud_id: str = None + _site_url: str = None _scopes: list[str] = None AUTH_URL = "https://auth.atlassian.com/authorize" PARAMS_TEMPLATE = { @@ -382,6 +397,12 @@ class Jira: TOKEN_URL = "https://auth.atlassian.com/oauth/token" API_TOKEN_URL = "https://api.atlassian.com/oauth/token/accessible-resources" REQUEST_TIMEOUT = 90 + ISSUE_STATUS_BATCH_SIZE = 100 + LABEL_MAX_LENGTH = 255 + FINDING_LABEL_PREFIX = "prowler-finding" + DELIVERY_ATTEMPT_LABEL_PREFIX = "prowler-attempt" + ISSUE_KEY_REGEX = re.compile(r"^[A-Z][A-Z0-9_]*-[0-9]+$") + ISSUE_ID_REGEX = re.compile(r"^[0-9]+$") HEADER_TEMPLATE = { "Content-Type": "application/json", "X-Force-Accept-Language": "true", @@ -397,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 @@ -404,6 +426,7 @@ class Jira: self._api_token = api_token self._domain = domain self._scopes = ["read:jira-user", "read:jira-work", "write:jira-work"] + self._validated_destinations: set[tuple[str, str]] = set() # If the client mail, API token and site name are present, use basic auth if user_mail and api_token and domain: self._using_basic_auth = True @@ -432,6 +455,255 @@ class Jira: """ return " ".join(summary.split())[:255] + @property + def site_url(self) -> Optional[str]: + """Base URL of the Jira site, used to build issue browse links. + + Basic auth derives it from the configured site name; OAuth captures it + from the accessible-resources response when resolving the cloud id. + """ + if self._using_basic_auth and self._domain: + return f"https://{self._domain}.atlassian.net" + return self._site_url + + def get_issue_url(self, issue_key: str) -> Optional[str]: + """Build the browse URL for an issue key, or None if the site is unknown.""" + site_url = self.site_url + if not site_url or not issue_key: + return None + return f"{site_url.rstrip('/')}/browse/{issue_key}" + + @staticmethod + def sanitize_label(label: Optional[str]) -> str: + """Make a value safe to use as a Jira label. + + Jira rejects labels containing whitespace and longer than 255 + characters. The transformation is deterministic so the same input always + yields the same label: whitespace runs become a single underscore, + control characters are dropped and the result is truncated. + + Args: + label: Raw label text. + + Returns: + The sanitized label, or an empty string if nothing usable remains. + """ + if not label: + return "" + cleaned = "".join(ch for ch in str(label) if ch.isprintable() or ch.isspace()) + collapsed = re.sub(r"_+", "_", "_".join(cleaned.split())) + return collapsed.strip("_")[: Jira.LABEL_MAX_LENGTH] + + @classmethod + def sanitize_labels(cls, labels: Optional[list[str]]) -> list[str]: + """Sanitize a list of labels, dropping empties and duplicates (order kept).""" + result: list[str] = [] + for label in labels or []: + sanitized = cls.sanitize_label(label) + if sanitized and sanitized not in result: + result.append(sanitized) + return result + + @classmethod + def _build_prefixed_label(cls, prefix: str, raw_value: Optional[str]) -> str: + """Build a deterministic Jira label with collision-safe truncation.""" + if not raw_value: + return "" + raw_value = str(raw_value) + readable_value = cls.sanitize_label(raw_value) + if not readable_value: + return "" + complete_label = f"{prefix}-{readable_value}" + if len(complete_label) <= cls.LABEL_MAX_LENGTH: + return complete_label + + digest = hashlib.sha256(raw_value.encode("utf-8")).hexdigest() + readable_length = cls.LABEL_MAX_LENGTH - len(prefix) - len(digest) - 2 + readable_prefix = readable_value[:readable_length].rstrip("_") + return f"{prefix}-{readable_prefix}-{digest}" + + @classmethod + def build_finding_label(cls, finding_uid: Optional[str]) -> str: + """Build the stable label used to identify a Prowler finding.""" + return cls._build_prefixed_label(cls.FINDING_LABEL_PREFIX, finding_uid) + + @classmethod + def build_delivery_attempt_label(cls, marker: Optional[str]) -> str: + """Build the stable label used to reconcile a delivery attempt.""" + return cls._build_prefixed_label(cls.DELIVERY_ATTEMPT_LABEL_PREFIX, marker) + + @staticmethod + def _retry_after(response: requests.Response) -> Optional[str]: + """Return Jira's Retry-After header without parsing or normalizing it.""" + headers = getattr(response, "headers", None) + if not isinstance(headers, Mapping): + return None + retry_after = headers.get("Retry-After") + return str(retry_after) if retry_after is not None else None + + @staticmethod + def _response_json(response: requests.Response) -> object: + """Decode a Jira response, returning None for invalid JSON.""" + try: + return response.json() + except (ValueError, requests.exceptions.JSONDecodeError): + return None + + def _issue_identity( + self, issue: object + ) -> tuple[Optional[str], Optional[str], Optional[str]]: + """Return individually validated Jira issue identity fields.""" + issue = issue if isinstance(issue, dict) else {} + issue_id = issue.get("id") + issue_key = issue.get("key") + if not isinstance(issue_id, str) or not self.ISSUE_ID_REGEX.fullmatch(issue_id): + issue_id = None + if not isinstance(issue_key, str) or not self.ISSUE_KEY_REGEX.fullmatch( + issue_key + ): + issue_key = None + return issue_id, issue_key, self.get_issue_url(issue_key) if issue_key else None + + def _classify_creation_response( + self, + response: requests.Response, + delivery_marker: Optional[str] = None, + ) -> JiraCreationResult: + """Classify Jira's create-issue response without inferring delivery.""" + status_code = response.status_code + retry_after = self._retry_after(response) + response_json = self._response_json(response) + response_details = { + "delivery_marker": delivery_marker, + "http_status": status_code, + "retry_after": retry_after, + } + + if status_code == 201: + if not isinstance(response_json, dict): + return JiraCreationResult( + outcome=JiraCreationOutcome.UNCERTAIN, + error_code="malformed_success_response", + error_message="Jira returned an invalid successful creation response.", + **response_details, + ) + + issue_id, issue_key, issue_url = self._issue_identity(response_json) + if not issue_key or not issue_id or not issue_url: + missing_fields = [ + field + for field, value in zip( + ("key", "id", "url"), (issue_key, issue_id, issue_url) + ) + if not value + ] + return JiraCreationResult( + outcome=JiraCreationOutcome.UNCERTAIN, + issue_key=issue_key, + issue_id=issue_id, + issue_url=issue_url, + error_code="incomplete_success_response", + error_message=( + "Jira confirmed creation without a usable " + f"{', '.join(missing_fields)}." + ), + **response_details, + ) + return JiraCreationResult( + outcome=JiraCreationOutcome.CONFIRMED_SUCCESS, + issue_key=issue_key, + issue_id=issue_id, + issue_url=issue_url, + **response_details, + ) + + error_message = _format_jira_issue_creation_error(response_json, status_code) + if status_code == 429: + outcome = JiraCreationOutcome.RETRYABLE_FAILURE + error_code = "rate_limited" + elif status_code == 408: + outcome = JiraCreationOutcome.UNCERTAIN + error_code = "jira_http_408" + elif 400 <= status_code < 500: + outcome = JiraCreationOutcome.CONFIRMED_REJECTION + error_code = f"jira_http_{status_code}" + else: + outcome = JiraCreationOutcome.UNCERTAIN + error_code = f"jira_http_{status_code}" + return JiraCreationResult( + outcome=outcome, + error_code=error_code, + error_message=error_message, + **response_details, + ) + + @staticmethod + def _creation_transport_result( + exception: requests.exceptions.RequestException, + delivery_marker: Optional[str], + ) -> JiraCreationResult: + """Classify create-issue transport failures by delivery certainty.""" + if isinstance(exception, requests.exceptions.ConnectTimeout): + outcome = JiraCreationOutcome.RETRYABLE_FAILURE + error_code = "connect_timeout" + error_message = "Jira could not be reached before the request was sent." + else: + outcome = JiraCreationOutcome.UNCERTAIN + error_code = "ambiguous_transport_failure" + error_message = "Jira did not confirm whether it created the issue." + return JiraCreationResult( + outcome=outcome, + delivery_marker=delivery_marker, + error_code=error_code, + error_message=error_message, + ) + + @staticmethod + def _destination_validation_result( + error: Exception, + delivery_marker: Optional[str], + ) -> JiraCreationResult: + """Classify a catalog failure that happened before issue creation.""" + errors = (error, getattr(error, "original_exception", None)) + no_projects = any(isinstance(item, JiraNoProjectsError) for item in errors) + http_status = next( + (item.http_status for item in errors if hasattr(item, "http_status")), None + ) + retry_after = next( + (item.retry_after for item in errors if hasattr(item, "retry_after")), None + ) + + if no_projects: + outcome = JiraCreationOutcome.CONFIRMED_REJECTION + error_code = "invalid_project" + error_message = "The Jira project key is invalid." + elif http_status is not None and 400 <= http_status < 500: + if http_status in (408, 429): + outcome = JiraCreationOutcome.RETRYABLE_FAILURE + error_code = "destination_temporarily_unavailable" + else: + outcome = JiraCreationOutcome.CONFIRMED_REJECTION + error_code = ( + "invalid_credentials" + if http_status in (401, 403) + else "destination_rejected" + ) + error_message = "The Jira destination could not be validated." + else: + outcome = JiraCreationOutcome.RETRYABLE_FAILURE + error_code = "destination_validation_failed" + error_message = ( + "The Jira destination could not be validated before sending." + ) + return JiraCreationResult( + outcome=outcome, + delivery_marker=delivery_marker, + http_status=http_status, + retry_after=retry_after, + error_code=error_code, + error_message=error_message, + ) + @staticmethod def _build_code_block_content(code_value: str) -> Optional[Dict]: if not code_value: @@ -707,6 +979,7 @@ class Jira: if response.status_code == 200: resources = response.json() if len(resources) > 0: + self._site_url = resources[0].get("url") return resources[0].get("id") else: error_message = ( @@ -747,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 @@ -764,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 @@ -858,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 @@ -939,7 +1228,10 @@ class Jira: access_token = self.get_access_token() if not access_token: - return ValueError("Failed to get access token") + raise JiraNoTokenError( + message="No token was found", + file=os.path.basename(__file__), + ) headers = self.get_headers(access_token) @@ -965,10 +1257,13 @@ class Jira: logger.error( f"Failed to get projects: {response.status_code} - {response.text}" ) - raise JiraGetProjectsResponseError( + response_error = JiraGetProjectsResponseError( message="Failed to get projects from Jira", file=os.path.basename(__file__), ) + response_error.http_status = response.status_code + response_error.retry_after = self._retry_after(response) + raise response_error except JiraNoProjectsError as no_projects_error: raise no_projects_error except JiraRefreshTokenError as refresh_error: @@ -980,6 +1275,7 @@ class Jira: raise JiraGetProjectsError( message="Failed to get projects from Jira", file=os.path.basename(__file__), + original_exception=e, ) def get_available_issue_types(self, project_key: str = None) -> list[str]: @@ -1004,7 +1300,7 @@ class Jira: access_token = self.get_access_token() if not access_token: - return JiraNoTokenError( + raise JiraNoTokenError( message="No token was found", file=os.path.basename(__file__), ) @@ -1019,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__), @@ -1029,18 +1328,29 @@ class Jira: else: response_error = f"Failed to get available issue types: {response.status_code} - {response.text}" logger.error(response_error) - raise JiraGetAvailableIssueTypesResponseError( + response_error = JiraGetAvailableIssueTypesResponseError( message=response_error, file=os.path.basename(__file__) ) + response_error.http_status = response.status_code + response_error.retry_after = self._retry_after(response) + raise response_error except JiraRefreshTokenError as refresh_error: 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( message="Failed to get available issue types", file=os.path.basename(__file__), + original_exception=e, ) def get_metadata(self) -> dict: @@ -1290,7 +1600,6 @@ class Jira: finding_url: str = "", tenant_info: str = "", ) -> dict: - # ADF forbids empty text nodes, so Jira rejects them with 400 INVALID_INPUT. def _safe(value: str) -> str: return value if (value and value.strip()) else "-" @@ -2195,12 +2504,383 @@ class Jira: return {"type": "doc", "version": 1, "content": content} + @staticmethod + def _unknown_issue_status( + reference: JiraIssueReference, + *, + http_status: Optional[int] = None, + retry_after: Optional[str] = None, + error_code: str = "unknown", + error_message: str = "Jira could not confirm the issue status.", + ) -> JiraIssueStatusResult: + """Build a safe unknown status without implying deletion.""" + return JiraIssueStatusResult( + reference=reference, + outcome=JiraIssueLookupOutcome.UNKNOWN, + http_status=http_status, + retry_after=retry_after, + error_code=error_code, + error_message=error_message, + ) + + def _set_unknown_issue_statuses( + self, + results: dict[JiraIssueReference, JiraIssueStatusResult], + references: list[JiraIssueReference], + **details, + ) -> None: + """Assign the same unknown observation to a group of references.""" + for reference in references: + results[reference] = self._unknown_issue_status(reference, **details) + + def _resolved_issue_status( + self, + reference: JiraIssueReference, + issue: dict, + retry_after: Optional[str], + ) -> JiraIssueStatusResult: + """Classify one issue returned by Jira's bulk fetch endpoint.""" + malformed = { + "http_status": 200, + "retry_after": retry_after, + "error_code": "malformed_issue", + "error_message": "Jira returned malformed issue data.", + } + issue_id = str(issue.get("id")) + issue_key = issue.get("key") + if not isinstance(issue_key, str) or not self.ISSUE_KEY_REGEX.fullmatch( + issue_key + ): + return self._unknown_issue_status(reference, **malformed) + + fields = issue.get("fields") + status = fields.get("status") if isinstance(fields, dict) else None + category = status.get("statusCategory") if isinstance(status, dict) else None + category_key = category.get("key") if isinstance(category, dict) else None + status_name = status.get("name") if isinstance(status, dict) else None + if issue_key != reference.issue_key: + outcome = JiraIssueLookupOutcome.MOVED + else: + outcome = { + "new": JiraIssueLookupOutcome.OPEN, + "indeterminate": JiraIssueLookupOutcome.OPEN, + "done": JiraIssueLookupOutcome.DONE, + }.get(category_key) + if not outcome or not isinstance(status_name, str): + return self._unknown_issue_status(reference, **malformed) + + return JiraIssueStatusResult( + reference=reference, + outcome=outcome, + current_issue_id=issue_id, + current_issue_key=issue_key, + current_issue_url=self.get_issue_url(issue_key), + status=status_name if isinstance(status_name, str) else None, + status_category=category_key if isinstance(category_key, str) else None, + http_status=200, + retry_after=retry_after, + ) + + def get_issues_status( + self, issue_references: list[JiraIssueReference] + ) -> list[JiraIssueStatusResult]: + """Resolve unique Jira references by ID without inferring deletion.""" + references = list(dict.fromkeys(issue_references or [])) + if not references: + return [] + + results: dict[JiraIssueReference, JiraIssueStatusResult] = {} + valid_references: list[JiraIssueReference] = [] + for reference in references: + if ( + not reference.issue_id + or not isinstance(reference.issue_id, str) + or not self.ISSUE_ID_REGEX.fullmatch(reference.issue_id) + or not isinstance(reference.issue_key, str) + or not self.ISSUE_KEY_REGEX.fullmatch(reference.issue_key) + ): + results[reference] = self._unknown_issue_status( + reference, + error_code="invalid_reference", + error_message="The stored Jira issue reference is invalid.", + ) + else: + valid_references.append(reference) + + if valid_references: + try: + access_token = self.get_access_token() + except ( + JiraRefreshTokenError, + JiraRefreshTokenResponseError, + JiraGetAccessTokenError, + ): + access_token = None + if not access_token: + self._set_unknown_issue_statuses( + results, + valid_references, + error_code="authentication_failed", + error_message="Jira authentication failed during status lookup.", + ) + return [results[reference] for reference in references] + + headers = self.get_headers(access_token, content_type_json=True) + for start in range(0, len(valid_references), self.ISSUE_STATUS_BATCH_SIZE): + batch = valid_references[start : start + self.ISSUE_STATUS_BATCH_SIZE] + try: + response = requests.post( + f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/3/issue/bulkfetch", + json={ + "issueIdsOrKeys": [ + reference.issue_id for reference in batch + ], + "fields": ["status"], + }, + headers=headers, + timeout=self.REQUEST_TIMEOUT, + ) + except requests.exceptions.RequestException: + self._set_unknown_issue_statuses( + results, + batch, + error_code="transport_failure", + error_message="Jira status lookup failed in transit.", + ) + continue + + retry_after = self._retry_after(response) + if response.status_code != 200: + self._set_unknown_issue_statuses( + results, + batch, + http_status=response.status_code, + retry_after=retry_after, + error_code=f"jira_http_{response.status_code}", + ) + continue + + response_json = self._response_json(response) + if not isinstance(response_json, dict) or not isinstance( + response_json.get("issues", []), list + ): + self._set_unknown_issue_statuses( + results, + batch, + http_status=200, + retry_after=retry_after, + error_code="malformed_response", + error_message="Jira returned a malformed status response.", + ) + continue + + references_by_id: dict[str, list[JiraIssueReference]] = {} + for reference in batch: + references_by_id.setdefault(reference.issue_id, []).append( + reference + ) + + for issue in response_json.get("issues", []): + if not isinstance(issue, dict): + continue + current_issue_id = issue.get("id") + matching_references = references_by_id.get( + str(current_issue_id), [] + ) + if not matching_references: + continue + for reference in matching_references: + results[reference] = self._resolved_issue_status( + reference, issue, retry_after + ) + + issue_errors = response_json.get("issueErrors", []) + if isinstance(issue_errors, list): + for issue_error in issue_errors: + if not isinstance(issue_error, dict): + continue + error_reference = issue_error.get( + "issueIdOrKey", + issue_error.get("issueId", issue_error.get("id")), + ) + matching_references = references_by_id.get( + str(error_reference), [] + ) + error_status = issue_error.get( + "statusCode", + issue_error.get("status", issue_error.get("errorCode")), + ) + try: + error_status = int(error_status) + except (TypeError, ValueError): + error_status = None + outcome = { + 403: JiraIssueLookupOutcome.FORBIDDEN, + 404: JiraIssueLookupOutcome.MISSING, + }.get(error_status) + if outcome: + for reference in matching_references: + results[reference] = JiraIssueStatusResult( + reference=reference, + outcome=outcome, + http_status=error_status, + retry_after=retry_after, + error_code=f"jira_http_{error_status}", + error_message="Jira could not return the issue.", + ) + + for reference in batch: + if reference not in results: + results[reference] = self._unknown_issue_status( + reference, + http_status=200, + retry_after=retry_after, + error_code="omitted_issue", + error_message="Jira omitted the issue from its response.", + ) + + return [results[reference] for reference in references] + + @staticmethod + def _escape_jql_string(value: str) -> str: + """Escape a string used inside a quoted JQL value.""" + return value.replace("\\", "\\\\").replace('"', '\\"') + + def search_issues_by_delivery_attempt( + self, delivery_attempt_marker: Optional[str] + ) -> JiraIssueSearchResult: + """Search Jira by a caller-owned delivery marker.""" + matches: list[JiraIssueSearchMatch] = [] + + def result( + outcome: JiraIssueSearchOutcome, + *, + error_code: Optional[str] = None, + error_message: Optional[str] = None, + response: Optional[requests.Response] = None, + ) -> JiraIssueSearchResult: + return JiraIssueSearchResult( + outcome=outcome, + matches=tuple(matches), + http_status=getattr(response, "status_code", None), + retry_after=( + self._retry_after(response) if response is not None else None + ), + error_code=error_code, + error_message=error_message, + ) + + attempt_label = self.build_delivery_attempt_label(delivery_attempt_marker) + if not attempt_label: + return result( + JiraIssueSearchOutcome.UNKNOWN, + error_code="invalid_delivery_marker", + error_message="The Jira delivery marker is empty.", + ) + try: + access_token = self.get_access_token() + except ( + JiraRefreshTokenError, + JiraRefreshTokenResponseError, + JiraGetAccessTokenError, + ): + access_token = None + if not access_token: + return result( + JiraIssueSearchOutcome.UNKNOWN, + error_code="authentication_failed", + error_message="Jira authentication failed during marker lookup.", + ) + + headers = self.get_headers(access_token, content_type_json=True) + payload = { + "jql": f'labels = "{self._escape_jql_string(attempt_label)}"', + "fields": ["key"], + "maxResults": 100, + } + seen_matches: set[tuple[str, str]] = set() + seen_page_tokens: set[str] = set() + while True: + try: + response = requests.post( + f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/3/search/jql", + json=payload, + headers=headers, + timeout=self.REQUEST_TIMEOUT, + ) + except requests.exceptions.RequestException: + return result( + JiraIssueSearchOutcome.RETRYABLE_FAILURE, + error_code="transport_failure", + error_message="Jira marker lookup failed in transit.", + ) + + if response.status_code != 200: + retryable = response.status_code == 429 or response.status_code >= 500 + return result( + ( + JiraIssueSearchOutcome.RETRYABLE_FAILURE + if retryable + else JiraIssueSearchOutcome.UNKNOWN + ), + error_code=f"jira_http_{response.status_code}", + error_message="Jira marker lookup failed.", + response=response, + ) + + response_json = self._response_json(response) + if not isinstance(response_json, dict) or not isinstance( + response_json.get("issues"), list + ): + return result( + JiraIssueSearchOutcome.UNKNOWN, + error_code="malformed_response", + error_message="Jira returned a malformed marker lookup response.", + response=response, + ) + + for issue in response_json["issues"]: + issue_id, issue_key, issue_url = self._issue_identity(issue) + if not issue_id or not issue_key or not issue_url: + return result( + JiraIssueSearchOutcome.UNKNOWN, + error_code="malformed_issue", + error_message="Jira returned malformed marker lookup data.", + response=response, + ) + identity = (issue_id, issue_key) + if identity not in seen_matches: + seen_matches.add(identity) + matches.append( + JiraIssueSearchMatch( + issue_id=issue_id, + issue_key=issue_key, + issue_url=issue_url, + ) + ) + + next_page_token = response_json.get("nextPageToken") + if not next_page_token: + return result(JiraIssueSearchOutcome.SUCCESS, response=response) + if ( + not isinstance(next_page_token, str) + or next_page_token in seen_page_tokens + ): + return result( + JiraIssueSearchOutcome.UNKNOWN, + error_code="pagination_stalled", + error_message="Jira marker lookup pagination did not complete.", + response=response, + ) + seen_page_tokens.add(next_page_token) + payload["nextPageToken"] = next_page_token + def send_findings( self, findings: list[Finding] = None, project_key: str = None, issue_type: str = None, - issue_labels: list[str] = None, + issue_labels: Optional[list[str]] = None, finding_url: str = None, tenant_info: str = None, ): @@ -2300,6 +2980,7 @@ class Jira: "customfield_10088": {"value": "Core"}, } } + issue_labels = self.sanitize_labels(issue_labels) if issue_labels: payload["fields"]["labels"] = issue_labels @@ -2310,25 +2991,25 @@ class Jira: timeout=self.REQUEST_TIMEOUT, ) - if response.status_code != 201: - try: - response_json = response.json() - except (ValueError, requests.exceptions.JSONDecodeError): - response_error = f"Failed to send finding: {response.status_code} - {response.text}" - logger.error(response_error) - raise JiraSendFindingsResponseError( - message=response_error, file=os.path.basename(__file__) - ) + creation_result = self._classify_creation_response(response) + if not creation_result.is_confirmed_success: + response_json = self._response_json(response) or {} # Check if the error is due to required custom fields - if response.status_code == 400 and "errors" in response_json: + if ( + response.status_code == 400 + and isinstance(response_json, dict) + and "errors" in response_json + ): errors = response_json.get("errors", {}) # Look for custom field errors (fields starting with "customfield_") - custom_field_errors = { - k: v - for k, v in errors.items() - if k.startswith("customfield_") - } + custom_field_errors = {} + if isinstance(errors, dict): + custom_field_errors = { + k: v + for k, v in errors.items() + if k.startswith("customfield_") + } if custom_field_errors: custom_fields_formatted = ", ".join( [ @@ -2341,19 +3022,19 @@ class Jira: file=os.path.basename(__file__), ) - response_error = f"Failed to send finding: {response.status_code} - {response_json}" + response_error = ( + creation_result.error_message + or "Jira did not confirm issue creation." + ) logger.error(response_error) raise JiraSendFindingsResponseError( message=response_error, file=os.path.basename(__file__) ) else: - try: - response_json = response.json() - logger.info(f"Finding sent successfully: {response_json}") - except (ValueError, requests.exceptions.JSONDecodeError): - logger.info( - f"Finding sent successfully: Status {response.status_code}" - ) + logger.info( + "Finding sent successfully: %s", + creation_result.issue_key, + ) except JiraRequiredCustomFieldsError as custom_fields_error: raise custom_fields_error except JiraRefreshTokenError as refresh_error: @@ -2391,7 +3072,8 @@ class Jira: compliance: dict = "", project_key: str = "", issue_type: str = "", - issue_labels: list[str] = "", + issue_labels: Optional[list[str]] = None, + delivery_attempt_marker: Optional[str] = None, finding_url: str = "", tenant_info: str = "", affected_failing_resources: int = 0, @@ -2402,7 +3084,7 @@ class Jira: failing_for: str = "", finding_group_url: str = "", finding_group_link_text: str = "", - ) -> bool: + ) -> JiraCreationResult: """ Send the finding to Jira @@ -2430,6 +3112,8 @@ class Jira: - project_key: The project key - issue_type: The issue type - issue_labels: The issue labels + - delivery_attempt_marker: Caller-owned marker used to reconcile an + ambiguous delivery attempt - finding_url: The finding URL - tenant_info: The tenant info - affected_failing_resources: The number of affected failing resources @@ -2442,43 +3126,43 @@ class Jira: - finding_group_url: The finding group URL - finding_group_link_text: The link text for the finding group URL - Raises: - - JiraRefreshTokenError: Failed to refresh the access token - - JiraRefreshTokenResponseError: Failed to refresh the access token, response code did not match 200 - - JiraNoTokenError: Failed to get an access token - - JiraCreateIssueError: Failed to create an issue in Jira - - JiraSendFindingsResponseError: Failed to send the finding to Jira - - JiraRequiredCustomFieldsError: Jira project requires custom fields that are not supported - Returns: - - True if the finding was sent successfully - - False if the finding was not sent successfully + - A typed creation result. Only ``confirmed_success`` proves that + Jira created the issue. """ try: access_token = self.get_access_token() if not access_token: - raise JiraNoTokenError( - message="No token was found", - file=os.path.basename(__file__), + return JiraCreationResult( + outcome=JiraCreationOutcome.CONFIRMED_REJECTION, + delivery_marker=delivery_attempt_marker, + error_code="missing_credentials", + error_message="Jira credentials are unavailable.", ) - projects = self.get_projects() + destination = (project_key, issue_type) + if destination not in self._validated_destinations: + projects = self.get_projects() + if project_key not in projects: + logger.error("The project key is invalid") + return JiraCreationResult( + outcome=JiraCreationOutcome.CONFIRMED_REJECTION, + delivery_marker=delivery_attempt_marker, + error_code="invalid_project", + error_message="The Jira project key is invalid.", + ) - if project_key not in projects: - logger.error("The project key is invalid") - raise JiraInvalidProjectKeyError( - message="The project key is invalid", - file=os.path.basename(__file__), - ) - - available_issue_types = self.get_available_issue_types(project_key) - - if issue_type not in available_issue_types: - logger.error("The issue type is invalid") - raise JiraInvalidIssueTypeError( - message="The issue type is invalid", file=os.path.basename(__file__) - ) + available_issue_types = self.get_available_issue_types(project_key) + if issue_type not in available_issue_types: + logger.error("The issue type is invalid") + return JiraCreationResult( + outcome=JiraCreationOutcome.CONFIRMED_REJECTION, + delivery_marker=delivery_attempt_marker, + error_code="invalid_issue_type", + error_message="The Jira issue type is invalid.", + ) + self._validated_destinations.add(destination) headers = self.get_headers(access_token, content_type_json=True) @@ -2553,81 +3237,40 @@ class Jira: "issuetype": {"name": issue_type}, } } + issue_labels = list(issue_labels or []) + attempt_label = self.build_delivery_attempt_label(delivery_attempt_marker) + if attempt_label: + issue_labels.append(attempt_label) + issue_labels = self.sanitize_labels(issue_labels) if issue_labels: payload["fields"]["labels"] = issue_labels - response = requests.post( - f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/3/issue", - json=payload, - headers=headers, - timeout=self.REQUEST_TIMEOUT, + try: + response = requests.post( + f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/3/issue", + json=payload, + headers=headers, + timeout=self.REQUEST_TIMEOUT, + ) + except requests.exceptions.RequestException as error: + return self._creation_transport_result(error, delivery_attempt_marker) + return self._classify_creation_response(response, delivery_attempt_marker) + except ( + JiraRefreshTokenError, + JiraRefreshTokenResponseError, + JiraGetAccessTokenError, + ): + return JiraCreationResult( + outcome=JiraCreationOutcome.RETRYABLE_FAILURE, + delivery_marker=delivery_attempt_marker, + error_code="authentication_failed_before_send", + error_message="Jira authentication failed before sending.", ) - - if response.status_code != 201: - try: - response_json = response.json() - except (ValueError, requests.exceptions.JSONDecodeError): - response_error = _format_jira_issue_creation_error( - {}, response.status_code - ) - logger.error(response_error) - raise JiraSendFindingsResponseError( - message=response_error, file=os.path.basename(__file__) - ) - - # Check if the error is due to required custom fields - if ( - response.status_code == 400 - and isinstance(response_json, dict) - and "errors" in response_json - ): - errors = response_json.get("errors", {}) - # Look for custom field errors (fields starting with "customfield_") - custom_field_errors = {} - if isinstance(errors, dict): - custom_field_errors = { - k: v - for k, v in errors.items() - if k.startswith("customfield_") - } - if custom_field_errors: - custom_fields_formatted = ", ".join( - [f"'{k}': '{v}'" for k, v in custom_field_errors.items()] - ) - raise JiraRequiredCustomFieldsError( - message=f"Jira project requires custom fields that are not supported: {custom_fields_formatted}", - file=os.path.basename(__file__), - ) - - response_error = _format_jira_issue_creation_error( - response_json, response.status_code - ) - logger.error(response_error) - raise JiraSendFindingsResponseError( - message=response_error, file=os.path.basename(__file__) - ) - else: - try: - response_json = response.json() - logger.info(f"Finding sent successfully: {response_json}") - except (ValueError, requests.exceptions.JSONDecodeError): - logger.info( - f"Finding sent successfully: Status {response.status_code}" - ) - return True - except JiraRequiredCustomFieldsError as custom_fields_error: - logger.error(f"Custom fields error: {custom_fields_error}") - raise custom_fields_error - except JiraSendFindingsResponseError as response_error: - logger.error(f"Jira response error: {response_error}") - raise response_error - except JiraRefreshTokenError as refresh_error: - logger.error(f"Token refresh error: {refresh_error}") - raise refresh_error - except JiraRefreshTokenResponseError as response_error: - raise response_error - except JiraNoTokenError as no_token_error: - raise no_token_error - except Exception as e: - logger.error(f"Failed to send finding: {e}") - return False + except ( + JiraNoProjectsError, + JiraGetProjectsError, + JiraGetProjectsResponseError, + JiraGetAvailableIssueTypesError, + JiraGetAvailableIssueTypesResponseError, + ) as error: + return self._destination_validation_result(error, delivery_attempt_marker) diff --git a/prowler/lib/outputs/jira/models.py b/prowler/lib/outputs/jira/models.py new file mode 100644 index 0000000000..7c2abc094b --- /dev/null +++ b/prowler/lib/outputs/jira/models.py @@ -0,0 +1,112 @@ +"""Typed result models for Jira issue creation and lookup operations.""" + +from dataclasses import dataclass +from enum import Enum +from typing import Optional, Tuple + + +class JiraCreationOutcome(str, Enum): + """Possible outcomes of a Jira issue creation attempt.""" + + CONFIRMED_SUCCESS = "confirmed_success" + CONFIRMED_REJECTION = "confirmed_rejection" + RETRYABLE_FAILURE = "retryable_failure" + UNCERTAIN = "uncertain" + + +@dataclass(frozen=True) +class JiraCreationResult: + """Immutable result of a Jira issue creation attempt.""" + + outcome: JiraCreationOutcome + issue_key: Optional[str] = None + issue_id: Optional[str] = None + issue_url: Optional[str] = None + delivery_marker: Optional[str] = None + http_status: Optional[int] = None + retry_after: Optional[str] = None + error_code: Optional[str] = None + error_message: Optional[str] = None + + def __post_init__(self) -> None: + """Require a complete issue reference for confirmed creation.""" + if self.outcome == JiraCreationOutcome.CONFIRMED_SUCCESS and not all( + (self.issue_key, self.issue_id, self.issue_url) + ): + raise ValueError( + "Confirmed Jira issue creation requires an issue key, ID, and URL" + ) + + @property + def is_confirmed_success(self) -> bool: + """Return whether Jira confirmed that it created the issue.""" + return self.outcome == JiraCreationOutcome.CONFIRMED_SUCCESS + + def __bool__(self) -> bool: + """Preserve boolean compatibility for confirmed creation only.""" + return self.is_confirmed_success + + +@dataclass(frozen=True) +class JiraIssueReference: + """Stable Jira issue identity and its last known key.""" + + issue_id: str + issue_key: str + + +class JiraIssueLookupOutcome(str, Enum): + """Possible outcomes of looking up a Jira issue.""" + + OPEN = "open" + DONE = "done" + MOVED = "moved" + MISSING = "missing" + FORBIDDEN = "forbidden" + UNKNOWN = "unknown" + + +@dataclass(frozen=True) +class JiraIssueStatusResult: + """Immutable result of looking up a Jira issue's current state.""" + + reference: JiraIssueReference + outcome: JiraIssueLookupOutcome + current_issue_id: Optional[str] = None + current_issue_key: Optional[str] = None + current_issue_url: Optional[str] = None + status: Optional[str] = None + status_category: Optional[str] = None + http_status: Optional[int] = None + retry_after: Optional[str] = None + error_code: Optional[str] = None + error_message: Optional[str] = None + + +class JiraIssueSearchOutcome(str, Enum): + """Possible outcomes of searching Jira issues by a delivery marker.""" + + SUCCESS = "success" + RETRYABLE_FAILURE = "retryable_failure" + UNKNOWN = "unknown" + + +@dataclass(frozen=True) +class JiraIssueSearchMatch: + """A Jira issue found through a delivery-marker search.""" + + issue_id: str + issue_key: str + issue_url: str + + +@dataclass(frozen=True) +class JiraIssueSearchResult: + """Immutable result of searching Jira issues by a delivery marker.""" + + outcome: JiraIssueSearchOutcome + matches: Tuple[JiraIssueSearchMatch, ...] = () + http_status: Optional[int] = None + retry_after: Optional[str] = None + error_code: Optional[str] = None + error_message: Optional[str] = None diff --git a/prowler/lib/outputs/ocsf/ingestion.py b/prowler/lib/outputs/ocsf/ingestion.py index c3314a0ebe..ac095f7ec9 100644 --- a/prowler/lib/outputs/ocsf/ingestion.py +++ b/prowler/lib/outputs/ocsf/ingestion.py @@ -1,7 +1,9 @@ import os +import ssl from typing import Any, Dict, Optional import requests +from requests.adapters import HTTPAdapter from prowler.config.config import ( cloud_api_base_url, @@ -10,6 +12,116 @@ from prowler.config.config import ( ) +class SystemTrustStoreError(RuntimeError): + """Raised when the operating system trust store cannot be initialized.""" + + +def _load_ca_bundle(ssl_context: ssl.SSLContext, ca_bundle_path: str) -> None: + """Add a CA bundle file or directory to an existing TLS context.""" + if os.path.isfile(ca_bundle_path): + ssl_context.load_verify_locations(cafile=ca_bundle_path) + elif os.path.isdir(ca_bundle_path): + ssl_context.load_verify_locations(capath=ca_bundle_path) + else: + raise FileNotFoundError + + +class _SystemTrustHTTPAdapter(HTTPAdapter): + """Use one combined trust context for HTTPS origin connection pools.""" + + def __init__(self, ssl_context: ssl.SSLContext) -> None: + """Initialize the adapter with a preconfigured TLS context. + + Args: + ssl_context: TLS context used for HTTPS connections. + """ + self._ssl_context = ssl_context + super().__init__() + + def build_connection_pool_key_attributes( + self, + request: requests.PreparedRequest, + verify: Any, + cert: Any = None, + ) -> tuple[Dict[str, Any], Dict[str, Any]]: + """Build pool attributes that enforce the configured TLS context. + + Args: + request: Prepared request used to derive host parameters. + verify: Certificate verification setting supplied by Requests. + cert: Optional client certificate configuration. + + Returns: + Host parameters and connection pool keyword arguments. + """ + verify = True + host_params, pool_kwargs = super().build_connection_pool_key_attributes( + request, verify, cert + ) + pool_kwargs["ssl_context"] = self._ssl_context + pool_kwargs["cert_reqs"] = "CERT_REQUIRED" + pool_kwargs.pop("ca_certs", None) + pool_kwargs.pop("ca_cert_dir", None) + return host_params, pool_kwargs + + def cert_verify(self, conn: Any, url: str, verify: Any, cert: Any) -> None: + """Keep certificate verification in the supplied native context.""" + if verify is not True: + raise ValueError("TLS certificate verification is required for ingestion.") + conn.cert_reqs = "CERT_REQUIRED" + conn.ca_certs = None + conn.ca_cert_dir = None + + def proxy_manager_for(self, proxy: str, **proxy_kwargs: Any) -> Any: + """Use native trust for HTTPS proxies without changing SOCKS support.""" + if proxy.lower().startswith("https://"): + proxy_kwargs.setdefault("proxy_ssl_context", self._ssl_context) + return super().proxy_manager_for(proxy, **proxy_kwargs) + + +class _SystemTrustSession(requests.Session): + """Requests session using native trust augmented with compatibility roots.""" + + def __init__(self) -> None: + super().__init__() + try: + import truststore + + ssl_context = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + _load_ca_bundle(ssl_context, requests.certs.where()) + configured_ca_bundle = os.environ.get( + "REQUESTS_CA_BUNDLE" + ) or os.environ.get("CURL_CA_BUNDLE") + if configured_ca_bundle: + _load_ca_bundle(ssl_context, configured_ca_bundle) + ssl_context.check_hostname = True + ssl_context.verify_mode = ssl.CERT_REQUIRED + except Exception as error: + self.close() + raise SystemTrustStoreError( + "Could not initialize the operating system trust store. " + "Check the configured CA bundle paths." + ) from error + + self.mount("https://", _SystemTrustHTTPAdapter(ssl_context)) + + def merge_environment_settings( + self, + url: str, + proxies: Optional[Dict[str, str]], + stream: Optional[bool], + verify: Any, + cert: Any, + ) -> Dict[str, Any]: + """Preserve environment proxies without replacing the prepared context.""" + verify = False + settings = super().merge_environment_settings( + url, proxies, stream, verify, cert + ) + settings["verify"] = True + return settings + + def send_ocsf_to_api( file_path: str, *, @@ -32,6 +144,7 @@ def send_ocsf_to_api( Raises: FileNotFoundError: If the OCSF file does not exist. ValueError: If no API key is available. + SystemTrustStoreError: If the operating system trust store cannot initialize. requests.HTTPError: If the API returns an error status. """ if not file_path: @@ -53,15 +166,31 @@ def send_ocsf_to_api( url = f"{base_url}{cloud_api_ingestion_path}" - with open(file_path, "rb") as fh: - response = requests.post( - url, - headers={ - "Authorization": f"Api-Key {api_key}", - "Accept": "application/vnd.api+json", - }, - files={"file": (os.path.basename(file_path), fh, "application/json")}, - timeout=timeout, - ) - response.raise_for_status() - return response.json() if response.text else {} + session = _SystemTrustSession() + try: + with open(file_path, "rb") as fh: + response = session.post( + url, + headers={ + "Authorization": f"Api-Key {api_key}", + "Accept": "application/vnd.api+json", + }, + files={ + "file": ( + os.path.basename(file_path), + fh, + "application/json", + ) + }, + timeout=timeout, + allow_redirects=False, + ) + if 300 <= response.status_code < 400: + raise requests.HTTPError( + f"Prowler Cloud ingestion refused HTTP redirect {response.status_code}.", + response=response, + ) + response.raise_for_status() + return response.json() if response.text else {} + finally: + session.close() diff --git a/prowler/lib/outputs/slack/slack.py b/prowler/lib/outputs/slack/slack.py index d32214cc7c..ff6afe264c 100644 --- a/prowler/lib/outputs/slack/slack.py +++ b/prowler/lib/outputs/slack/slack.py @@ -110,6 +110,20 @@ class Slack: list: list of Slack message blocks. """ try: + # A scan can legitimately produce no findings, in which case + # `findings_count` is 0 and the percentages below would raise + # `ZeroDivisionError`. + findings_count = stats["findings_count"] + pass_percentage = ( + round(stats["total_pass"] / findings_count * 100, 2) + if findings_count + else 0 + ) + fail_percentage = ( + round(stats["total_fail"] / findings_count * 100, 2) + if findings_count + else 0 + ) blocks = [ { "type": "section", @@ -128,7 +142,7 @@ class Slack: "type": "section", "text": { "type": "mrkdwn", - "text": f"\n:white_check_mark: *{stats['total_pass']} Passed findings* ({round(stats['total_pass'] / stats['findings_count'] * 100, 2)}%)\n", + "text": f"\n:white_check_mark: *{stats['total_pass']} Passed findings* ({pass_percentage}%)\n", }, }, { @@ -148,7 +162,7 @@ class Slack: "type": "section", "text": { "type": "mrkdwn", - "text": f"\n:x: *{stats['total_fail']} Failed findings* ({round(stats['total_fail'] / stats['findings_count'] * 100, 2)}%)\n ", + "text": f"\n:x: *{stats['total_fail']} Failed findings* ({fail_percentage}%)\n ", }, }, { diff --git a/prowler/providers/alibabacloud/services/oss/oss_bucket_server_side_encryption_enabled/__init__.py b/prowler/providers/alibabacloud/services/oss/oss_bucket_server_side_encryption_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/alibabacloud/services/oss/oss_bucket_server_side_encryption_enabled/oss_bucket_server_side_encryption_enabled.metadata.json b/prowler/providers/alibabacloud/services/oss/oss_bucket_server_side_encryption_enabled/oss_bucket_server_side_encryption_enabled.metadata.json new file mode 100644 index 0000000000..86e8f10fd6 --- /dev/null +++ b/prowler/providers/alibabacloud/services/oss/oss_bucket_server_side_encryption_enabled/oss_bucket_server_side_encryption_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "alibabacloud", + "CheckID": "oss_bucket_server_side_encryption_enabled", + "CheckTitle": "Server-side encryption is enabled for OSS buckets", + "CheckType": [], + "ServiceName": "oss", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "ALIYUN::OSS::Bucket", + "ResourceGroup": "storage", + "Description": "**Alibaba Cloud OSS** buckets should define a **default server-side encryption** rule so every newly uploaded object is encrypted at rest with SSE-OSS (`AES256`) or SSE-KMS (`KMS`) without relying on per-request headers.", + "Risk": "Without a default encryption rule, objects may be stored unencrypted unless every client sets encryption headers. This weakens **confidentiality** of data at rest and increases the impact of unauthorized bucket access or data exfiltration.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.alibabacloud.com/help/en/oss/user-guide/server-side-encryption-8", + "https://www.alibabacloud.com/help/en/oss/developer-reference/getbucketencryption" + ], + "Remediation": { + "Code": { + "CLI": "ossutil bucket-encryption --method put oss:// --sse-algorithm AES256", + "NativeIaC": "", + "Other": "1. Log on to the **OSS Console**\n2. In the bucket-list pane, click on a target OSS bucket\n3. Open **Security** / **Encryption** settings\n4. Enable default encryption and choose **SSE-OSS (AES256)** or **SSE-KMS**\n5. Click **Save**", + "Terraform": "resource \"alicloud_oss_bucket\" \"example\" {\n bucket = \"example-bucket\"\n\n server_side_encryption_rule {\n sse_algorithm = \"AES256\"\n }\n}" + }, + "Recommendation": { + "Text": "Configure a default server-side encryption rule on every OSS bucket (AES256 or KMS) so all new objects are encrypted at rest without depending on client-side headers.", + "Url": "https://hub.prowler.com/check/oss_bucket_server_side_encryption_enabled" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/alibabacloud/services/oss/oss_bucket_server_side_encryption_enabled/oss_bucket_server_side_encryption_enabled.py b/prowler/providers/alibabacloud/services/oss/oss_bucket_server_side_encryption_enabled/oss_bucket_server_side_encryption_enabled.py new file mode 100644 index 0000000000..b5cfc047ec --- /dev/null +++ b/prowler/providers/alibabacloud/services/oss/oss_bucket_server_side_encryption_enabled/oss_bucket_server_side_encryption_enabled.py @@ -0,0 +1,36 @@ +from prowler.lib.check.models import Check, CheckReportAlibabaCloud +from prowler.providers.alibabacloud.services.oss.oss_client import oss_client + + +class oss_bucket_server_side_encryption_enabled(Check): + """Check if default server-side encryption is enabled for OSS buckets.""" + + def execute(self) -> list[CheckReportAlibabaCloud]: + """Return PASS/FAIL findings for each OSS bucket's default server-side encryption.""" + findings = [] + + for bucket in oss_client.buckets.values(): + report = CheckReportAlibabaCloud(metadata=self.metadata(), resource=bucket) + report.region = bucket.region + report.resource_id = bucket.name + report.resource_arn = bucket.arn + + algorithm = (bucket.encryption_algorithm or "").upper() + if algorithm in {"AES256", "KMS"}: + report.status = "PASS" + encryption_details = bucket.encryption_algorithm + if bucket.encryption_kms_key_id: + encryption_details += f" (KMS key {bucket.encryption_kms_key_id}" + if bucket.encryption_kms_data_algorithm: + encryption_details += ( + f", data encryption {bucket.encryption_kms_data_algorithm}" + ) + encryption_details += ")" + report.status_extended = f"OSS bucket {bucket.name} has server-side encryption enabled with {encryption_details}." + else: + report.status = "FAIL" + report.status_extended = f"OSS bucket {bucket.name} does not have default server-side encryption enabled." + + findings.append(report) + + return findings diff --git a/prowler/providers/alibabacloud/services/oss/oss_bucket_versioning_enabled/__init__.py b/prowler/providers/alibabacloud/services/oss/oss_bucket_versioning_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/alibabacloud/services/oss/oss_bucket_versioning_enabled/oss_bucket_versioning_enabled.metadata.json b/prowler/providers/alibabacloud/services/oss/oss_bucket_versioning_enabled/oss_bucket_versioning_enabled.metadata.json new file mode 100644 index 0000000000..ae1a473967 --- /dev/null +++ b/prowler/providers/alibabacloud/services/oss/oss_bucket_versioning_enabled/oss_bucket_versioning_enabled.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "alibabacloud", + "CheckID": "oss_bucket_versioning_enabled", + "CheckTitle": "Versioning is enabled for OSS buckets", + "CheckType": [], + "ServiceName": "oss", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "ALIYUN::OSS::Bucket", + "ResourceGroup": "storage", + "Description": "**Alibaba Cloud OSS Bucket Versioning** stores previous versions of objects when they are overwritten or deleted, allowing any version to be restored at a later time. Enabling versioning on all OSS buckets ensures that data can be recovered after unintended changes or deletions.", + "Risk": "Without **OSS bucket versioning** enabled, objects that are overwritten or deleted cannot be recovered. This increases the impact of **accidental deletion**, **misconfigured automation**, and **malicious activity such as ransomware-style overwrites**, as no prior object versions are retained for recovery.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.alibabacloud.com/help/doc-detail/109695.htm" + ], + "Remediation": { + "Code": { + "CLI": "ossutil bucket-versioning --method put oss:// enabled", + "NativeIaC": "", + "Other": "1. Log on to the **OSS Console**\n2. In the bucket-list pane, click on a target OSS bucket\n3. Find the **Versioning** setting\n4. Click **Configure** and select **Enabled**\n5. Click **Save**", + "Terraform": "resource \"alicloud_oss_bucket_versioning\" \"example\" {\n bucket = alicloud_oss_bucket.example.bucket\n status = \"Enabled\"\n}" + }, + "Recommendation": { + "Text": "Enable versioning on all OSS buckets so that overwritten or deleted objects can be recovered, protecting data against accidental loss and malicious overwrites.", + "Url": "https://hub.prowler.com/check/oss_bucket_versioning_enabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/alibabacloud/services/oss/oss_bucket_versioning_enabled/oss_bucket_versioning_enabled.py b/prowler/providers/alibabacloud/services/oss/oss_bucket_versioning_enabled/oss_bucket_versioning_enabled.py new file mode 100644 index 0000000000..75ddd28a19 --- /dev/null +++ b/prowler/providers/alibabacloud/services/oss/oss_bucket_versioning_enabled/oss_bucket_versioning_enabled.py @@ -0,0 +1,35 @@ +from prowler.lib.check.models import Check, CheckReportAlibabaCloud +from prowler.providers.alibabacloud.services.oss.oss_client import oss_client + + +class oss_bucket_versioning_enabled(Check): + """Check if versioning is enabled for OSS buckets.""" + + def execute(self) -> list[CheckReportAlibabaCloud]: + findings = [] + + for bucket in oss_client.buckets.values(): + report = CheckReportAlibabaCloud(metadata=self.metadata(), resource=bucket) + report.region = bucket.region + report.resource_id = bucket.name + report.resource_arn = bucket.arn + + if bucket.versioning_status == "Enabled": + report.status = "PASS" + report.status_extended = ( + f"OSS bucket {bucket.name} has versioning enabled." + ) + else: + report.status = "FAIL" + if bucket.versioning_status == "Suspended": + report.status_extended = ( + f"OSS bucket {bucket.name} has versioning suspended." + ) + else: + report.status_extended = ( + f"OSS bucket {bucket.name} does not have versioning enabled." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/alibabacloud/services/oss/oss_service.py b/prowler/providers/alibabacloud/services/oss/oss_service.py index 42cb40e2ec..df09cd1d1c 100644 --- a/prowler/providers/alibabacloud/services/oss/oss_service.py +++ b/prowler/providers/alibabacloud/services/oss/oss_service.py @@ -8,6 +8,8 @@ from threading import Lock from typing import Optional import requests +from alibabacloud_tea_openapi import models as open_api_models +from alibabacloud_tea_util import models as util_models from defusedxml import ElementTree from pydantic.v1 import BaseModel @@ -38,6 +40,8 @@ class OSS(AlibabaCloudService): self.__threading_call__(self._get_bucket_acl, self.buckets.values()) self.__threading_call__(self._get_bucket_policy, self.buckets.values()) self.__threading_call__(self._get_bucket_logging, self.buckets.values()) + self.__threading_call__(self._get_bucket_encryption, self.buckets.values()) + self.__threading_call__(self._get_bucket_versioning, self.buckets.values()) def _list_buckets(self, regional_client=None): region = "unknown" @@ -131,58 +135,59 @@ class OSS(AlibabaCloudService): ) return + def _get_bucket_subresource(self, bucket, action: str, subresource: str) -> dict: + """Call a bucket sub-resource API (GET /?) and return its parsed body. + + The generated OSS SDK methods return empty response models for these + APIs: the OSS gateway keeps the XML root element when it deserializes + the body, while the generated response models expect its children at the + top level. Calling the shared ``execute`` path directly and unwrapping the + root element preserves the actual configuration. + + Args: + bucket: Bucket to query. + action: OSS API action name (e.g. ``GetBucketEncryption``). + subresource: Sub-resource query string (e.g. ``encryption``). + + Returns: + dict: Content of the XML root element, or an empty dict when the + response carries no configuration. + + Raises: + Exception: Any error raised by the OSS SDK, including ``TeaException`` + with the OSS error code for 4xx/5xx responses. + """ + oss_client = self.session.client("oss", bucket.region) + params = open_api_models.Params( + action=action, + version="2019-05-17", + protocol="HTTPS", + pathname=f"/?{subresource}", + method="GET", + auth_type="AK", + style="ROA", + req_body_type="xml", + body_type="xml", + ) + request = open_api_models.OpenApiRequest( + host_map={"bucket": bucket.name}, headers={} + ) + response = oss_client.execute(params, request, util_models.RuntimeOptions()) + body = response.get("body") if isinstance(response, dict) else None + if not isinstance(body, dict): + return {} + if len(body) == 1: + root_content = next(iter(body.values())) + return root_content if isinstance(root_content, dict) else {} + return body + def _get_bucket_acl(self, bucket): - """Get bucket ACL.""" + """Get bucket ACL (private, public-read or public-read-write).""" logger.info(f"OSS - Getting ACL for bucket {bucket.name}...") try: - # Get OSS client for the bucket's region - # OSS bucket operations use regional endpoint: oss-{region}.aliyuncs.com - oss_client = self.session.client("oss", bucket.region) - - # Get bucket ACL - response = oss_client.get_bucket_acl(bucket.name) - - if response and response.body: - # ACL can be retrieved from the response - # The ACL value is typically in the response body - acl_value = getattr(response.body, "acl", None) - if acl_value: - # ACL values: private, public-read, public-read-write - bucket.acl = acl_value - else: - # Try to get from access_control_list if available - acl_list = getattr(response.body, "access_control_list", None) - if acl_list: - grant = getattr(acl_list, "grant", None) - if grant: - # Check grants to determine ACL type - if isinstance(grant, list): - # Check if any grant has public access - for g in grant: - permission = getattr(g, "permission", "") - if permission in ["READ", "FULL_CONTROL"]: - if permission == "READ": - bucket.acl = "public-read" - else: - bucket.acl = "public-read-write" - break - else: - bucket.acl = "private" - else: - permission = getattr(grant, "permission", "") - if permission == "READ": - bucket.acl = "public-read" - elif permission == "FULL_CONTROL": - bucket.acl = "public-read-write" - else: - bucket.acl = "private" - else: - bucket.acl = "private" - else: - bucket.acl = "private" - else: - bucket.acl = "private" - + acl_policy = self._get_bucket_subresource(bucket, "GetBucketAcl", "acl") + grant = (acl_policy.get("AccessControlList") or {}).get("Grant") + bucket.acl = str(grant) if grant else "private" except Exception as error: logger.error( f"{bucket.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" @@ -219,67 +224,66 @@ class OSS(AlibabaCloudService): bucket.policy = {} def _get_bucket_logging(self, bucket): - """Get bucket logging configuration using OSS SDK.""" + """Get bucket logging configuration.""" logger.info(f"OSS - Getting logging configuration for bucket {bucket.name}...") try: - oss_client = self.session.client("oss", bucket.region) - - response = oss_client.get_bucket_logging(bucket.name) - - if response and response.body: - logging_enabled = None - if hasattr(response.body, "logging_enabled"): - logging_enabled = response.body.logging_enabled - elif hasattr(response.body, "loggingenabled"): - logging_enabled = response.body.loggingenabled - elif hasattr(response.body, "bucket_logging"): - logging_enabled = response.body.bucket_logging - - if logging_enabled: - target_bucket = None - target_prefix = None - - for attr_name in [ - "target_bucket", - "targetBucket", - "target_bucket_name", - "targetBucketName", - ]: - if hasattr(logging_enabled, attr_name): - target_bucket = getattr(logging_enabled, attr_name) - break - - for attr_name in [ - "target_prefix", - "targetPrefix", - "target_prefix_name", - "targetPrefixName", - ]: - if hasattr(logging_enabled, attr_name): - target_prefix = getattr(logging_enabled, attr_name) - break - - if target_bucket: - bucket.logging_enabled = True - bucket.logging_target_bucket = ( - str(target_bucket) if target_bucket else "" - ) - bucket.logging_target_prefix = ( - str(target_prefix) if target_prefix else "" - ) - else: - bucket.logging_enabled = False - bucket.logging_target_bucket = "" - bucket.logging_target_prefix = "" - else: - bucket.logging_enabled = False - bucket.logging_target_bucket = "" - bucket.logging_target_prefix = "" + logging_status = self._get_bucket_subresource( + bucket, "GetBucketLogging", "logging" + ) + logging_enabled = logging_status.get("LoggingEnabled") or {} + target_bucket = logging_enabled.get("TargetBucket") + if target_bucket: + bucket.logging_enabled = True + bucket.logging_target_bucket = str(target_bucket) + bucket.logging_target_prefix = str( + logging_enabled.get("TargetPrefix") or "" + ) else: bucket.logging_enabled = False bucket.logging_target_bucket = "" bucket.logging_target_prefix = "" + except Exception as error: + logger.error( + f"{bucket.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + def _get_bucket_encryption(self, bucket): + """Get bucket default server-side encryption configuration.""" + logger.info( + f"OSS - Getting encryption configuration for bucket {bucket.name}..." + ) + try: + encryption_rule = self._get_bucket_subresource( + bucket, "GetBucketEncryption", "encryption" + ) + default_rule = ( + encryption_rule.get("ApplyServerSideEncryptionByDefault") or {} + ) + bucket.encryption_algorithm = str(default_rule.get("SSEAlgorithm") or "") + bucket.encryption_kms_key_id = str(default_rule.get("KMSMasterKeyID") or "") + bucket.encryption_kms_data_algorithm = str( + default_rule.get("KMSDataEncryption") or "" + ) + except Exception as error: + # No encryption rule configured means default encryption is disabled + error_code = getattr(error, "code", "") + if error_code == "NoSuchServerSideEncryptionRule": + bucket.encryption_algorithm = "" + bucket.encryption_kms_key_id = "" + bucket.encryption_kms_data_algorithm = "" + else: + logger.error( + f"{bucket.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _get_bucket_versioning(self, bucket): + """Get bucket versioning status (Enabled, Suspended or unset).""" + logger.info(f"OSS - Getting versioning status for bucket {bucket.name}...") + try: + versioning_configuration = self._get_bucket_subresource( + bucket, "GetBucketVersioning", "versioning" + ) + bucket.versioning_status = str(versioning_configuration.get("Status") or "") except Exception as error: logger.error( f"{bucket.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" @@ -330,4 +334,8 @@ class Bucket(BaseModel): logging_enabled: bool = False logging_target_bucket: str = "" logging_target_prefix: str = "" + encryption_algorithm: str = "" # "", AES256, KMS + encryption_kms_key_id: str = "" + encryption_kms_data_algorithm: str = "" # "", AES256, SM4 (only with KMS) + versioning_status: str = "" # "", Enabled, Suspended creation_date: Optional[datetime] = None diff --git a/prowler/providers/aws/aws_provider.py b/prowler/providers/aws/aws_provider.py index b4c9ed3771..f6d5347cb7 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 @@ -104,7 +112,7 @@ class AwsProvider(Provider): def __init__( self, - retries_max_attempts: int = 3, + retries_max_attempts: Optional[int] = None, role_arn: str = None, session_duration: int = 3600, external_id: str = None, @@ -125,12 +133,15 @@ 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. Args: - retries_max_attempts: The maximum number of retries for the AWS client. + Defaults to the PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS environment variable or, if unset, to 3. - role_arn: The ARN of the IAM role to assume. - session_duration: The duration of the session in seconds, between 900 and 43200. - external_id: The external ID to use when assuming the IAM role. @@ -154,6 +165,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 +241,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 +271,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") ######## @@ -372,7 +390,7 @@ class AwsProvider(Provider): ) self._organizations_metadata = self.get_organizations_info( - aws_organizations_session, self._identity.account + aws_organizations_session, self._identity.account, sts_region ) # Fallback to the original (pre-assume) session when no explicit @@ -394,7 +412,7 @@ class AwsProvider(Provider): "Retrying AWS Organizations metadata retrieval with the original session" ) self._organizations_metadata = self.get_organizations_info( - self._session.original_session, self._identity.account + self._session.original_session, self._identity.account, sts_region ) ######## @@ -490,7 +508,10 @@ class AwsProvider(Provider): # TODO: This can be moved to another class since it doesn't need self def get_organizations_info( - self, organizations_session: Session, aws_account_id: str + self, + organizations_session: Session, + aws_account_id: str, + region: str | None = None, ) -> AWSOrganizationsInfo: """ get_organizations_info returns a AWSOrganizationsInfo object if the account to be audited is a delegated administrator for AWS Organizations or if the AWS Organizations Role ARN (--organizations-role) is passed. @@ -498,6 +519,7 @@ class AwsProvider(Provider): Args: - organizations_session: needs to be a Session object with permissions to do organizations:DescribeAccount and organizations:ListTagsForResource. - aws_account_id: is the AWS Account ID from which we want to get the AWS Organizations account metadata + - region: a region of the audited partition, so the client reaches that partition's Organizations endpoint Returns: - AWSOrganizationsInfo object with the AWS Organizations metadata for the account to be audited. @@ -511,6 +533,7 @@ class AwsProvider(Provider): get_organizations_metadata( aws_account_id=aws_account_id, session=organizations_session, + region=region, ) ) @@ -575,8 +598,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 +701,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 +713,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 +939,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 +957,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 +1170,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 +1194,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 @@ -1185,7 +1236,8 @@ class AwsProvider(Provider): Args: - session: The AWS session object - - assumed_role_info: The AWSAssumeRoleInfo object + - assumed_role_info: The AWSAssumeRoleInfo object. Its sts_region is + updated to the region that answered, so later calls go straight there Returns: - AWSCredentials: The AWS credentials for the assumed role @@ -1211,10 +1263,14 @@ 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 + 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) + assumed_role_info.sts_region = sts_region # Convert the UTC datetime object to your local timezone credentials_expiration_local_time = ( assumed_credentials["Credentials"]["Expiration"] @@ -1293,30 +1349,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 +1476,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 +1493,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 +1546,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,11 +1562,14 @@ 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, assumed_role_information, ) + # Validate where the role was assumed, not where it timed out + aws_region = assumed_role_information.sts_region session = Session( aws_access_key_id=assumed_role_credentials.aws_access_key_id, aws_secret_access_key=assumed_role_credentials.aws_secret_access_key, @@ -1451,6 +1586,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 +1733,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 +1768,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 +1847,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 +1991,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 +2014,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 6ee2b8d627..d7620009bf 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,25 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], + "aws-us-gov": [] + } + }, + "account-access": { + "regions": { + "aws": [ + "ap-southeast-1", + "us-west-1" + ], + "aws-cn": [], + "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -150,6 +182,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,12 +242,39 @@ "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" ] } }, + "agent-registry": { + "regions": { + "aws": [ + "ap-northeast-1", + "ap-southeast-2", + "eu-west-1", + "us-east-1", + "us-west-2" + ], + "aws-cn": [], + "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], + "aws-us-gov": [] + } + }, "ahl": { "regions": { "aws": [ @@ -221,23 +289,37 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, "aiops": { "regions": { "aws": [ + "ap-east-1", "ap-northeast-1", "ap-south-1", "ap-southeast-1", "ap-southeast-2", "ap-southeast-5", "ap-southeast-7", + "eu-central-1", "eu-north-1", - "eu-south-2" + "eu-south-2", + "eu-west-1", + "us-east-1", + "us-east-2", + "us-west-2" ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -268,6 +350,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -292,6 +378,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 +413,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -351,6 +445,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -379,6 +477,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -427,6 +529,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 +574,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 +627,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 +689,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 +755,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 +798,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -659,6 +821,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -693,6 +859,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 +918,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 +935,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -787,6 +976,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -807,6 +1000,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -815,12 +1012,15 @@ "aws": [ "ap-northeast-1", "ap-northeast-2", + "ap-northeast-3", "ap-south-1", "ap-southeast-1", "ap-southeast-2", "ap-southeast-5", "ca-central-1", + "ca-west-1", "eu-central-1", + "eu-central-2", "eu-south-1", "eu-south-2", "eu-west-1", @@ -834,6 +1034,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 +1084,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -923,6 +1131,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 +1186,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 +1249,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 +1304,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 +1341,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1118,6 +1366,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1166,6 +1418,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 +1473,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 +1503,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1250,6 +1525,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1298,45 +1577,31 @@ "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" ] } }, - "awstransform": { - "regions": { - "aws": [ - "ap-northeast-1", - "ap-northeast-2", - "ap-south-1", - "ap-southeast-2", - "ca-central-1", - "eu-central-1", - "eu-west-2", - "sa-east-1", - "us-east-1" - ], - "aws-cn": [], - "aws-eusc": [], - "aws-us-gov": [] - } - }, "b2bi": { "regions": { "aws": [ - "ap-south-2", - "ap-southeast-2", "ca-central-1", - "eu-central-1", "eu-west-1", - "eu-west-3", "us-east-1", "us-east-2", "us-west-2" ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1385,6 +1650,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 +1695,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 +1750,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 +1780,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1496,6 +1796,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1506,6 +1810,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1550,6 +1858,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 +1890,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-west-1" ] @@ -1587,6 +1905,7 @@ "ap-northeast-1", "ap-northeast-2", "ap-south-1", + "ap-south-2", "ap-southeast-1", "ap-southeast-2", "ap-southeast-5", @@ -1602,10 +1921,15 @@ "sa-east-1", "us-east-1", "us-east-2", + "us-west-1", "us-west-2" ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1626,6 +1950,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 +1985,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 +2002,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1680,6 +2016,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1694,6 +2034,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1711,6 +2055,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 +2110,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1781,6 +2141,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 +2176,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 +2194,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1845,6 +2217,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1866,6 +2242,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 +2260,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1899,6 +2283,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1919,6 +2307,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1939,6 +2331,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -1970,6 +2366,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -2018,6 +2418,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 +2454,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 +2490,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -2104,6 +2527,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 +2582,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 +2646,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -2245,6 +2691,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 +2717,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -2307,6 +2761,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 +2816,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 +2858,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 +2915,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 +2955,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -2501,6 +3002,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 +3028,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -2552,6 +3069,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 +3102,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 +3156,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 +3193,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -2672,6 +3216,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -2714,6 +3262,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 +3298,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 +3331,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -2782,6 +3345,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -2827,6 +3394,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 +3448,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 +3500,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 +3527,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -2969,6 +3552,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 +3579,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 +3627,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 +3665,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -3108,6 +3717,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 +3754,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 +3779,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -3170,6 +3802,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -3189,6 +3825,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -3201,6 +3841,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -3220,6 +3864,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 +3915,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 +3967,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 +3986,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 +4008,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 +4037,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -3394,6 +4072,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 +4098,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -3430,6 +4116,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 +4170,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,22 +4210,43 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, "dax": { "regions": { "aws": [ + "af-south-1", + "ap-east-1", + "ap-east-2", "ap-northeast-1", + "ap-northeast-2", + "ap-northeast-3", "ap-south-1", + "ap-south-2", "ap-southeast-1", "ap-southeast-2", + "ap-southeast-3", + "ap-southeast-4", + "ap-southeast-5", + "ap-southeast-6", + "ap-southeast-7", + "ca-central-1", + "ca-west-1", "eu-central-1", + "eu-central-2", "eu-north-1", + "eu-south-1", "eu-south-2", "eu-west-1", "eu-west-2", "eu-west-3", + "il-central-1", + "mx-central-1", "sa-east-1", "us-east-1", "us-east-2", @@ -3536,6 +4258,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -3555,6 +4281,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -3585,6 +4315,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 +4332,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -3623,6 +4361,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -3671,6 +4413,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 +4447,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -3738,6 +4499,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 +4565,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 +4627,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" @@ -3847,6 +4653,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -3856,7 +4663,9 @@ "ap-southeast-2", "ap-southeast-3", "ap-southeast-4", + "ap-southeast-5", "ap-southeast-6", + "ap-southeast-7", "ca-central-1", "ca-west-1", "eu-central-1", @@ -3870,6 +4679,7 @@ "il-central-1", "me-central-1", "me-south-1", + "mx-central-1", "sa-east-1", "us-east-1", "us-east-2", @@ -3880,6 +4690,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" @@ -3931,6 +4745,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" @@ -3952,6 +4780,7 @@ "ca-west-1", "eu-central-1", "eu-north-1", + "eu-south-2", "eu-west-1", "eu-west-2", "eu-west-3", @@ -3962,6 +4791,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -4010,6 +4843,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" @@ -4061,6 +4909,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" @@ -4112,6 +4975,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" @@ -4163,6 +5041,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" @@ -4214,6 +5107,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" @@ -4228,6 +5136,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -4276,6 +5188,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" @@ -4327,6 +5254,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" @@ -4378,6 +5319,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" @@ -4424,6 +5380,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" @@ -4475,6 +5435,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" @@ -4522,6 +5497,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" @@ -4573,6 +5552,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" @@ -4624,6 +5618,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" @@ -4634,12 +5643,17 @@ "regions": { "aws": [ "ap-south-1", + "ap-southeast-2", "eu-west-1", "us-east-1", "us-west-2" ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -4688,6 +5702,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" @@ -4730,6 +5759,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" @@ -4778,7 +5811,15 @@ "cn-north-1", "cn-northwest-1" ], - "aws-eusc": [], + "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" @@ -4803,6 +5844,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -4851,6 +5896,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" @@ -4902,6 +5962,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" @@ -4953,6 +6017,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" @@ -4962,7 +6041,10 @@ "evs": { "regions": { "aws": [ + "ap-east-2", "ap-northeast-1", + "ap-northeast-2", + "ap-northeast-3", "ap-south-1", "ap-south-2", "ap-southeast-1", @@ -4971,10 +6053,14 @@ "ca-central-1", "ca-west-1", "eu-central-1", + "eu-central-2", + "eu-north-1", "eu-south-1", + "eu-south-2", "eu-west-1", "eu-west-2", "eu-west-3", + "il-central-1", "mx-central-1", "sa-east-1", "us-east-1", @@ -4984,6 +6070,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -5032,6 +6122,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" @@ -5054,6 +6148,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -5073,6 +6171,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -5087,6 +6189,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -5135,6 +6241,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" @@ -5171,6 +6291,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" @@ -5220,6 +6344,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" @@ -5242,6 +6370,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -5261,6 +6393,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -5276,6 +6412,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -5306,6 +6446,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -5354,6 +6498,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" @@ -5405,6 +6561,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" @@ -5456,6 +6616,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" @@ -5507,6 +6671,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" @@ -5558,6 +6726,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" @@ -5596,6 +6768,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -5630,6 +6806,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" @@ -5676,6 +6861,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -5724,6 +6913,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" @@ -5746,6 +6948,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" @@ -5774,6 +6980,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" @@ -5798,6 +7008,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -5846,6 +7060,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" @@ -5865,6 +7090,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" @@ -5916,6 +7149,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" @@ -5967,6 +7213,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" @@ -6018,6 +7268,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" @@ -6069,6 +7323,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" @@ -6093,6 +7351,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" @@ -6141,6 +7403,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" @@ -6189,6 +7455,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" @@ -6228,6 +7498,24 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], + "aws-us-gov": [] + } + }, + "invoicing": { + "regions": { + "aws": [], + "aws-cn": [], + "aws-eusc": [ + "eusc-de-east-1" + ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -6263,6 +7551,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" @@ -6301,6 +7593,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" @@ -6337,6 +7633,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" @@ -6352,6 +7652,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -6365,6 +7669,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -6397,6 +7705,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" @@ -6435,62 +7747,16 @@ "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" ] } }, - "iotevents": { - "regions": { - "aws": [ - "ap-northeast-1", - "ap-northeast-2", - "ap-south-1", - "ap-southeast-1", - "ap-southeast-2", - "ca-central-1", - "eu-central-1", - "eu-west-1", - "eu-west-2", - "us-east-1", - "us-east-2", - "us-west-2" - ], - "aws-cn": [ - "cn-north-1" - ], - "aws-eusc": [], - "aws-us-gov": [ - "us-gov-west-1" - ] - } - }, - "iotevents-data": { - "regions": { - "aws": [ - "ap-northeast-1", - "ap-northeast-2", - "ap-south-1", - "ap-southeast-1", - "ap-southeast-2", - "ca-central-1", - "eu-central-1", - "eu-west-1", - "eu-west-2", - "us-east-1", - "us-east-2", - "us-west-2" - ], - "aws-cn": [ - "cn-north-1" - ], - "aws-eusc": [], - "aws-us-gov": [ - "us-gov-west-1" - ] - } - }, "iotfleetwise": { "regions": { "aws": [ @@ -6500,6 +7766,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -6535,6 +7805,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" @@ -6560,6 +7834,10 @@ "cn-north-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-west-1" ] @@ -6570,6 +7848,10 @@ "aws": [], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -6590,6 +7872,10 @@ "cn-north-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-west-1" ] @@ -6608,6 +7894,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -6624,6 +7914,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -6640,6 +7934,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -6656,6 +7954,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -6704,6 +8006,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" @@ -6753,6 +8059,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" @@ -6775,6 +8085,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-west-1" ] @@ -6795,6 +8109,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -6843,6 +8161,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" @@ -6894,6 +8227,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" @@ -6928,6 +8269,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" @@ -6979,6 +8326,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" @@ -7030,6 +8392,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" @@ -7081,6 +8456,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" @@ -7129,6 +8519,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" @@ -7171,6 +8565,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" @@ -7194,6 +8592,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-west-1" ] @@ -7216,6 +8618,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-west-1" ] @@ -7266,6 +8672,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" @@ -7314,6 +8734,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" @@ -7359,6 +8783,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" @@ -7390,6 +8818,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -7438,6 +8870,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" @@ -7453,6 +8900,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -7480,6 +8931,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -7510,6 +8965,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" @@ -7524,6 +8983,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -7555,6 +9018,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -7586,6 +9053,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -7601,6 +9072,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-west-1" ] @@ -7613,6 +9088,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -7644,6 +9123,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" @@ -7695,6 +9178,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" @@ -7710,6 +9197,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": [] } }, @@ -7720,6 +9213,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -7734,6 +9231,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -7744,6 +9245,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -7752,21 +9257,30 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", + "ap-northeast-3", "ap-south-1", + "ap-south-2", "ap-southeast-1", "ap-southeast-2", "ap-southeast-5", "ap-southeast-7", "ca-central-1", + "ca-west-1", "eu-central-1", + "eu-central-2", "eu-north-1", + "eu-south-1", + "eu-south-2", "eu-west-1", "eu-west-2", "eu-west-3", + "il-central-1", "me-central-1", "me-south-1", + "mx-central-1", "sa-east-1", "us-east-1", "us-east-2", @@ -7778,6 +9292,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" @@ -7813,6 +9331,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -7845,6 +9367,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-west-1" ] @@ -7876,6 +9402,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": [] } }, @@ -7903,6 +9437,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": [] } }, @@ -7932,6 +9474,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -7962,6 +9508,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -7980,6 +9530,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -7998,6 +9552,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -8027,6 +9585,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -8041,6 +9603,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -8072,6 +9638,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" @@ -8116,6 +9686,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" @@ -8135,6 +9711,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -8178,6 +9758,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" @@ -8207,6 +9791,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -8223,6 +9811,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -8239,6 +9831,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -8251,6 +9847,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -8299,6 +9899,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" @@ -8312,6 +9916,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -8355,6 +9963,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -8396,7 +10008,13 @@ ], "aws-cn": [], "aws-eusc": [], - "aws-us-gov": [] + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], + "aws-us-gov": [ + "us-gov-west-1" + ] } }, "neptune": { @@ -8439,6 +10057,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" @@ -8476,6 +10109,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -8524,6 +10161,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" @@ -8553,6 +10198,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -8596,6 +10245,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" @@ -8636,6 +10289,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -8679,6 +10336,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -8691,6 +10352,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -8701,6 +10366,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -8749,6 +10418,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" @@ -8792,8 +10476,17 @@ "us-west-2" ], "aws-cn": [], - "aws-eusc": [], - "aws-us-gov": [] + "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" + ] } }, "odb": { @@ -8820,12 +10513,17 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, "omics": { "regions": { "aws": [ + "ap-northeast-1", "ap-northeast-2", "ap-southeast-1", "eu-central-1", @@ -8833,10 +10531,15 @@ "eu-west-2", "il-central-1", "us-east-1", + "us-east-2", "us-west-2" ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -8885,6 +10588,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" @@ -8919,6 +10637,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" @@ -8940,6 +10662,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -8958,6 +10684,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -9006,6 +10736,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" @@ -9026,6 +10771,7 @@ "eu-south-2", "eu-west-1", "eu-west-2", + "eu-west-3", "sa-east-1", "us-east-1", "us-east-2", @@ -9034,7 +10780,14 @@ ], "aws-cn": [], "aws-eusc": [], - "aws-us-gov": [] + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], + "aws-us-gov": [ + "us-gov-east-1", + "us-gov-west-1" + ] } }, "outposts": { @@ -9069,27 +10822,20 @@ ], "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" ] } }, - "panorama": { - "regions": { - "aws": [ - "ap-southeast-1", - "ap-southeast-2", - "ca-central-1", - "eu-west-1", - "us-east-1", - "us-west-2" - ], - "aws-cn": [], - "aws-eusc": [], - "aws-us-gov": [] - } - }, "partnercentral-account": { "regions": { "aws": [ @@ -9097,6 +10843,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -9107,6 +10857,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -9117,6 +10871,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -9142,6 +10900,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -9187,7 +10949,14 @@ "aws-eusc": [ "eusc-de-east-1" ], - "aws-us-gov": [] + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], + "aws-us-gov": [ + "us-gov-east-1", + "us-gov-west-1" + ] } }, "pca-connector-scep": { @@ -9231,6 +11000,10 @@ "aws-eusc": [ "eusc-de-east-1" ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -9239,11 +11012,14 @@ "aws": [ "af-south-1", "ap-northeast-1", + "ap-northeast-2", "ap-northeast-3", "ap-south-1", "ap-southeast-1", "ap-southeast-2", "ap-southeast-3", + "ap-southeast-4", + "ap-southeast-6", "eu-central-1", "eu-north-1", "eu-south-1", @@ -9258,6 +11034,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" @@ -9281,6 +11061,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -9329,6 +11113,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" @@ -9353,6 +11152,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-west-1" ] @@ -9370,6 +11173,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -9385,6 +11192,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -9427,6 +11238,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" @@ -9468,6 +11283,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": [] } }, @@ -9505,6 +11329,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" ] @@ -9523,6 +11351,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": [] } }, @@ -9571,6 +11411,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" @@ -9594,6 +11438,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -9605,6 +11453,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -9618,6 +11470,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -9629,6 +11485,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" @@ -9666,6 +11526,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" @@ -9717,6 +11584,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" @@ -9768,6 +11650,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" @@ -9819,6 +11716,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" @@ -9845,6 +11757,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -9855,6 +11771,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -9903,6 +11823,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" @@ -9954,6 +11889,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" @@ -10001,7 +11940,18 @@ "cn-north-1", "cn-northwest-1" ], - "aws-eusc": [], + "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" @@ -10032,6 +11982,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" ] @@ -10050,6 +12007,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -10079,6 +12040,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" @@ -10123,8 +12088,15 @@ "us-west-1", "us-west-2" ], - "aws-cn": [], + "aws-cn": [ + "cn-north-1", + "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" @@ -10176,6 +12148,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" @@ -10227,6 +12214,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" @@ -10278,6 +12280,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" @@ -10318,6 +12324,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" @@ -10369,6 +12379,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" @@ -10420,6 +12445,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" @@ -10459,6 +12488,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -10469,6 +12502,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -10479,6 +12516,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -10525,6 +12566,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" @@ -10576,6 +12626,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" @@ -10594,6 +12658,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -10637,6 +12705,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" @@ -10688,6 +12760,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" @@ -10734,6 +12821,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" @@ -10769,6 +12865,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" @@ -10814,7 +12918,13 @@ "cn-north-1", "cn-northwest-1" ], - "aws-eusc": [], + "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" @@ -10866,6 +12976,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" @@ -10906,6 +13027,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -10916,6 +13041,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -10957,6 +13086,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": [] } }, @@ -11005,6 +13145,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" @@ -11016,6 +13167,10 @@ "aws": [], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -11061,6 +13216,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" @@ -11112,6 +13276,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" @@ -11152,6 +13330,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" @@ -11172,6 +13359,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -11220,6 +13411,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" @@ -11257,6 +13463,25 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], + "aws-us-gov": [] + } + }, + "securityagent": { + "regions": { + "aws": [ + "us-east-1", + "us-west-2" + ], + "aws-cn": [], + "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -11305,6 +13530,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" @@ -11334,6 +13568,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" @@ -11367,6 +13605,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" @@ -11418,6 +13660,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" @@ -11463,6 +13720,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" @@ -11504,6 +13767,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" @@ -11555,6 +13822,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" @@ -11596,6 +13878,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" @@ -11637,6 +13923,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" @@ -11658,6 +13948,7 @@ "ap-southeast-3", "ap-southeast-4", "ap-southeast-5", + "ap-southeast-6", "ap-southeast-7", "ca-central-1", "ca-west-1", @@ -11681,6 +13972,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -11715,6 +14010,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" @@ -11766,6 +14065,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" @@ -11781,6 +14084,10 @@ "cn-north-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-west-1" ] @@ -11798,6 +14105,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -11824,6 +14135,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -11856,6 +14171,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" @@ -11883,6 +14207,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -11931,6 +14259,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" @@ -11977,6 +14320,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -12025,6 +14372,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" @@ -12076,6 +14438,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" @@ -12104,6 +14481,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -12129,6 +14510,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -12154,6 +14539,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -12191,6 +14580,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -12239,6 +14632,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" @@ -12290,6 +14687,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" @@ -12341,6 +14753,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" @@ -12392,6 +14813,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" @@ -12443,6 +14879,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" @@ -12458,6 +14904,22 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], + "aws-us-gov": [] + } + }, + "supportauthz": { + "regions": { + "aws": [], + "aws-cn": [], + "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -12469,6 +14931,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -12517,6 +14983,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" @@ -12568,6 +15049,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" @@ -12580,7 +15076,13 @@ "us-east-1" ], "aws-cn": [], - "aws-eusc": [], + "aws-eusc": [ + "eusc-de-east-1" + ], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -12604,6 +15106,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" @@ -12623,6 +15134,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -12631,8 +15146,10 @@ "aws": [ "ap-northeast-1", "ap-south-1", + "ap-southeast-1", "ap-southeast-2", "eu-central-1", + "eu-north-1", "eu-west-1", "us-east-1", "us-east-2", @@ -12640,6 +15157,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-west-1" ] @@ -12648,20 +15169,28 @@ "timestream-influxdb": { "regions": { "aws": [ + "af-south-1", + "ap-east-1", "ap-northeast-1", + "ap-northeast-2", "ap-northeast-3", "ap-south-1", + "ap-south-2", "ap-southeast-1", "ap-southeast-2", "ap-southeast-3", + "ap-southeast-4", + "ap-southeast-7", "ca-central-1", "eu-central-1", + "eu-central-2", "eu-north-1", "eu-south-1", "eu-south-2", "eu-west-1", "eu-west-2", "eu-west-3", + "il-central-1", "me-central-1", "mx-central-1", "sa-east-1", @@ -12674,6 +15203,10 @@ "cn-northwest-1" ], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -12691,6 +15224,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-west-1" ] @@ -12710,30 +15247,15 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-west-1" ] } }, - "tnb": { - "regions": { - "aws": [ - "ap-northeast-2", - "ap-southeast-2", - "ca-central-1", - "eu-central-1", - "eu-north-1", - "eu-south-2", - "eu-west-3", - "sa-east-1", - "us-east-1", - "us-west-2" - ], - "aws-cn": [], - "aws-eusc": [], - "aws-us-gov": [] - } - }, "transcribe": { "regions": { "aws": [ @@ -12763,6 +15285,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" @@ -12814,6 +15345,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" @@ -12865,6 +15400,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" @@ -12893,6 +15432,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" ] @@ -12943,6 +15491,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" @@ -12952,10 +15508,45 @@ "uxc": { "regions": { "aws": [ - "us-east-1" + "af-south-1", + "ap-east-1", + "ap-east-2", + "ap-northeast-1", + "ap-northeast-2", + "ap-northeast-3", + "ap-south-1", + "ap-south-2", + "ap-southeast-1", + "ap-southeast-2", + "ap-southeast-3", + "ap-southeast-4", + "ap-southeast-5", + "ap-southeast-6", + "ap-southeast-7", + "ca-central-1", + "ca-west-1", + "eu-central-1", + "eu-central-2", + "eu-north-1", + "eu-south-1", + "eu-south-2", + "eu-west-1", + "eu-west-2", + "eu-west-3", + "il-central-1", + "mx-central-1", + "sa-east-1", + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2" ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -12983,6 +15574,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" @@ -13032,6 +15627,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" @@ -13068,6 +15667,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" @@ -13088,6 +15691,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -13136,6 +15743,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" @@ -13156,6 +15767,7 @@ "ap-southeast-2", "ap-southeast-3", "ap-southeast-4", + "ap-southeast-5", "ca-central-1", "ca-west-1", "eu-central-1", @@ -13177,6 +15789,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -13222,6 +15838,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" @@ -13273,6 +15893,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" @@ -13316,6 +15944,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" @@ -13367,6 +15999,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" @@ -13397,6 +16037,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" @@ -13427,6 +16071,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" @@ -13449,6 +16097,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [ "us-gov-west-1" ] @@ -13470,6 +16122,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -13485,6 +16141,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -13497,6 +16157,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -13509,6 +16173,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -13537,6 +16205,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" @@ -13548,6 +16225,7 @@ "aws": [ "ap-east-1", "ap-northeast-2", + "ap-southeast-1", "ap-southeast-5", "eu-south-2", "me-central-1", @@ -13555,6 +16233,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -13574,6 +16256,10 @@ ], "aws-cn": [], "aws-eusc": [], + "aws-iso": [], + "aws-iso-b": [], + "aws-iso-e": [], + "aws-iso-f": [], "aws-us-gov": [] } }, @@ -13622,6 +16308,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..dd365bb8cf 100644 --- a/prowler/providers/aws/config.py +++ b/prowler/providers/aws/config.py @@ -2,14 +2,60 @@ import os from botocore.config import Config +from prowler.providers.aws.exceptions.exceptions import ( + AWSInvalidBoto3RetriesError, + 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_boto3_retries_from_env(name: str, default: int) -> int: + """Non-negative integer retries read from the environment, or default when unset.""" + raw = os.getenv(name, "").strip() + if not raw: + return default + if not raw.isdecimal(): + raise AWSInvalidBoto3RetriesError( + file=os.path.basename(__file__), + message=f"{name} must be a non-negative integer number of retries, 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": get_boto3_retries_from_env( + "PROWLER_AWS_BOTO3_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..dae23408e9 100644 --- a/prowler/providers/aws/exceptions/exceptions.py +++ b/prowler/providers/aws/exceptions/exceptions.py @@ -78,6 +78,14 @@ 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.", + }, + (1919, "AWSInvalidBoto3RetriesError"): { + "message": "The Boto3 retries configured through the environment are invalid", + "remediation": "Set PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS to a non-negative integer, 0 disables retries.", + }, } def __init__(self, code, file=None, original_exception=None, message=None): @@ -231,3 +239,21 @@ 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 + ) + + +class AWSInvalidBoto3RetriesError(AWSBaseException): + """Boto3 retries configured through the environment are not a non-negative integer.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 1919, 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/organizations/organizations.py b/prowler/providers/aws/lib/organizations/organizations.py index 5e152a790b..153ca46e7b 100644 --- a/prowler/providers/aws/lib/organizations/organizations.py +++ b/prowler/providers/aws/lib/organizations/organizations.py @@ -42,9 +42,11 @@ def _get_ou_metadata(organizations_client, account_id): def get_organizations_metadata( aws_account_id: str, session: session.Session, + region: str | None = None, ) -> tuple[dict, dict, dict]: try: - organizations_client = session.client("organizations") + # Without a region botocore resolves the commercial endpoint, whatever the partition + organizations_client = session.client("organizations", region_name=region) organizations_metadata = organizations_client.describe_account( AccountId=aws_account_id diff --git a/prowler/providers/aws/lib/s3/s3.py b/prowler/providers/aws/lib/s3/s3.py index a4bbb42cc7..d2f511fd09 100644 --- a/prowler/providers/aws/lib/s3/s3.py +++ b/prowler/providers/aws/lib/s3/s3.py @@ -85,7 +85,7 @@ class S3: aws_access_key_id: str = None, aws_secret_access_key: str = None, aws_session_token: Optional[str] = None, - retries_max_attempts: int = 3, + retries_max_attempts: Optional[int] = None, regions: set = set(), ) -> None: """ diff --git a/prowler/providers/aws/lib/security_hub/security_hub.py b/prowler/providers/aws/lib/security_hub/security_hub.py index bc372d1ddd..ae4c2350e4 100644 --- a/prowler/providers/aws/lib/security_hub/security_hub.py +++ b/prowler/providers/aws/lib/security_hub/security_hub.py @@ -106,7 +106,7 @@ class SecurityHub: aws_access_key_id: str = None, aws_secret_access_key: str = None, aws_session_token: Optional[str] = None, - retries_max_attempts: int = 3, + retries_max_attempts: Optional[int] = None, regions: set = set(), ) -> "SecurityHub": """ 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..6b00bb437b 100644 --- a/prowler/providers/aws/lib/session/aws_set_up_session.py +++ b/prowler/providers/aws/lib/session/aws_set_up_session.py @@ -40,8 +40,10 @@ class AwsSetUpSession: aws_access_key_id: str = None, aws_secret_access_key: str = None, aws_session_token: Optional[str] = None, - retries_max_attempts: int = 3, + retries_max_attempts: Optional[int] = None, 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, @@ -100,6 +106,8 @@ class AwsSetUpSession: session=self._session.current_session, aws_region=sts_region, ) + # Later STS calls go where validation got an answer, not where it timed out + sts_region = caller_identity.region logger.info("Credentials validated") ######## diff --git a/prowler/providers/aws/services/batch/batch_service.py b/prowler/providers/aws/services/batch/batch_service.py index d0110ffbaf..9902fa6175 100644 --- a/prowler/providers/aws/services/batch/batch_service.py +++ b/prowler/providers/aws/services/batch/batch_service.py @@ -1,6 +1,7 @@ from itertools import zip_longest from typing import Optional +from botocore.exceptions import ClientError from pydantic.v1 import BaseModel from prowler.lib.logger import logger @@ -34,18 +35,38 @@ class BatchJobDefinition(BaseModel): container_properties: BatchContainerProperties +class BatchComputeEnvironment(BaseModel): + """An AWS Batch compute environment with its networking configuration.""" + + name: str + arn: str + region: str + security_groups: list[str] = [] + subnets: list[str] = [] + + class Batch(AWSService): - """AWS Batch service client for listing job definitions.""" + """AWS Batch service client for listing job definitions and compute environments.""" def __init__(self, provider): super().__init__(__class__.__name__, provider) self.job_definitions = {} self._job_definitions_by_region = {} + self.compute_environments = {} + # Security groups referenced by compute environments. A compute + # environment holds them in its configuration even while it is scaled + # down to zero instances, so no ENI exists to reveal the association. + self.security_groups_in_use = set() + # Regions whose compute environments could not be listed. Their + # security group associations are unknown rather than absent, so + # consumers must not read an empty result as "nothing is attached". + self.compute_environment_lookup_failed_regions = set() self.job_definition_limit = get_resource_scan_limit( self.audit_config, "max_batch_job_definitions" ) self.__threading_call__(self._list_job_definitions) self._select_job_definitions_for_analysis() + self.__threading_call__(self._describe_compute_environments) def _list_job_definitions(self, regional_client): """List ACTIVE job definitions for a regional client.""" @@ -89,6 +110,47 @@ class Batch(AWSService): f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _describe_compute_environments(self, regional_client): + """Describe the compute environments for a regional client.""" + logger.info("Batch - Describing Compute Environments...") + try: + paginator = regional_client.get_paginator("describe_compute_environments") + for page in paginator.paginate(): + for compute_environment in page.get("computeEnvironments", []): + arn = compute_environment["computeEnvironmentArn"] + if self.audit_resources and not is_resource_filtered( + arn, self.audit_resources + ): + continue + compute_resources = compute_environment.get("computeResources", {}) + security_groups = compute_resources.get("securityGroupIds", []) + self.security_groups_in_use.update(security_groups) + self.compute_environments[arn] = BatchComputeEnvironment( + name=compute_environment["computeEnvironmentName"], + arn=arn, + region=regional_client.region, + security_groups=security_groups, + subnets=compute_resources.get("subnets", []), + ) + except ClientError as error: + self.compute_environment_lookup_failed_regions.add(regional_client.region) + if error.response["Error"]["Code"] in ( + "AccessDeniedException", + "UnrecognizedClientException", + ): + logger.warning( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + else: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + self.compute_environment_lookup_failed_regions.add(regional_client.region) + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + def _select_job_definitions_for_analysis(self): """Apply the global resource limit, interleaving regions fairly.""" interleaved = [ 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/bedrock/bedrock_agent_role_not_shared_across_agents/__init__.py b/prowler/providers/aws/services/bedrock/bedrock_agent_role_not_shared_across_agents/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/bedrock/bedrock_agent_role_not_shared_across_agents/bedrock_agent_role_not_shared_across_agents.metadata.json b/prowler/providers/aws/services/bedrock/bedrock_agent_role_not_shared_across_agents/bedrock_agent_role_not_shared_across_agents.metadata.json new file mode 100644 index 0000000000..00581d65df --- /dev/null +++ b/prowler/providers/aws/services/bedrock/bedrock_agent_role_not_shared_across_agents/bedrock_agent_role_not_shared_across_agents.metadata.json @@ -0,0 +1,42 @@ +{ + "Provider": "aws", + "CheckID": "bedrock_agent_role_not_shared_across_agents", + "CheckTitle": "Bedrock Agent has a dedicated execution role", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "bedrock", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "Other", + "ResourceGroup": "ai_ml", + "Description": "Every **Bedrock Agent** assumes the role in its `agentResourceRoleArn`. That role must belong to exactly one agent, so no agent inherits another's permissions.", + "Risk": "A **shared execution role** gives every agent using it the union of all their permissions, so a low-trust agent reaching an untrusted tool holds the rights granted for a high-trust one, turning a single **prompt injection** into access it was never scoped for.\n\nIt also destroys **attribution**: **CloudTrail** records the role session, so an action cannot be traced to one agent.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/bedrock/latest/userguide/agents-permissions.html", + "https://docs.aws.amazon.com/bedrock/latest/APIReference/API_agent_UpdateAgent.html" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "```yaml\nResources:\n ExampleResource:\n Type: AWS::Bedrock::Agent\n Properties:\n AgentName: example_resource\n AgentResourceRoleArn: arn:aws:iam::123456789012:role/AmazonBedrockExecutionRoleForAgents_example_resource # Critical: one role per agent, never shared\n```", + "Other": "1. Open the AWS Console and go to Amazon Bedrock\n2. Select **Agents**, then an agent sharing a role\n3. Create a new IAM role trusted by bedrock.amazonaws.com holding only that agent's permissions\n4. Scope its trust policy with aws:SourceArn set to the agent ARN\n5. Edit the agent to use the new role, keeping its other settings, then prepare the agent to cut a version\n6. Open **Aliases** and point every alias at the new version, since an alias left on the old version keeps invoking the shared role\n7. Repeat until each agent has its own role, then remove the unused permissions from the old role", + "Terraform": "```hcl\nresource \"aws_bedrockagent_agent\" \"example_resource\" {\n agent_name = \"example_resource\"\n agent_resource_role_arn = aws_iam_role.example_resource_agent.arn # Critical: one role per agent, never shared\n foundation_model = \"example-model-id\"\n}\n```" + }, + "Recommendation": { + "Text": "Give each Bedrock Agent its own execution role scoped to that agent's ARN with aws:SourceArn. Repoint the draft with UpdateAgent, passing every field it already has because omitted ones are not preserved, then PrepareAgent and UpdateAgentAlias to move each alias onto the new version: an alias left on the old version keeps invoking the shared role.", + "Url": "https://hub.prowler.com/check/bedrock_agent_role_not_shared_across_agents" + } + }, + "Categories": [ + "gen-ai", + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [ + "bedrock_agent_role_least_privilege" + ], + "Notes": "Reports one finding per AGENT, not per role, so a role shared by three agents produces three findings each naming the other two. Sharing is counted across the whole account inventory, so a partial scan can under-report. An agent whose role ARN could not be retrieved from GetAgent returns MANUAL rather than PASS, because an unknown role can be neither confirmed nor ruled out as shared, and it is also excluded from every other agent's share count." +} diff --git a/prowler/providers/aws/services/bedrock/bedrock_agent_role_not_shared_across_agents/bedrock_agent_role_not_shared_across_agents.py b/prowler/providers/aws/services/bedrock/bedrock_agent_role_not_shared_across_agents/bedrock_agent_role_not_shared_across_agents.py new file mode 100644 index 0000000000..55fe930e1b --- /dev/null +++ b/prowler/providers/aws/services/bedrock/bedrock_agent_role_not_shared_across_agents/bedrock_agent_role_not_shared_across_agents.py @@ -0,0 +1,164 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.bedrock.bedrock_agent_client import ( + bedrock_agent_client, +) + +SHARED_ROLE_AGENT_COUNT = 2 + + +class bedrock_agent_role_not_shared_across_agents(Check): + """Ensure each Bedrock Agent has a dedicated execution role. + + A shared `agentResourceRoleArn` means every agent using it inherits the + union of all their permissions, so the least-privileged agent in the set + still holds the rights of the most privileged one. It also destroys + attribution: CloudTrail records the role session, so an action taken with + that role cannot be tied back to a single agent. + + Every role an agent can run under counts, not only the working draft's. + GetAgent returns the draft, but an agent version is an immutable snapshot + that keeps the role it was cut with, and an alias routes invocations at a + specific version — so a deployed version can still hold a role the draft no + longer has, and sharing it is the same exposure. Only versions an alias + routes to are considered, since a version no alias points at cannot be + invoked. An alias routing at DRAFT resolves to the draft role already read. + + Sharing is judged against the whole account inventory, so the verdict is only + as complete as that inventory. Dedication cannot be asserted while any part of + the picture is missing — an unlisted Region, an agent whose own role could + not be read, or an agent whose deployed versions could not be listed may hold + the same role. A role already seen on two agents is shared whatever else is + missing, so FAIL survives an incomplete inventory. + + A scan scoped with ``--resource-arn`` narrows which agents are REPORTED on, not + which agents count towards sharing: the role index is built from the complete + account inventory, so selecting one of two agents that share a role still FAILs. + + - PASS: No role this agent holds is used by any other agent, every Region's + agent inventory was listed, every discovered agent's role was readable, and + every agent's deployed versions were listed. + - FAIL: Two or more agents hold the same role, on the draft or on a deployed + version; the other agents holding it are named in the message, and the + version is named when the sharing is through one. + - MANUAL: This agent's own execution role could not be retrieved from + GetAgent; or its roles look dedicated but another agent's role or deployed + versions are unknown, or a Region could not be listed; or ListAgents failed + for a Region, which therefore contributed no agents at all. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the check logic. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + + incomplete_regions = sorted(bedrock_agent_client.agents_scan_errors) + for region, error in sorted(bedrock_agent_client.agents_scan_errors.items()): + report = Check_Report_AWS( + metadata=self.metadata(), resource={"region": region} + ) + report.region = region + report.resource_id = "agent/unknown" + report.resource_arn = f"arn:{bedrock_agent_client.audited_partition}:bedrock:{region}:{bedrock_agent_client.audited_account}:agent/unknown" + report.status = "MANUAL" + report.status_extended = f"Bedrock Agents could not be listed in region {region} ({error}); verify manually that no execution role is shared between agents." + findings.append(report) + + # Keyed on agent ARN, not name, so two same-named agents still count + # twice. An agent is indexed under every role it holds, because a + # deployed version keeps the role it was cut with: sharing through a + # version is the same exposure as sharing through the draft. Indexed over + # all_agents, not the filtered agents; findings are emitted from the + # filtered set below. + agents_by_role = {} + unresolved_agents = [] + for agent in bedrock_agent_client.all_agents.values(): + entry = (agent.arn, agent.name or agent.id) + for role_arn in self._roles_held_by(agent): + if entry not in agents_by_role.setdefault(role_arn, []): + agents_by_role[role_arn].append(entry) + # Both gaps are recorded, not just the first: an agent can have an + # unreadable draft role and an unlistable version inventory at once, + # and each independently keeps another agent from being called + # dedicated. + if not agent.detail_retrieved or not agent.role_arn: + unresolved_agents.append( + f"the execution role of {agent.name or agent.id}" + ) + if not agent.versions_listed: + unresolved_agents.append( + f"deployed versions of {agent.name or agent.id}" + ) + + # Dedication can only be asserted from a complete picture: an unlisted + # Region, an agent whose role could not be read, or an agent whose + # deployed versions could not be listed may hold the same role. + incomplete = sorted(unresolved_agents) + [ + f"agents in region {region}" for region in incomplete_regions + ] + + for agent in bedrock_agent_client.agents.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=agent) + name = agent.name or agent.id + + if not agent.detail_retrieved or not agent.role_arn: + report.status = "MANUAL" + report.status_extended = f"Bedrock Agent {name} execution role could not be retrieved in region {agent.region}; verify manually that no other agent shares it." + findings.append(report) + continue + + # Any role this agent holds, on the draft or on a deployed version, + # is a finding when another agent holds it too. Reported on the + # lowest-sorted shared role for determinism across scans. + shared_roles = sorted( + role_arn + for role_arn in self._roles_held_by(agent) + if len(agents_by_role.get(role_arn, [])) >= SHARED_ROLE_AGENT_COUNT + ) + if shared_roles: + role_arn = shared_roles[0] + others = sorted( + other_name + for other_arn, other_name in agents_by_role[role_arn] + if other_arn != agent.arn + ) + through = ( + "" + if role_arn == agent.role_arn + else f" through deployed version {sorted(version for version, version_role in agent.version_role_arns.items() if version_role == role_arn)[0]}" + ) + report.status = "FAIL" + report.status_extended = f"Bedrock Agent {name} shares execution role {role_arn}{through} with {', '.join(others)} in region {agent.region}, so each agent inherits the union of their permissions and CloudTrail cannot attribute an action to one of them." + elif incomplete: + report.status = "MANUAL" + # Each entry already reads as its own subject, because they are + # not all roles: an entry is an agent whose role is unknown, the + # deployed versions of an agent, or the agents of a Region that + # could not be listed. + report.status_extended = f"Bedrock Agent {name} execution role is used by no other agent whose role could be read in region {agent.region}, but {', '.join(incomplete)} could not be read; verify manually that none of them shares it." + else: + report.status = "PASS" + report.status_extended = f"Bedrock Agent {name} has a dedicated execution role in region {agent.region}." + findings.append(report) + + return findings + + def _roles_held_by(self, agent) -> set: + """Collect every execution role an agent can run under. + + Args: + agent: The Bedrock Agent to inspect. + + Returns: + The working draft's role plus the role of each deployed version an + alias routes to, skipping any that could not be read. + """ + roles = set() + if agent.detail_retrieved and agent.role_arn: + roles.add(agent.role_arn) + roles.update( + role_arn for role_arn in agent.version_role_arns.values() if role_arn + ) + return roles diff --git a/prowler/providers/aws/services/bedrock/bedrock_custom_model_encrypted_with_cmk/__init__.py b/prowler/providers/aws/services/bedrock/bedrock_custom_model_encrypted_with_cmk/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/bedrock/bedrock_custom_model_encrypted_with_cmk/bedrock_custom_model_encrypted_with_cmk.metadata.json b/prowler/providers/aws/services/bedrock/bedrock_custom_model_encrypted_with_cmk/bedrock_custom_model_encrypted_with_cmk.metadata.json new file mode 100644 index 0000000000..06f11e42a2 --- /dev/null +++ b/prowler/providers/aws/services/bedrock/bedrock_custom_model_encrypted_with_cmk/bedrock_custom_model_encrypted_with_cmk.metadata.json @@ -0,0 +1,43 @@ +{ + "Provider": "aws", + "CheckID": "bedrock_custom_model_encrypted_with_cmk", + "CheckTitle": "Bedrock custom model is encrypted with a customer-managed KMS key", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/AWS Security Best Practices/Data Encryption" + ], + "ServiceName": "bedrock", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "critical", + "ResourceType": "Other", + "ResourceGroup": "ai_ml", + "Description": "**Bedrock custom models** produced by model customization can be encrypted at rest with a customer-managed KMS key rather than resting under an AWS-owned key the organization cannot audit, rotate, or revoke.", + "Risk": "A **fine-tuned model** is derived from the training data used to build it and can leak that data through inference.\n\nUnder an **AWS-owned key**, the organization has no key policy to restrict who may decrypt the artifacts, no customer-controlled rotation, no **CloudTrail** record of key usage, and no way to revoke access to the model by disabling a key.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/bedrock/latest/userguide/encryption-custom-job.html", + "https://docs.aws.amazon.com/bedrock/latest/APIReference/API_GetCustomModel.html" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Open the AWS Console and go to Amazon Bedrock\n2. Select **Custom models**, then create a new customization job\n3. Under encryption, choose a customer-managed KMS key instead of the default\n4. Grant the customization service role kms:Decrypt, kms:GenerateDataKey and kms:DescribeKey on that key\n5. Re-run customization, because the key of an existing custom model cannot be changed in place", + "Terraform": "```hcl\nresource \"aws_bedrock_custom_model\" \"example_resource\" {\n custom_model_name = \"example-custom-model\"\n job_name = \"example-customization-job\"\n base_model_identifier = \"arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-text-express-v1\"\n role_arn = \"arn:aws:iam::123456789012:role/example-bedrock-customization-role\"\n custom_model_kms_key_id = \"arn:aws:kms:us-east-1:123456789012:key/example-key-id\" # Critical: without this the model is encrypted with an AWS owned key\n\n hyperparameters = {\n epochCount = \"1\"\n }\n\n output_data_config {\n s3_uri = \"s3://example-bucket/output/\"\n }\n\n training_data_config {\n s3_uri = \"s3://example-bucket/training/\"\n }\n}\n```" + }, + "Recommendation": { + "Text": "Choose a customer-managed KMS key when creating each model customization job so the resulting custom model rests under a key with an auditable policy and rotation. The key cannot be changed after the model is created, so re-run customization for existing models.", + "Url": "https://hub.prowler.com/check/bedrock_custom_model_encrypted_with_cmk" + } + }, + "Categories": [ + "gen-ai", + "encryption" + ], + "DependsOn": [], + "RelatedTo": [ + "bedrock_prompt_encrypted_with_cmk" + ], + "Notes": "Reports one finding per custom model, reading modelKmsKeyArn from GetCustomModel because ListCustomModels summaries do not carry it. A model whose detail could not be retrieved returns MANUAL rather than PASS, because an absent key ARN means the answer is unknown, not that no key is set." +} diff --git a/prowler/providers/aws/services/bedrock/bedrock_custom_model_encrypted_with_cmk/bedrock_custom_model_encrypted_with_cmk.py b/prowler/providers/aws/services/bedrock/bedrock_custom_model_encrypted_with_cmk/bedrock_custom_model_encrypted_with_cmk.py new file mode 100644 index 0000000000..1ac9c7e8d8 --- /dev/null +++ b/prowler/providers/aws/services/bedrock/bedrock_custom_model_encrypted_with_cmk/bedrock_custom_model_encrypted_with_cmk.py @@ -0,0 +1,54 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.bedrock.bedrock_client import bedrock_client + + +class bedrock_custom_model_encrypted_with_cmk(Check): + """Ensure Bedrock custom models are encrypted with a customer-managed KMS key. + + - PASS: GetCustomModel returns a `modelKmsKeyArn`, so the model artifacts + are encrypted with a key the account controls. + - FAIL: No `modelKmsKeyArn` is set, so the model is encrypted with an + AWS-owned key that the organization cannot audit, rotate, or revoke. + - MANUAL: GetCustomModel failed, so the key could not be retrieved and an + absent value cannot be read as "no key"; or ListCustomModels failed for a + region, so that region's custom models are unknown rather than absent. + + Only models this account owns are audited (`ListCustomModels` is called with + `isOwned=True`): the KMS key of a model shared in through Resource Access + Manager belongs to the owning account and cannot be changed here. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the check logic. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + + for region, error in sorted(bedrock_client.custom_models_scan_errors.items()): + report = Check_Report_AWS( + metadata=self.metadata(), resource={"region": region} + ) + report.region = region + report.resource_id = "custom-model/unknown" + report.resource_arn = f"arn:{bedrock_client.audited_partition}:bedrock:{region}:{bedrock_client.audited_account}:custom-model/unknown" + report.status = "MANUAL" + report.status_extended = f"Bedrock custom models could not be listed in region {region} ({error}); verify manually that every custom model uses a customer-managed KMS key." + findings.append(report) + + for model in bedrock_client.custom_models.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=model) + + if not model.detail_retrieved: + report.status = "MANUAL" + report.status_extended = f"Bedrock custom model {model.name} encryption configuration could not be retrieved in region {model.region}; verify manually that it uses a customer-managed KMS key." + elif model.kms_key_arn: + report.status = "PASS" + report.status_extended = f"Bedrock custom model {model.name} is encrypted with a customer-managed KMS key in region {model.region}." + else: + report.status = "FAIL" + report.status_extended = f"Bedrock custom model {model.name} is not encrypted with a customer-managed KMS key in region {model.region}, so the fine-tuned weights rest under an AWS-owned key the organization cannot audit or revoke." + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/bedrock/bedrock_guardrail_contextual_grounding_filter_enabled/__init__.py b/prowler/providers/aws/services/bedrock/bedrock_guardrail_contextual_grounding_filter_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/bedrock/bedrock_guardrail_contextual_grounding_filter_enabled/bedrock_guardrail_contextual_grounding_filter_enabled.metadata.json b/prowler/providers/aws/services/bedrock/bedrock_guardrail_contextual_grounding_filter_enabled/bedrock_guardrail_contextual_grounding_filter_enabled.metadata.json new file mode 100644 index 0000000000..2fd1b3cb54 --- /dev/null +++ b/prowler/providers/aws/services/bedrock/bedrock_guardrail_contextual_grounding_filter_enabled/bedrock_guardrail_contextual_grounding_filter_enabled.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "aws", + "CheckID": "bedrock_guardrail_contextual_grounding_filter_enabled", + "CheckTitle": "Bedrock guardrail blocks ungrounded and irrelevant model responses", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "bedrock", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "Other", + "ResourceGroup": "ai_ml", + "Description": "**Bedrock guardrails** can attach a `contextualGroundingPolicy` whose filters score each response for `GROUNDING` (supported by the retrieved source) and `RELEVANCE` (answers the question asked). Both filter types must be enabled, set to `action: BLOCK`, and carry a `threshold` above 0.", + "Risk": "Without both filters the model can return confident, unsupported answers that a **RAG application** presents as sourced fact.\n\nThree settings make a configured filter **inert**: `enabled: false` runs no evaluation, so a `BLOCK` action and tuned threshold sit unused; `action: NONE` scores the response and reports it in the trace while still delivering it; a `threshold` of 0 can never be tripped.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails-contextual-grounding-check.html", + "https://docs.aws.amazon.com/bedrock/latest/APIReference/API_GuardrailContextualGroundingFilterConfig.html" + ], + "Remediation": { + "Code": { + "CLI": "aws bedrock update-guardrail --guardrail-identifier example-guardrail-id --name example-guardrail --blocked-input-messaging \"Sorry, I cannot answer that.\" --blocked-outputs-messaging \"Sorry, I cannot answer that.\" --contextual-grounding-policy-config '{\"filtersConfig\":[{\"type\":\"GROUNDING\",\"threshold\":0.75,\"action\":\"BLOCK\",\"enabled\":true},{\"type\":\"RELEVANCE\",\"threshold\":0.75,\"action\":\"BLOCK\",\"enabled\":true}]}'", + "NativeIaC": "```yaml\nResources:\n ExampleResource:\n Type: AWS::Bedrock::Guardrail\n Properties:\n Name: example_resource\n BlockedInputMessaging: Sorry, I cannot answer that.\n BlockedOutputsMessaging: Sorry, I cannot answer that.\n ContextualGroundingPolicyConfig:\n FiltersConfig:\n - Type: GROUNDING\n Threshold: 0.75\n Action: BLOCK # Critical: NONE only reports, it does not block\n Enabled: true # Critical: false runs no evaluation at all\n - Type: RELEVANCE\n Threshold: 0.75\n Action: BLOCK\n Enabled: true\n```", + "Other": "1. Open the AWS Console and go to Amazon Bedrock\n2. Select **Guardrails**, then the guardrail\n3. Edit the contextual grounding check\n4. Enable both the grounding and the relevance filter\n5. Set each threshold above 0 to a value tuned for your corpus\n6. Set each action to Block rather than None\n7. Save and create a new guardrail version", + "Terraform": "```hcl\nresource \"awscc_bedrock_guardrail\" \"example_resource\" {\n name = \"example-guardrail\"\n blocked_input_messaging = \"Sorry, I cannot answer that.\"\n blocked_outputs_messaging = \"Sorry, I cannot answer that.\"\n\n contextual_grounding_policy_config = {\n filters_config = [\n {\n type = \"GROUNDING\"\n threshold = 0.75 # Critical: 0 can never be tripped\n action = \"BLOCK\" # Critical: NONE scores without blocking\n enabled = true # Critical: when false the evaluation never runs\n },\n {\n type = \"RELEVANCE\"\n threshold = 0.75\n action = \"BLOCK\"\n enabled = true\n },\n ]\n }\n}\n```" + }, + "Recommendation": { + "Text": "Configure both a GROUNDING and a RELEVANCE contextual grounding filter on every guardrail used by a retrieval-augmented application, set each action to BLOCK, and tune each threshold above 0 against a labelled sample rather than leaving it at the minimum.", + "Url": "https://hub.prowler.com/check/bedrock_guardrail_contextual_grounding_filter_enabled" + } + }, + "Categories": [ + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [ + "bedrock_guardrail_prompt_attack_filter_enabled" + ], + "Notes": "Reports one finding per guardrail, reading contextualGroundingPolicy.filters from GetGuardrail. A guardrail whose detail could not be retrieved returns MANUAL rather than PASS, because an absent policy means the answer is unknown, not that the control is satisfied. enabled and action are both optional members with no documented default, so a filter that is otherwise compliant but omits either returns MANUAL rather than being read as blocking or not; an explicit enabled: false or a non-BLOCK action is a definite finding and FAILs." +} diff --git a/prowler/providers/aws/services/bedrock/bedrock_guardrail_contextual_grounding_filter_enabled/bedrock_guardrail_contextual_grounding_filter_enabled.py b/prowler/providers/aws/services/bedrock/bedrock_guardrail_contextual_grounding_filter_enabled/bedrock_guardrail_contextual_grounding_filter_enabled.py new file mode 100644 index 0000000000..d94255c835 --- /dev/null +++ b/prowler/providers/aws/services/bedrock/bedrock_guardrail_contextual_grounding_filter_enabled/bedrock_guardrail_contextual_grounding_filter_enabled.py @@ -0,0 +1,136 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.bedrock.bedrock_client import bedrock_client + +REQUIRED_FILTER_TYPES = frozenset({"GROUNDING", "RELEVANCE"}) +BLOCKING_ACTION = "BLOCK" + + +class bedrock_guardrail_contextual_grounding_filter_enabled(Check): + """Ensure Bedrock guardrails block ungrounded and irrelevant model responses. + + - PASS: The guardrail configures both a GROUNDING and a RELEVANCE + contextual grounding filter, each enabled, with action BLOCK and a + threshold above 0. + - FAIL: No contextual grounding policy is configured; one of the two + required filter types is missing; a present filter carries + enabled: false, so its evaluation never runs; a present filter uses + action NONE, which scores and reports without blocking; or a present + filter has a threshold of 0, which nothing can ever trip. + - MANUAL: GetGuardrail failed, so the policy could not be retrieved and + compliance cannot be asserted from an absent field; or a filter is + otherwise compliant but omits enabled or action, leaving it unknown + whether the evaluation runs or whether it blocks; or ListGuardrails failed + for a Region, so that Region's guardrails are unknown rather than absent. + + enabled and action are both optional members of + GuardrailContextualGroundingFilter (only type and threshold are required) + and AWS documents no default for either, so an omitted value is unknown + rather than false or NONE. Reading an omitted enabled as false would FAIL + filters that are in fact evaluating; reading an omitted action as NONE + would assert a misconfiguration the response never stated, and would print + the literal None into status_extended where it reads as an AWS enum value. + Both are therefore reported MANUAL, matching how this check already treats + a guardrail whose detail could not be retrieved. An explicit enabled: false + or an explicit non-BLOCK action is a definite finding and still FAILs, as + does a zero threshold, so an unknown never masks a real one. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the check logic. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + + for region, error in sorted(bedrock_client.guardrails_scan_errors.items()): + report = Check_Report_AWS( + metadata=self.metadata(), resource={"region": region} + ) + report.region = region + report.resource_id = "guardrail/unknown" + report.resource_arn = f"arn:{bedrock_client.audited_partition}:bedrock:{region}:{bedrock_client.audited_account}:guardrail/unknown" + report.status = "MANUAL" + report.status_extended = f"Bedrock guardrails could not be listed in region {region} ({error}); verify manually that each one blocks ungrounded and irrelevant responses." + findings.append(report) + + for guardrail in bedrock_client.guardrails.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=guardrail) + + if not guardrail.detail_retrieved: + # GetGuardrail failed (permissions, throttling, transient + # error). An absent policy is not evidence of a missing one. + report.status = "MANUAL" + report.status_extended = f"Bedrock Guardrail {guardrail.name} contextual grounding policy could not be retrieved in region {guardrail.region}; verify manually that GROUNDING and RELEVANCE filters block ungrounded responses." + findings.append(report) + continue + + filters_by_type = { + filter.type: filter + for filter in guardrail.contextual_grounding_filters + if filter.type + } + + if not filters_by_type: + report.status = "FAIL" + report.status_extended = f"Bedrock Guardrail {guardrail.name} has no contextual grounding policy configured in region {guardrail.region}, so ungrounded and irrelevant responses are never detected." + findings.append(report) + continue + + missing_types = sorted(REQUIRED_FILTER_TYPES - set(filters_by_type)) + if missing_types: + report.status = "FAIL" + # Both required types can be missing at once, so the nouns follow + # the count rather than assuming a single filter. + noun = "filter" if len(missing_types) == 1 else "filters" + classes = "that class" if len(missing_types) == 1 else "those classes" + report.status_extended = f"Bedrock Guardrail {guardrail.name} contextual grounding policy is missing the {', '.join(missing_types)} {noun} in region {guardrail.region}, leaving {classes} of ungrounded response unchecked." + findings.append(report) + continue + + reasons = [] + unknown_types = [] + for filter_type in sorted(REQUIRED_FILTER_TYPES): + filter = filters_by_type[filter_type] + action = filter.action + if filter.enabled is False: + reasons.append( + f"the {filter_type} filter is disabled, so its evaluation never runs regardless of its action or threshold" + ) + elif action is not None and action != BLOCKING_ACTION: + reasons.append( + f"the {filter_type} filter uses action {action} instead of BLOCK, so it scores and reports without blocking" + ) + elif not (filter.threshold or 0) > 0: + reasons.append( + f"the {filter_type} filter has a threshold of {filter.threshold}, which no response can ever trip" + ) + elif action is None or filter.enabled is None: + # Name only the attributes actually omitted: one of the two + # may be present, and claiming both are missing describes a + # response the guardrail did not return. + missing = [ + attribute + for attribute, value in ( + ("enabled", filter.enabled), + ("action", action), + ) + if value is None + ] + unknown_types.append( + f"{filter_type} filter omits {' and '.join(missing)}" + ) + + if reasons: + report.status = "FAIL" + report.status_extended = f"Bedrock Guardrail {guardrail.name} does not block ungrounded responses in region {guardrail.region}: {'; '.join(reasons)}." + elif unknown_types: + report.status = "MANUAL" + subject = "it blocks" if len(unknown_types) == 1 else "they block" + report.status_extended = f"Bedrock Guardrail {guardrail.name} has both required contextual grounding filters with a non-zero threshold in region {guardrail.region}, but the {', '.join(unknown_types)}, so whether {subject} is unknown; verify manually that the evaluation runs and blocks." + else: + report.status = "PASS" + report.status_extended = f"Bedrock Guardrail {guardrail.name} blocks ungrounded and irrelevant responses with GROUNDING and RELEVANCE filters in region {guardrail.region}." + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/bedrock/bedrock_knowledge_base_encrypted_with_cmk/__init__.py b/prowler/providers/aws/services/bedrock/bedrock_knowledge_base_encrypted_with_cmk/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/bedrock/bedrock_knowledge_base_encrypted_with_cmk/bedrock_knowledge_base_encrypted_with_cmk.metadata.json b/prowler/providers/aws/services/bedrock/bedrock_knowledge_base_encrypted_with_cmk/bedrock_knowledge_base_encrypted_with_cmk.metadata.json new file mode 100644 index 0000000000..a6ea8bfe5f --- /dev/null +++ b/prowler/providers/aws/services/bedrock/bedrock_knowledge_base_encrypted_with_cmk/bedrock_knowledge_base_encrypted_with_cmk.metadata.json @@ -0,0 +1,43 @@ +{ + "Provider": "aws", + "CheckID": "bedrock_knowledge_base_encrypted_with_cmk", + "CheckTitle": "Bedrock knowledge base data source is encrypted with a customer-managed KMS key", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/AWS Security Best Practices/Data Encryption" + ], + "ServiceName": "bedrock", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "Other", + "ResourceGroup": "ai_ml", + "Description": "Each data source of a **Bedrock knowledge base** can set `serverSideEncryptionConfiguration.kmsKeyArn`, which encrypts the transient storage used while documents are chunked and embedded, rather than relying on an AWS-owned key.", + "Risk": "**Knowledge base data sources** carry the private corpus a **RAG application** answers from, often the most sensitive content in the workload.\n\nUnder an **AWS-owned key**, there is no key policy restricting who may decrypt the ingestion storage, no account-controlled rotation, no **CloudTrail** record of key usage, and no way to revoke access by disabling a key.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/bedrock/latest/userguide/encryption-kb.html", + "https://docs.aws.amazon.com/bedrock/latest/APIReference/API_agent_GetDataSource.html" + ], + "Remediation": { + "Code": { + "CLI": "aws bedrock-agent update-data-source --knowledge-base-id example-kb-id --data-source-id example-ds-id --name example-data-source --data-source-configuration '{\"type\":\"S3\",\"s3Configuration\":{\"bucketArn\":\"arn:aws:s3:::example-bucket\"}}' --server-side-encryption-configuration '{\"kmsKeyArn\":\"arn:aws:kms:us-east-1:123456789012:key/example-key-id\"}'", + "NativeIaC": "```yaml\nResources:\n ExampleResource:\n Type: AWS::Bedrock::DataSource\n Properties:\n Name: example_resource\n KnowledgeBaseId: ABCDE12345\n DataSourceConfiguration:\n Type: S3\n S3Configuration:\n BucketArn: arn:aws:s3:::example-bucket\n ServerSideEncryptionConfiguration:\n KmsKeyArn: arn:aws:kms:us-east-1:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab # Critical: omitting this uses an AWS-owned key\n```", + "Other": "1. Open the AWS Console and go to Amazon Bedrock\n2. Select **Knowledge bases**, then the knowledge base\n3. Select the data source and choose Edit\n4. Under transient data encryption, choose a customer-managed KMS key\n5. Grant the knowledge base service role kms:Decrypt and kms:GenerateDataKey on that key\n6. Save and re-sync the data source", + "Terraform": "```hcl\nresource \"aws_bedrockagent_data_source\" \"example_resource\" {\n name = \"example_resource\"\n knowledge_base_id = \"ABCDE12345\"\n data_source_configuration {\n type = \"S3\"\n s3_configuration {\n bucket_arn = \"arn:aws:s3:::example-bucket\"\n }\n }\n server_side_encryption_configuration {\n kms_key_arn = \"arn:aws:kms:us-east-1:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab\" # Critical: omitting this uses an AWS-owned key\n }\n}\n```" + }, + "Recommendation": { + "Text": "Set a customer-managed KMS key on every knowledge base data source so the transient ingestion storage rests under a key with an auditable policy, and grant the knowledge base service role only kms:Decrypt and kms:GenerateDataKey on it.", + "Url": "https://hub.prowler.com/check/bedrock_knowledge_base_encrypted_with_cmk" + } + }, + "Categories": [ + "gen-ai", + "encryption" + ], + "DependsOn": [], + "RelatedTo": [ + "bedrock_custom_model_encrypted_with_cmk" + ], + "Notes": "Reports one finding per DATA SOURCE, not per knowledge base, because the key is configured on the data source; the knowledge base name is carried into the message. GetDataSource nests its payload under a top-level dataSource object. A data source whose detail could not be retrieved returns MANUAL rather than PASS, because an absent key ARN means the answer is unknown, not that no key is set." +} diff --git a/prowler/providers/aws/services/bedrock/bedrock_knowledge_base_encrypted_with_cmk/bedrock_knowledge_base_encrypted_with_cmk.py b/prowler/providers/aws/services/bedrock/bedrock_knowledge_base_encrypted_with_cmk/bedrock_knowledge_base_encrypted_with_cmk.py new file mode 100644 index 0000000000..b8598a3352 --- /dev/null +++ b/prowler/providers/aws/services/bedrock/bedrock_knowledge_base_encrypted_with_cmk/bedrock_knowledge_base_encrypted_with_cmk.py @@ -0,0 +1,80 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.bedrock.bedrock_agent_client import ( + bedrock_agent_client, +) + + +class bedrock_knowledge_base_encrypted_with_cmk(Check): + """Ensure Bedrock knowledge base data sources are encrypted with a CMK. + + One finding is reported per data source, because the key is configured on + the data source rather than on the knowledge base. + + - PASS: The data source sets `serverSideEncryptionConfiguration.kmsKeyArn`, + so the transient storage used during ingestion is encrypted with a key + the account controls. + - FAIL: No `kmsKeyArn` is set, so the ingested documents rest under an + AWS-owned key the organization cannot audit, rotate, or revoke. + - MANUAL: GetDataSource failed, so the key could not be retrieved and an + absent value cannot be read as "no key"; or ListDataSources failed for the + knowledge base, so its data sources are unknown; or ListKnowledgeBases + failed for a region, so the region's knowledge bases are unknown. + + A knowledge base whose data sources could not be listed is reported against + the knowledge base itself. Reporting nothing would drop it from the output + entirely, which reads as "no data sources to flag" and is indistinguishable + from a clean result. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the check logic. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + + for region, error in sorted( + bedrock_agent_client.knowledge_bases_scan_errors.items() + ): + report = Check_Report_AWS( + metadata=self.metadata(), resource={"region": region} + ) + report.region = region + report.resource_id = "knowledge-base/unknown" + report.resource_arn = f"arn:{bedrock_agent_client.audited_partition}:bedrock:{region}:{bedrock_agent_client.audited_account}:knowledge-base/unknown" + report.status = "MANUAL" + report.status_extended = f"Bedrock knowledge bases could not be listed in region {region} ({error}); verify manually that every knowledge base data source uses a customer-managed KMS key." + findings.append(report) + + for knowledge_base in bedrock_agent_client.knowledge_bases.values(): + if knowledge_base.data_sources_listed: + continue + report = Check_Report_AWS(metadata=self.metadata(), resource=knowledge_base) + report.status = "MANUAL" + reason = ( + f" ({knowledge_base.data_sources_error})" + if knowledge_base.data_sources_error + else "" + ) + report.status_extended = f"Bedrock knowledge base {knowledge_base.name} data sources could not be listed in region {knowledge_base.region}{reason}; verify manually that each one uses a customer-managed KMS key." + findings.append(report) + + for data_source in bedrock_agent_client.data_sources.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=data_source) + knowledge_base = ( + data_source.knowledge_base_name or data_source.knowledge_base_id + ) + + if not data_source.detail_retrieved: + report.status = "MANUAL" + report.status_extended = f"Bedrock knowledge base {knowledge_base} data source {data_source.name} encryption configuration could not be retrieved in region {data_source.region}; verify manually that it uses a customer-managed KMS key." + elif data_source.kms_key_arn: + report.status = "PASS" + report.status_extended = f"Bedrock knowledge base {knowledge_base} data source {data_source.name} is encrypted with a customer-managed KMS key in region {data_source.region}." + else: + report.status = "FAIL" + report.status_extended = f"Bedrock knowledge base {knowledge_base} data source {data_source.name} is not encrypted with a customer-managed KMS key in region {data_source.region}, so ingested documents rest under an AWS-owned key the organization cannot audit or revoke." + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/bedrock/bedrock_service.py b/prowler/providers/aws/services/bedrock/bedrock_service.py index aead63a67f..8e7ea7c843 100644 --- a/prowler/providers/aws/services/bedrock/bedrock_service.py +++ b/prowler/providers/aws/services/bedrock/bedrock_service.py @@ -16,10 +16,14 @@ class Bedrock(AWSService): self.guardrails = {} self.guardrails_scanned_regions = set() self.guardrails_scan_errors = {} + self.custom_models = {} + self.custom_models_scan_errors = {} self.__threading_call__(self._get_model_invocation_logging_configuration) self.__threading_call__(self._list_guardrails) self.__threading_call__(self._get_guardrail, self.guardrails.values()) self.__threading_call__(self._list_tags_for_resource, self.guardrails.values()) + self.__threading_call__(self._list_custom_models) + self.__threading_call__(self._get_custom_model, self.custom_models.values()) def _get_model_invocation_logging_arn_template(self, region): return ( @@ -56,6 +60,7 @@ class Bedrock(AWSService): ) def _list_guardrails(self, regional_client): + """List the guardrails in a region.""" logger.info("Bedrock - Listing Guardrails...") try: paginator = regional_client.get_paginator("list_guardrails") @@ -72,9 +77,11 @@ class Bedrock(AWSService): ) self.guardrails_scanned_regions.add(regional_client.region) except ClientError as error: - self.guardrails_scan_errors[regional_client.region] = error.response[ - "Error" - ].get("Code", error.__class__.__name__) + code = error.response["Error"].get("Code", error.__class__.__name__) + # ValidationException means Bedrock is unavailable in the region: + # a definite "no guardrails", so it must not become a MANUAL finding. + if code != "ValidationException": + self.guardrails_scan_errors[regional_client.region] = code logger.error( f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) @@ -100,6 +107,19 @@ class Bedrock(AWSService): guardrail.prompt_attack_filter_strength = filter.get( "inputStrength", "NONE" ) + # Absent from the response when no grounding policy is configured. + guardrail.contextual_grounding_filters = [ + ContextualGroundingFilter( + type=filter.get("type"), + threshold=filter.get("threshold"), + action=filter.get("action"), + enabled=filter.get("enabled"), + ) + for filter in guardrail_info.get("contextualGroundingPolicy", {}).get( + "filters", [] + ) + ] + guardrail.detail_retrieved = True except Exception as error: logger.error( f"{guardrail.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" @@ -118,6 +138,64 @@ class Bedrock(AWSService): f"{guardrail.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _list_custom_models(self, regional_client): + """List the customized models owned by the audited account. + + isOwned=True is required: without it the response also carries models + shared into this account through Resource Access Manager, whose KMS key + the audited account does not own and cannot set, so auditing them + produces a finding nobody here can remediate. + """ + logger.info("Bedrock - Listing Custom Models...") + try: + paginator = regional_client.get_paginator("list_custom_models") + for page in paginator.paginate(isOwned=True): + for model in page.get("modelSummaries", []): + model_arn = model.get("modelArn", "") + if model_arn and ( + not self.audit_resources + or is_resource_filtered(model_arn, self.audit_resources) + ): + self.custom_models[model_arn] = CustomModel( + name=model.get("modelName", ""), + arn=model_arn, + region=regional_client.region, + ) + except ClientError as error: + code = error.response["Error"].get("Code", error.__class__.__name__) + # ValidationException means Bedrock is unavailable in the region: + # a definite "no models", so it must not become a MANUAL finding. + if code != "ValidationException": + self.custom_models_scan_errors[regional_client.region] = code + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + self.custom_models_scan_errors[regional_client.region] = ( + error.__class__.__name__ + ) + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _get_custom_model(self, model): + """Fetch the KMS key a custom model's weights are encrypted with. + + list_custom_models summaries do not carry modelKmsKeyArn, so a + per-model GetCustomModel call is required. + """ + logger.info("Bedrock - Getting Custom Model...") + try: + model_info = self.regional_clients[model.region].get_custom_model( + modelIdentifier=model.arn + ) + model.kms_key_arn = model_info.get("modelKmsKeyArn") + model.detail_retrieved = True + except Exception as error: + logger.error( + f"{model.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + class LoggingConfiguration(BaseModel): enabled: bool = False @@ -125,6 +203,19 @@ class LoggingConfiguration(BaseModel): s3_bucket: Optional[str] = None +class ContextualGroundingFilter(BaseModel): + """One filter of a guardrail's contextualGroundingPolicy. + + type and threshold are required by the API; enabled and action are optional + and have no documented default, so an absent value means unknown. + """ + + type: Optional[str] = None + threshold: Optional[float] = None + action: Optional[str] = None + enabled: Optional[bool] = None + + class Guardrail(BaseModel): id: str name: str @@ -133,6 +224,20 @@ class Guardrail(BaseModel): tags: Optional[list] = [] sensitive_information_filter: bool = False prompt_attack_filter_strength: Optional[str] = None + contextual_grounding_filters: list[ContextualGroundingFilter] = [] + # False when GetGuardrail failed: absent policy is unknown, not unset. + detail_retrieved: bool = False + + +class CustomModel(BaseModel): + """Model representing a Bedrock customized (fine-tuned) model.""" + + name: str + arn: str + region: str + kms_key_arn: Optional[str] = None + # False when GetCustomModel failed: absent key is unknown, not unset. + detail_retrieved: bool = False class BedrockAgent(AWSService): @@ -143,13 +248,28 @@ class BedrockAgent(AWSService): # Call AWSService's __init__ super().__init__("bedrock-agent", provider) self.agents = {} + # Every agent in the account, including those --resource-arn excluded. A check whose verdict + # for one agent depends on another (role sharing) cannot read self.agents: the agent that + # proves the sharing may be the one the operator filtered out. Same shape as + # cloudwatch_service's all_log_groups/log_groups pair -- one object, two dicts. + self.all_agents = {} self.prompts = {} + self.knowledge_bases = {} + self.data_sources = {} + self.knowledge_bases_scan_errors = {} + self.agents_scan_errors = {} self.prompt_scanned_regions: set = set() self.__threading_call__(self._list_agents) - self.__threading_call__(self._get_agent, self.agents.values()) + # Detail collection runs over the COMPLETE inventory: an out-of-scope agent's role still + # determines whether an in-scope agent's role is shared. + self.__threading_call__(self._get_agent, self.all_agents.values()) + self.__threading_call__(self._get_agent_version_roles, self.all_agents.values()) self.__threading_call__(self._list_prompts) self.__threading_call__(self._get_prompt, self.prompts.values()) self.__threading_call__(self._list_tags_for_resource, self.agents.values()) + self.__threading_call__(self._list_knowledge_bases) + self.__threading_call__(self._list_data_sources, self.knowledge_bases.values()) + self.__threading_call__(self._get_data_source, self.data_sources.values()) def _list_agents(self, regional_client): logger.info("Bedrock Agent - Listing Agents...") @@ -157,20 +277,32 @@ class BedrockAgent(AWSService): paginator = regional_client.get_paginator("list_agents") for page in paginator.paginate(): for agent in page.get("agentSummaries", []): - agent_arn = f"arn:aws:bedrock:{regional_client.region}:{self.audited_account}:agent/{agent['agentId']}" + agent_arn = f"arn:{self.audited_partition}:bedrock:{regional_client.region}:{self.audited_account}:agent/{agent['agentId']}" + agent_object = Agent( + id=agent["agentId"], + name=agent["agentName"], + arn=agent_arn, + guardrail_id=agent.get("guardrailConfiguration", {}).get( + "guardrailIdentifier" + ), + region=regional_client.region, + ) + self.all_agents[agent_arn] = agent_object if not self.audit_resources or ( is_resource_filtered(agent_arn, self.audit_resources) ): - self.agents[agent_arn] = Agent( - id=agent["agentId"], - name=agent["agentName"], - arn=agent_arn, - guardrail_id=agent.get("guardrailConfiguration", {}).get( - "guardrailIdentifier" - ), - region=regional_client.region, - ) + self.agents[agent_arn] = agent_object + except ClientError as error: + code = error.response["Error"].get("Code", error.__class__.__name__) + # ValidationException means Bedrock Agent is unavailable in the + # region: a definite "no agents", so it must not become MANUAL. + if code != "ValidationException": + self.agents_scan_errors[regional_client.region] = code + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) except Exception as error: + self.agents_scan_errors[regional_client.region] = error.__class__.__name__ logger.error( f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) @@ -186,11 +318,86 @@ class BedrockAgent(AWSService): try: agent_info = self.regional_clients[agent.region].get_agent(agentId=agent.id) agent.role_arn = agent_info.get("agent", {}).get("agentResourceRoleArn") + agent.detail_retrieved = True except Exception as error: logger.error( f"{agent.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + # PREPARED is the only status AWS documents as ready to invoke. CREATING and + # UPDATING are in-flight, so their routed versions are unknown rather than + # definitely active or inactive. + TRANSITIONAL_ALIAS_STATUSES = {"CREATING", "UPDATING"} + + @staticmethod + def _is_alias_active(alias: dict) -> bool: + """Can this alias actually invoke the version it routes to? + + Args: + alias: One agentAliasSummaries entry from ListAgentAliases. + + Returns: + True only when the alias is prepared and does not reject invocations. + """ + if alias.get("aliasInvocationState") == "REJECT_INVOCATIONS": + return False + return alias.get("agentAliasStatus") == "PREPARED" + + def _get_agent_version_roles(self, agent): + """Fetch the execution role of every agent version an ACTIVE alias routes to. + + GetAgent returns only the working draft. An agent version is an + immutable snapshot that keeps the role it was cut with, and an alias + routes invocations at a specific version, so a deployed version can + still hold a role the draft no longer has. Only versions an active alias + routes to are fetched: a version nothing can invoke is not live + exposure, so reporting on it would be a false FAIL. + """ + logger.info("Bedrock Agent - Getting Agent Version Roles...") + try: + client = self.regional_clients[agent.region] + paginator = client.get_paginator("list_agent_aliases") + routed_versions = set() + inventory_complete = True + for page in paginator.paginate(agentId=agent.id): + for alias in page.get("agentAliasSummaries", []): + if ( + alias.get("agentAliasStatus") + in self.TRANSITIONAL_ALIAS_STATUSES + ): + inventory_complete = False + continue + if not self._is_alias_active(alias): + continue + for route in alias.get("routingConfiguration", []): + version = route.get("agentVersion") + # agentVersion is an optional member of the routing + # configuration, and DRAFT routes at the working draft + # whose role GetAgent already captured. + if version and version != "DRAFT": + routed_versions.add(version) + + for version in sorted(routed_versions): + version_info = client.get_agent_version( + agentId=agent.id, agentVersion=version + ) + agent.version_role_arns[version] = version_info.get( + "agentVersion", {} + ).get("agentResourceRoleArn") + agent.versions_listed = inventory_complete + except ClientError as error: + agent.versions_error = error.response["Error"].get( + "Code", error.__class__.__name__ + ) + logger.error( + f"{agent.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + agent.versions_error = error.__class__.__name__ + logger.error( + f"{agent.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + def _list_prompts(self, regional_client): """List all prompts in a region.""" logger.info("Bedrock Agent - Listing Prompts...") @@ -229,6 +436,111 @@ class BedrockAgent(AWSService): f"{prompt.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _list_knowledge_bases(self, regional_client): + """List the knowledge bases in a region.""" + logger.info("Bedrock Agent - Listing Knowledge Bases...") + try: + paginator = regional_client.get_paginator("list_knowledge_bases") + for page in paginator.paginate(): + for knowledge_base in page.get("knowledgeBaseSummaries", []): + knowledge_base_id = knowledge_base.get("knowledgeBaseId", "") + if not knowledge_base_id: + continue + knowledge_base_arn = f"arn:{self.audited_partition}:bedrock:{regional_client.region}:{self.audited_account}:knowledge-base/{knowledge_base_id}" + if not self.audit_resources or is_resource_filtered( + knowledge_base_arn, self.audit_resources + ): + self.knowledge_bases[knowledge_base_arn] = KnowledgeBase( + id=knowledge_base_id, + name=knowledge_base.get("name", ""), + arn=knowledge_base_arn, + region=regional_client.region, + ) + except ClientError as error: + code = error.response["Error"].get("Code", error.__class__.__name__) + # ValidationException means Bedrock Agent is unavailable in the + # region: a definite "none", so it must not become a MANUAL finding. + if code != "ValidationException": + self.knowledge_bases_scan_errors[regional_client.region] = code + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + self.knowledge_bases_scan_errors[regional_client.region] = ( + error.__class__.__name__ + ) + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _list_data_sources(self, knowledge_base): + """List the data sources attached to one knowledge base. + + A failure is recorded on the knowledge base itself: findings are per data + source, so an unlisted knowledge base would otherwise vanish from the report. + """ + logger.info("Bedrock Agent - Listing Data Sources...") + try: + paginator = self.regional_clients[knowledge_base.region].get_paginator( + "list_data_sources" + ) + for page in paginator.paginate(knowledgeBaseId=knowledge_base.id): + for data_source in page.get("dataSourceSummaries", []): + data_source_id = data_source.get("dataSourceId", "") + if not data_source_id: + continue + data_source_arn = ( + f"{knowledge_base.arn}/data-source/{data_source_id}" + ) + # No filter here: the parent knowledge base was already + # filtered on its own ARN, and this ARN is synthetic because + # AWS exposes none for a data source, so it could never match + # a user-supplied --resource-arn and would silently drop every + # data source of an in-scope knowledge base. + self.data_sources[data_source_arn] = KnowledgeBaseDataSource( + id=data_source_id, + name=data_source.get("name", ""), + arn=data_source_arn, + region=knowledge_base.region, + knowledge_base_id=knowledge_base.id, + knowledge_base_name=knowledge_base.name, + ) + knowledge_base.data_sources_listed = True + except ClientError as error: + knowledge_base.data_sources_error = error.response["Error"].get( + "Code", error.__class__.__name__ + ) + logger.error( + f"{knowledge_base.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + knowledge_base.data_sources_error = error.__class__.__name__ + logger.error( + f"{knowledge_base.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _get_data_source(self, data_source): + """Fetch the KMS key a data source's transient storage is encrypted with.""" + logger.info("Bedrock Agent - Getting Data Source...") + try: + # GetDataSource nests everything under a top-level dataSource object. + data_source_info = ( + self.regional_clients[data_source.region] + .get_data_source( + knowledgeBaseId=data_source.knowledge_base_id, + dataSourceId=data_source.id, + ) + .get("dataSource", {}) + ) + data_source.kms_key_arn = data_source_info.get( + "serverSideEncryptionConfiguration", {} + ).get("kmsKeyArn") + data_source.detail_retrieved = True + except Exception as error: + logger.error( + f"{data_source.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + def _list_tags_for_resource(self, resource): """List tags for a Bedrock Agent resource.""" logger.info("Bedrock Agent - Listing Tags for Resource...") @@ -256,6 +568,17 @@ class Agent(BaseModel): role_arn: Optional[str] = None region: str tags: Optional[list] = [] + # False when GetAgent failed: absent role is unknown, not unset. + detail_retrieved: bool = False + # Execution role of each numbered version an alias routes to, keyed by + # version. A version is an immutable snapshot, so it keeps the role it was + # cut with even after the working draft's role changes. + version_role_arns: dict = {} + # True once the alias and version inventory was read in full, so an agent + # with no deployed versions genuinely has none rather than that they could + # not be listed. + versions_listed: bool = False + versions_error: Optional[str] = None class Prompt(BaseModel): @@ -266,3 +589,30 @@ class Prompt(BaseModel): arn: str region: str customer_encryption_key_arn: Optional[str] = None + + +class KnowledgeBase(BaseModel): + """Model representing a Bedrock Agent knowledge base.""" + + id: str + name: str + arn: str + region: str + # False when ListDataSources failed: empty set is unknown, not none. + data_sources_listed: bool = False + # The error code from a failed ListDataSources, for the finding message. + data_sources_error: Optional[str] = None + + +class KnowledgeBaseDataSource(BaseModel): + """Model representing a data source attached to a Bedrock knowledge base.""" + + id: str + name: str + arn: str + region: str + knowledge_base_id: str + knowledge_base_name: Optional[str] = None + kms_key_arn: Optional[str] = None + # False when GetDataSource failed: absent key is unknown, not unset. + detail_retrieved: bool = False 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/ec2/ec2_securitygroup_not_used/ec2_securitygroup_not_used.metadata.json b/prowler/providers/aws/services/ec2/ec2_securitygroup_not_used/ec2_securitygroup_not_used.metadata.json index e417b1d2d2..f85dcf2610 100644 --- a/prowler/providers/aws/services/ec2/ec2_securitygroup_not_used/ec2_securitygroup_not_used.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_securitygroup_not_used/ec2_securitygroup_not_used.metadata.json @@ -11,7 +11,7 @@ "Severity": "low", "ResourceType": "AwsEc2SecurityGroup", "ResourceGroup": "network", - "Description": "EC2 security groups, except `default`, are assessed for **unused** status: zero attached network interfaces, no AWS Lambda associations, and no references from other security groups.", + "Description": "EC2 security groups, except `default`, are assessed for **unused** status: zero attached network interfaces, no AWS Lambda associations, no AWS Batch compute environment associations, and no references from other security groups.", "Risk": "Orphaned security groups may later be attached with **overly permissive rules** without review, enabling unintended inbound or lateral access that compromises **confidentiality** and **integrity**. They also create **configuration drift**, increasing the chance of misapplied access controls.", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/aws/services/ec2/ec2_securitygroup_not_used/ec2_securitygroup_not_used.py b/prowler/providers/aws/services/ec2/ec2_securitygroup_not_used/ec2_securitygroup_not_used.py index c45693c498..54a0ff1f90 100644 --- a/prowler/providers/aws/services/ec2/ec2_securitygroup_not_used/ec2_securitygroup_not_used.py +++ b/prowler/providers/aws/services/ec2/ec2_securitygroup_not_used/ec2_securitygroup_not_used.py @@ -1,5 +1,6 @@ from prowler.lib.check.models import Check, Check_Report_AWS from prowler.providers.aws.services.awslambda.awslambda_client import awslambda_client +from prowler.providers.aws.services.batch.batch_client import batch_client from prowler.providers.aws.services.ec2.ec2_client import ec2_client @@ -18,6 +19,9 @@ class ec2_securitygroup_not_used(Check): sg_in_lambda = ( security_group.id in awslambda_client.security_groups_in_use ) + # A Batch compute environment scaled down to zero instances + # keeps its security groups in configuration without any ENI + sg_in_batch = security_group.id in batch_client.security_groups_in_use sg_associated = False for sg in ec2_client.security_groups.values(): if security_group.id in sg.associated_sgs: @@ -25,10 +29,24 @@ class ec2_securitygroup_not_used(Check): if ( len(security_group.network_interfaces) == 0 and not sg_in_lambda + and not sg_in_batch and not sg_associated ): - report.status = "FAIL" - report.status_extended = f"Security group {security_group.name} ({security_group.id}) it is not being used." + # Compute environments failing to list leaves their security + # group associations unknown, not absent, so reporting the + # group as unused would be a guess. Not being able to read + # the compute environments is a lack of visibility, not a + # misconfiguration, so report MANUAL rather than asserting a + # status either way. + if ( + security_group.region + in batch_client.compute_environment_lookup_failed_regions + ): + report.status = "MANUAL" + report.status_extended = f"Security group {security_group.name} ({security_group.id}) usage could not be verified because AWS Batch compute environments could not be listed in region {security_group.region}; grant batch:DescribeComputeEnvironments and run the check again." + else: + report.status = "FAIL" + report.status_extended = f"Security group {security_group.name} ({security_group.id}) it is not being used." findings.append(report) 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/ecr/ecr_repository_image_no_secrets/__init__.py b/prowler/providers/aws/services/ecr/ecr_repository_image_no_secrets/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/ecr/ecr_repository_image_no_secrets/ecr_repository_image_no_secrets.metadata.json b/prowler/providers/aws/services/ecr/ecr_repository_image_no_secrets/ecr_repository_image_no_secrets.metadata.json new file mode 100644 index 0000000000..4c1138a6ae --- /dev/null +++ b/prowler/providers/aws/services/ecr/ecr_repository_image_no_secrets/ecr_repository_image_no_secrets.metadata.json @@ -0,0 +1,42 @@ +{ + "Provider": "aws", + "CheckID": "ecr_repository_image_no_secrets", + "CheckTitle": "ECR repository image contains no hardcoded secrets", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Sensitive Data Identifications/Passwords", + "Effects/Data Exposure" + ], + "ServiceName": "ecr", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "AwsEcrRepository", + "ResourceGroup": "container", + "Description": "The **latest image** pushed to each **Amazon ECR repository** is analyzed for **embedded secrets**: environment variables and build history (Dockerfile instructions) recorded in the image configuration, plus the file contents of every filesystem layer. Findings reference the variable, build step, or file, never the secret value.", + "Risk": "Anyone able to pull the image obtains any **credentials, tokens, or keys** embedded at build time via `ENV`, `ARG`, inline `RUN` commands, or files copied into the image (e.g. `COPY .env .`).\n\nLeaked credentials enable unauthorized access to databases, APIs, or cloud resources, and rotation is harder once secrets are baked into distributed image artifacts.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/AmazonECR/latest/userguide/security-best-practices.html", + "https://docs.aws.amazon.com/secretsmanager/latest/userguide/best-practices.html", + "https://docs.docker.com/build/building/secrets/" + ], + "Remediation": { + "Code": { + "CLI": "aws ecr batch-delete-image --repository-name --image-ids imageDigest=", + "NativeIaC": "", + "Other": "1. Remove the secret from the Dockerfile (ENV/ARG/RUN) or from any file copied into the build context, and rebuild the image without it.\n2. Provide the secret at runtime instead: reference AWS Secrets Manager or SSM Parameter Store from your ECS task definition, EKS pod (Secrets Store CSI driver), or application code.\n3. Push the rebuilt image and delete the compromised image versions from the repository.\n4. Rotate the exposed credential immediately.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Never bake secrets into images with `ENV`, `ARG`, inline `RUN` commands, or copied files. Use **BuildKit build secrets** (`--mount=type=secret`) at build time and **AWS Secrets Manager**/Parameter Store at runtime. Add secret scanning to CI/CD before pushing images.", + "Url": "https://hub.prowler.com/check/ecr_repository_image_no_secrets" + } + }, + "Categories": [ + "secrets" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Only the most recently pushed image in each repository is scanned; older tagged images are not scanned. The latest scannable image is evaluated in every repository regardless of whether scan-on-push is enabled. The scanned image's configuration (environment variables and build history) plus every filesystem layer's file contents are analyzed. A multi-architecture image resolves to a single platform's manifest; other architectures in the same manifest list are not scanned. To bound cost, a single layer over 100 MB (compressed) is not downloaded, an individual file over 1 MB is not scanned, and scanning of an image stops after 5000 files or 500 MB (decompressed). When part of an image cannot be scanned this way, a clean result is reported as MANUAL (coverage was incomplete) rather than PASS, and a FAIL still discloses that some content was skipped. Requires the ecr:BatchGetImage and ecr:GetDownloadUrlForLayer permissions in addition to SecurityAudit." +} diff --git a/prowler/providers/aws/services/ecr/ecr_repository_image_no_secrets/ecr_repository_image_no_secrets.py b/prowler/providers/aws/services/ecr/ecr_repository_image_no_secrets/ecr_repository_image_no_secrets.py new file mode 100644 index 0000000000..abab734e63 --- /dev/null +++ b/prowler/providers/aws/services/ecr/ecr_repository_image_no_secrets/ecr_repository_image_no_secrets.py @@ -0,0 +1,244 @@ +import re + +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) +from prowler.providers.aws.services.ecr.ecr_client import ecr_client + +_SAFE_ENVIRONMENT_VARIABLE_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") + + +class ecr_repository_image_no_secrets(Check): + """Ensure the latest ECR repository image embeds no hardcoded secrets. + + The most recently pushed image in every ECR repository is resolved to a + single scannable manifest (a multi-arch image resolves to one platform's + manifest; other architectures in the same manifest list are not + scanned) and scanned for plaintext secrets in its configuration + (environment variables, build history) and every filesystem layer's + file contents. Older tagged images are not scanned. + - PASS: no secrets detected and the whole image was scanned. + - FAIL: a potential secret was detected; the variable, build step, or + file is reported, never the secret value. + - MANUAL: the image could not be scanned in full, so a clean result would + be misleading -- the manifest could not be retrieved or resolved, the + scan itself failed, or part of the image exceeded configured size limits + or could not be retrieved. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the check logic. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + secrets_ignore_patterns = ecr_client.audit_config.get( + "secrets_ignore_patterns", [] + ) + validate = ecr_client.audit_config.get("secrets_validate", False) + + # Phase 1: collect. The service yields (repository, image, scan_data) + # lazily, downloading each image's manifest, config, and layers; each + # image contributes an env/history/file payload per scannable unit so + # a finding's key maps back to a variable, build step, or file. + scanned = [] + + def image_payloads(): + """Yield keyed scan payloads, recording each image into `scanned`.""" + for repository, image, scan_data in ecr_client._get_image_scan_data(): + index = len(scanned) + scanned.append((repository, image, scan_data)) + if scan_data is None or isinstance(scan_data, Exception): + continue + for env_index, entry in enumerate(scan_data.env): + yield (index, f"environment:{env_index}"), entry + for history_index, entry in enumerate(scan_data.history): + yield (index, f"history:{history_index}"), entry + for file_index, scanned_file in enumerate(scan_data.files): + yield (index, f"file:{file_index}"), scanned_file.content + # Free the file's contents once handed to the scanner. The + # report phase needs only its path and layer digest, so + # retained memory stays flat instead of growing with the + # number of repositories scanned. + scanned_file.content = "" + + # Phase 2: batch — one call, chunked Kingfisher subprocesses. This + # must fully consume image_payloads() so every image is appended to + # `scanned` before Phase 3 runs; detect_secrets_scan_batch does so + # today, but a future short-circuit there would silently drop images + # from the report loop. + scan_error = None + try: + batch_results = detect_secrets_scan_batch( + image_payloads(), + excluded_secrets=secrets_ignore_patterns, + validate=validate, + ) + except SecretsScanError as error: + batch_results = {} + scan_error = error + + if scan_error: + # The scan failed and the payload generator may not have been + # consumed, so build the MANUAL reports from the repositories + # themselves rather than risk a false PASS or a missing finding. + for registry in ecr_client.registries.values(): + for repository in registry.repositories: + image = ecr_client._get_scan_target_image(repository) + if isinstance(image, Exception): + findings.append( + self._build_scan_error_report(repository, image) + ) + elif image is not None: + report = self._build_report(repository, image) + report.status = "MANUAL" + report.status_extended = ( + f"Could not scan image '{image.latest_tag}' " + f"({image.latest_digest}) of ECR repository " + f"{repository.name} for secrets: {scan_error}; " + f"manual review is required." + ) + findings.append(report) + return findings + + # Phase 3: report — one finding per scanned image. + for index, (repository, image, scan_data) in enumerate(scanned): + if isinstance(scan_data, Exception): + findings.append(self._build_scan_error_report(repository, scan_data)) + continue + report = self._build_report(repository, image) + image_reference = ( + f"image '{image.latest_tag}' ({image.latest_digest}) of ECR " + f"repository {repository.name}" + ) + + if scan_data is None: + report.status = "MANUAL" + report.status_extended = ( + f"Could not resolve or retrieve the manifest of the " + f"{image_reference} to scan it for secrets; manual " + f"review is required." + ) + findings.append(report) + continue + + env_findings_by_index = { + int(key[1].split(":", 1)[1]): entry_secrets + for key, entry_secrets in batch_results.items() + if key[0] == index and key[1].startswith("environment:") + } + history_findings_by_index = { + int(key[1].split(":", 1)[1]): entry_secrets + for key, entry_secrets in batch_results.items() + if key[0] == index and key[1].startswith("history:") + } + file_findings_by_index = { + int(key[1].split(":", 1)[1]): file_secrets + for key, file_secrets in batch_results.items() + if key[0] == index and key[1].startswith("file:") + } + + if ( + env_findings_by_index + or history_findings_by_index + or file_findings_by_index + ): + secrets_found = [] + all_secrets = [] + + for env_index, env_findings in env_findings_by_index.items(): + variable = None + if 0 <= env_index < len(scan_data.env): + entry = scan_data.env[env_index] + # Only a well-formed "NAME=value" entry has a name safe + # to report; an entry with no "=" may itself be the + # secret, so it is never echoed back. + if "=" in entry: + candidate = entry.split("=", 1)[0] + if _SAFE_ENVIRONMENT_VARIABLE_NAME.fullmatch(candidate): + variable = candidate + all_secrets.extend(env_findings) + for secret in env_findings: + if variable is not None: + secrets_found.append( + f"{secret['type']} in environment variable {variable}" + ) + else: + secrets_found.append( + f"{secret['type']} in image environment variables" + ) + for ( + history_index, + history_findings, + ) in history_findings_by_index.items(): + all_secrets.extend(history_findings) + for secret in history_findings: + secrets_found.append( + f"{secret['type']} in image history step {history_index + 1}" + ) + for file_index, file_secrets in file_findings_by_index.items(): + scanned_file = scan_data.files[file_index] + all_secrets.extend(file_secrets) + for secret in file_secrets: + secrets_found.append( + f"{secret['type']} in file {scanned_file.path} " + f"(layer {scanned_file.layer_digest})" + ) + + report.status = "FAIL" + report.status_extended = ( + f"Potential {'secrets' if len(secrets_found) > 1 else 'secret'} " + f"found in the {image_reference} -> {', '.join(secrets_found)}." + ) + if scan_data.truncated: + report.status_extended += ( + " Some of the image could not be retrieved or exceeded " + "configured size limits and was not scanned." + ) + annotate_verified_secrets(report, all_secrets) + elif scan_data.truncated: + # No secrets in what was scanned, but coverage was incomplete + # (size/count limits, or the config could not be retrieved), so + # a clean result would be misleading. + report.status = "MANUAL" + report.status_extended = ( + f"No secrets were found in the scanned portion of the " + f"{image_reference}, but part of it could not be retrieved " + f"or exceeded configured size limits and was not scanned; " + f"manual review is required." + ) + else: + report.status = "PASS" + report.status_extended = f"No secrets found in the {image_reference}." + + findings.append(report) + + return findings + + def _build_scan_error_report(self, repository, error) -> Check_Report_AWS: + """Build a repository-level report for a latest-image lookup failure.""" + report = Check_Report_AWS(metadata=self.metadata(), resource=repository) + report.status = "MANUAL" + report.status_extended = ( + f"Could not determine the latest image of ECR repository " + f"{repository.name}: {error}; manual review is required." + ) + return report + + def _build_report(self, repository, image) -> Check_Report_AWS: + """Build a report scoped to a single image within a repository. + + ECR images have no ARN of their own, so the repository's ARN is + reused with the image digest appended as a synthetic suffix, + mirroring how other sub-resource checks (e.g. CodeArtifact packages + within a repository) identify per-item findings. + """ + report = Check_Report_AWS(metadata=self.metadata(), resource=repository) + digest_short = image.latest_digest.split(":")[-1][:12] + report.resource_id = f"{repository.name}:{image.latest_tag}@{digest_short}" + report.resource_arn = f"{repository.arn}/image/{digest_short}" + return report diff --git a/prowler/providers/aws/services/ecr/ecr_service.py b/prowler/providers/aws/services/ecr/ecr_service.py index a09969725a..ac128ee632 100644 --- a/prowler/providers/aws/services/ecr/ecr_service.py +++ b/prowler/providers/aws/services/ecr/ecr_service.py @@ -1,3 +1,4 @@ +from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait from datetime import datetime from json import loads from typing import Optional @@ -8,10 +9,21 @@ 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 +from prowler.providers.aws.services.ecr.image_inspection import ImageInspector + +# Concurrency for the image-scan pipeline (_get_image_scan_data). Kept smaller +# than the shared MAX_WORKERS metadata pool because each task can retain up to +# MAX_LAYER_DOWNLOAD_BYTES compressed plus MAX_TOTAL_BYTES_PER_IMAGE decompressed +# content (see image_inspection), so a high worker count would multiply peak +# memory into several GB. +IMAGE_SCAN_MAX_WORKERS = 4 class ECR(AWSService): + """AWS Elastic Container Registry service.""" + def __init__(self, provider): + """Discover registries, repositories, policies, and image metadata.""" # Call AWSService's __init__ super().__init__(__class__.__name__, provider) self.registry_id = self.audited_account @@ -24,6 +36,7 @@ class ECR(AWSService): self.__threading_call__(self._list_tags_for_resource) def _describe_registries_and_repositories(self, regional_client): + """Populate the registry and its repositories for one region.""" logger.info("ECR - Describing registries and repositories...") regional_registry_repositories = [] try: @@ -68,6 +81,7 @@ class ECR(AWSService): ) def _describe_repository_policies(self, regional_client): + """Fetch and attach each repository's resource policy, if any.""" logger.info("ECR - Describing repository policies...") try: if regional_client.region in self.registries: @@ -96,6 +110,7 @@ class ECR(AWSService): ) def _get_repository_lifecycle_policy(self, regional_client): + """Fetch and attach each repository's lifecycle policy, if any.""" logger.info("ECR - Getting repository lifecycle policy...") try: if regional_client.region in self.registries: @@ -124,6 +139,7 @@ class ECR(AWSService): ) def _get_image_details(self, regional_client): + """Populate each scan-on-push repository's scannable, tagged images.""" logger.info("ECR - Getting images details...") try: if regional_client.region in self.registries: @@ -158,12 +174,7 @@ class ECR(AWSService): image_scan_findings_field_name = ( "imageScanFindingsSummary" ) - if "docker" in artifact_media_type: - type = "Docker" - elif "oci" in artifact_media_type: - type = "OCI" - else: - type = "" + type = ECR._artifact_type(artifact_media_type) # If imageScanStatus is not present or imageScanFindingsSummary is missing, # we need to call DescribeImageScanFindings because AWS' new version of @@ -252,6 +263,7 @@ class ECR(AWSService): ) def _list_tags_for_resource(self, regional_client): + """Fetch and attach each repository's resource tags.""" logger.info("ECR - List Tags...") try: if regional_client.region in self.registries: @@ -280,6 +292,7 @@ class ECR(AWSService): ) def _get_registry_scanning_configuration(self, regional_client): + """Fetch and attach the registry's image-scanning configuration.""" logger.info("ECR - Getting Registry Scanning Configuration...") try: if regional_client.region in self.registries: @@ -315,6 +328,155 @@ class ECR(AWSService): f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _get_image_scan_data(self): + """Lazily fetch manifest, config, and layer file contents for the latest image. + + Only the most recently pushed scannable image in each repository is + scanned (resolved via _get_scan_target_image, which also covers + scan-on-push-disabled repositories) to bound cost on repositories + with many tags. + + Not called from __init__: this is only invoked by the + ecr_repository_image_no_secrets check, since it downloads and + decompresses image layers and is significantly more expensive than + the metadata gathered above. A dedicated, smaller thread pool bounds + the concurrency (and therefore the peak memory) of this heavy + pipeline independently of the shared metadata pool. + + Yields: + Tuple of repository, optional image, and scan data. The third item + is an exception when the authoritative image lookup failed. + """ + logger.info("ECR - Fetching image manifests, configs, and layers...") + inspector = ImageInspector() + + def images_to_fetch(): + for registry in self.registries.values(): + for repository in registry.repositories: + image = self._get_scan_target_image(repository) + if isinstance(image, Exception): + yield repository, None, image + elif image is not None: + yield repository, image, None + + with ThreadPoolExecutor(max_workers=IMAGE_SCAN_MAX_WORKERS) as executor: + pending = {} + targets = iter(images_to_fetch()) + + def submit_next(): + try: + repository, image, error = next(targets) + except StopIteration: + return False + if error: + future = Future() + future.set_result(error) + else: + client = self.regional_clients[repository.region] + registry_id = self.registries[repository.region].id + future = executor.submit( + inspector.fetch_image_scan_data, + client, + registry_id, + repository.name, + image.latest_digest, + ) + pending[future] = (repository, image) + return True + + for _ in range(IMAGE_SCAN_MAX_WORKERS): + if not submit_next(): + break + + while pending: + completed, _ = wait(pending, return_when=FIRST_COMPLETED) + for future in completed: + repository, image = pending.pop(future) + scan_data = None + try: + scan_data = future.result() + except Exception as error: + logger.error( + f"{repository.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + yield repository, image, scan_data + submit_next() + + @staticmethod + def _artifact_type(artifact_media_type: Optional[str]) -> str: + """Map an image's artifact media type to a short image type label. + + Returns: + "Docker", "OCI", or "" for an unrecognized/absent media type. + """ + if artifact_media_type: + if "docker" in artifact_media_type: + return "Docker" + if "oci" in artifact_media_type: + return "OCI" + return "" + + def _get_scan_target_image(self, repository) -> Optional["ImageDetails"]: + """Resolve the latest scannable image to scan for secrets. + + Secret scanning is independent of ECR's vulnerability scanning + configuration, but `_get_image_details` only populates + `images_details` for scan-on-push-enabled repositories. For a + repository with scan-on-push disabled (empty `images_details`), this + performs a dedicated `describe_images` lookup to find the most + recently pushed scannable image, so those repositories are not + silently skipped. + + The synthesized ImageDetails is deliberately NOT appended to + `repository.images_details`: other checks (e.g. + ecr_repositories_scan_vulnerabilities_in_latest_image) treat any + entry there as a scanned image and would FAIL scan-on-push-disabled + repositories that currently produce no finding. + + Returns: + The latest scannable ImageDetails, or None if the repository has + no scannable image; an exception if the lookup failed. + """ + latest = repository.images_details[-1] if repository.images_details else None + try: + client = self.regional_clients[repository.region] + describe_images_paginator = client.get_paginator("describe_images") + for page in describe_images_paginator.paginate( + registryId=self.registries[repository.region].id, + repositoryName=repository.name, + PaginationConfig={"PageSize": 1000}, + ): + for image in page["imageDetails"]: + if image is None: + continue + artifact_media_type = image.get("artifactMediaType", None) + tags = image.get("imageTags", []) + if not ECR._is_artifact_scannable(artifact_media_type, tags): + continue + image_pushed_at = image.get("imagePushedAt") + if image_pushed_at is None: + continue + # Match _get_image_details' "sort ascending, take last" + # selection: on equal push dates the later-listed image + # wins, so `<` (not `<=`) is used to replace on ties. + if latest is not None and image_pushed_at < latest.image_pushed_at: + continue + latest = ImageDetails( + latest_tag=image.get("imageTags", ["None"])[0], + image_pushed_at=image_pushed_at, + latest_digest=image.get("imageDigest"), + scan_findings_status=None, + scan_findings_severity_count=None, + artifact_media_type=artifact_media_type, + type=ECR._artifact_type(artifact_media_type), + ) + return latest + except Exception as error: + logger.error( + f"{repository.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return error + @staticmethod def _is_artifact_scannable(artifact_media_type: str, tags: list[str] = []) -> bool: """ @@ -355,12 +517,16 @@ class ECR(AWSService): class FindingSeverityCounts(BaseModel): + """Count of an image's vulnerability scan findings by severity.""" + critical: int high: int medium: int class ImageDetails(BaseModel): + """A single scannable, tagged image within an ECR repository.""" + latest_tag: str latest_digest: str image_pushed_at: datetime @@ -371,6 +537,8 @@ class ImageDetails(BaseModel): class Repository(BaseModel): + """An ECR repository and its policies, images, and tags.""" + name: str arn: str region: str @@ -384,11 +552,15 @@ class Repository(BaseModel): class ScanningRule(BaseModel): + """A registry-level image-scanning rule and its repository filters.""" + scan_frequency: str scan_filters: list[dict] class Registry(BaseModel): + """An ECR registry: its repositories and scanning configuration.""" + id: str arn: str region: str diff --git a/prowler/providers/aws/services/ecr/image_inspection.py b/prowler/providers/aws/services/ecr/image_inspection.py new file mode 100644 index 0000000000..969e82dab8 --- /dev/null +++ b/prowler/providers/aws/services/ecr/image_inspection.py @@ -0,0 +1,494 @@ +import gzip +import tarfile +from contextlib import contextmanager +from json import loads +from typing import Optional + +import requests +import zstandard +from pydantic.v1 import BaseModel + +from prowler.lib.logger import logger + +# Manifest media types that wrap several per-architecture manifests (a "fat +# manifest") rather than a single scannable image. +_MANIFEST_LIST_MEDIA_TYPES = { + "application/vnd.docker.distribution.manifest.list.v2+json", + "application/vnd.oci.image.index.v1+json", +} + +# Compressed size of a single layer, checked against the manifest-declared +# size before downloading, and re-checked against actual bytes received. +MAX_LAYER_DOWNLOAD_BYTES = 100 * 1024 * 1024 +# Size of a single extracted file considered for scanning. +MAX_FILE_BYTES = 1 * 1024 * 1024 +# Hard cap on the number of files scanned per image, across all its layers. +MAX_FILES_PER_IMAGE = 5000 +# Hard cap on total decompressed bytes read per image, across all its layers. +MAX_TOTAL_BYTES_PER_IMAGE = 500 * 1024 * 1024 +LAYER_DOWNLOAD_TIMEOUT_SECONDS = 30 + + +class _LayerTooLargeError(Exception): + """Raised when a streamed layer exceeds MAX_LAYER_DOWNLOAD_BYTES.""" + + +class _ImageTooLargeError(Exception): + """Raised when decompressed image streams exceed their shared budget.""" + + +class _CappedLayerReader: + """A minimal read-only file object that caps the bytes it will yield. + + Wraps a streaming HTTP body (urllib3's ``response.raw``) so ``tarfile`` can + read a gzip/uncompressed layer incrementally while enforcing an upper bound + on the compressed bytes consumed. A manifest that under-declares a layer's + size (the declared size is pre-checked separately) cannot make this buffer + an unbounded amount of untrusted data: once ``max_bytes`` is exceeded the + read raises ``_LayerTooLargeError`` instead of continuing. + """ + + def __init__(self, raw, max_bytes: int): + """Store the underlying raw stream and the remaining byte budget.""" + self._raw = raw + self._remaining = max_bytes + + def read(self, size: int = -1) -> bytes: + """Read up to ``size`` bytes, never exceeding the remaining budget. + + A negative/None ``size`` (``read all``) is treated as "read what's left + of the budget, plus one" so a lying stream can never pull an unbounded + amount into memory and an over-cap layer is still detected. + """ + if size is None or size < 0: + size = self._remaining + 1 + to_read = min(size, self._remaining + 1) + chunk = self._raw.read(to_read) + self._remaining -= len(chunk) + if self._remaining < 0: + raise _LayerTooLargeError() + return chunk + + +class _DecompressedByteBudget: + """Track every decompressed byte consumed across an image's tar streams.""" + + def __init__(self, max_bytes: int): + """Set the shared decompressed-byte allowance.""" + self.remaining = max_bytes + + def wrap(self, raw): + """Return a reader that charges bytes consumed from ``raw``.""" + return _BudgetedReader(raw, self) + + +class _BudgetedReader: + """Charge all stream reads against a shared decompressed-byte budget.""" + + def __init__(self, raw, budget: _DecompressedByteBudget): + self._raw = raw + self._budget = budget + + def read(self, size: int = -1) -> bytes: + """Read without allowing the shared budget to be exceeded.""" + if size is None or size < 0: + size = self._budget.remaining + 1 + chunk = self._raw.read(min(size, self._budget.remaining + 1)) + self._budget.remaining -= len(chunk) + if self._budget.remaining < 0: + raise _ImageTooLargeError() + return chunk + + +class ImageScanFile(BaseModel): + """A single file extracted from an image layer for secret scanning.""" + + path: str + layer_digest: str + content: str + + +class ImageScanData(BaseModel): + """An image's scannable content: config env/history and layer files.""" + + env: list[str] = [] + history: list[str] = [] + files: list[ImageScanFile] = [] + # True when part of the image was not scanned -- a layer/file exceeded a + # configured size or count limit, or the config blob could not be + # retrieved/parsed -- so a clean result can be reported as MANUAL + # (coverage incomplete) rather than a false PASS. + truncated: bool = False + + +class ImageInspector: + """Bounded, opt-in extraction of an ECR image's scannable content. + + Given a boto3 ECR client and an image digest, resolves the image's + manifest (handling multi-arch manifest lists and skipping attestation + manifests) and returns its configuration (environment variables, build + history) and every filesystem layer's file contents, subject to this + module's size and count limits. + + This is deliberately isolated from the ECR service so a future check can + reuse the bounded extraction without the service downloading and + decompressing image layers by default: the service only pays this cost + when a check explicitly drives the inspector. + """ + + def fetch_image_scan_data( + self, client, registry_id, repository_name, image_digest + ) -> Optional[ImageScanData]: + """Resolve one image's manifest and return its scannable content. + + Downloads the config blob (environment variables, build history) + and every filesystem layer's file contents, bounded by the module's + size/count limits. + + Returns: + An ImageScanData, or None if the manifest could not be resolved. + """ + manifest, truncated = self._resolve_image_manifest( + client, registry_id, repository_name, image_digest + ) + if manifest is None: + return None + + env = [] + history = [] + + config_digest = (manifest.get("config") or {}).get("digest") + if config_digest: + config_bytes = self._download_layer( + client, + registry_id, + repository_name, + config_digest, + max_bytes=MAX_FILE_BYTES, + ) + if config_bytes is None: + # The config blob (env vars, build history) could not be + # retrieved. Empty env/history would be indistinguishable + # from a clean config, so mark coverage incomplete instead + # of risking a false PASS. + truncated = True + else: + try: + config_json = loads(config_bytes) + env = config_json.get("config", {}).get("Env", []) or [] + history = [ + step.get("created_by", "") + for step in config_json.get("history", []) + if step.get("created_by") + ] + except Exception as error: + logger.warning( + f"{repository_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + truncated = True + + files = [] + decompressed_budget = _DecompressedByteBudget(MAX_TOTAL_BYTES_PER_IMAGE) + for layer in manifest.get("layers", []): + if len(files) >= MAX_FILES_PER_IMAGE or decompressed_budget.remaining <= 0: + truncated = True + break + + layer_digest = layer.get("digest") + layer_size = layer.get("size", 0) + if layer_size and layer_size > MAX_LAYER_DOWNLOAD_BYTES: + truncated = True + continue + + try: + with self._open_layer_tar_stream( + client, + registry_id, + repository_name, + layer_digest, + layer.get("mediaType", ""), + decompressed_budget, + ) as tar_stream: + if tar_stream is None: + truncated = True + continue + for member in tar_stream: + if len(files) >= MAX_FILES_PER_IMAGE: + truncated = True + break + if not member.isfile(): + continue + base_name = member.name.rsplit("/", 1)[-1] + if base_name.startswith(".wh."): + # Whiteout marker: a deletion recorded by the union + # filesystem, not real file content. + continue + if member.size > MAX_FILE_BYTES: + truncated = True + continue + try: + content = ( + tar_stream.extractfile(member).read().decode("latin-1") + ) + except _LayerTooLargeError: + # Over-cap while reading this member: truncate the + # whole layer rather than silently skipping one file. + raise + except Exception: + continue + files.append( + ImageScanFile( + path=member.name, + layer_digest=layer_digest, + content=content, + ) + ) + except _LayerTooLargeError: + # The layer streamed more bytes than MAX_LAYER_DOWNLOAD_BYTES + # (a manifest under-declaring its size); skip it and disclose + # the partial coverage rather than buffer unbounded data. + truncated = True + continue + except _ImageTooLargeError: + truncated = True + break + + return ImageScanData(env=env, history=history, files=files, truncated=truncated) + + def _resolve_image_manifest( + self, client, registry_id, repository_name, image_digest + ) -> tuple[Optional[dict], bool]: + """Resolve an image digest to a single scannable image manifest. + + Multi-arch images are stored as a manifest list/image index pointing + at one manifest per platform (plus, often, an attestation manifest + that isn't a real image). This picks one real platform manifest to + scan; the other architectures in the same list are not scanned. + """ + try: + manifest, media_type = self._batch_get_manifest( + client, registry_id, repository_name, image_digest + ) + if manifest is None: + return None, False + + truncated = False + if media_type in _MANIFEST_LIST_MEDIA_TYPES: + truncated = True + child_digest = self._select_child_manifest_digest(manifest) + if not child_digest: + return None, truncated + manifest, _ = self._batch_get_manifest( + client, registry_id, repository_name, child_digest + ) + if manifest is not None and not ( + manifest.get("config") or manifest.get("layers") + ): + # A resolved manifest with neither a config nor layers has + # nothing to scan (e.g. a nested manifest list, or an + # unsupported manifest shape) -- treat it as unresolvable so + # the caller reports MANUAL instead of a false PASS. + return None, truncated + return manifest, truncated + except Exception as error: + logger.error( + f"{client.meta.region_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return None, False + + @staticmethod + def _batch_get_manifest(client, registry_id, repository_name, image_digest): + """Fetch and parse the raw manifest JSON for a single image digest. + + Returns: + A (manifest, media_type) tuple, or (None, None) if not found. + """ + response = client.batch_get_image( + registryId=registry_id, + repositoryName=repository_name, + imageIds=[{"imageDigest": image_digest}], + ) + images = response.get("images", []) + if not images: + return None, None + manifest = loads(images[0]["imageManifest"]) + media_type = manifest.get("mediaType") or images[0].get( + "imageManifestMediaType" + ) + return manifest, media_type + + @staticmethod + def _select_child_manifest_digest(manifest_list: dict) -> Optional[str]: + """Pick one real platform manifest's digest from a manifest list. + + Prefers linux/amd64, falling back to the first remaining candidate + once attestation manifests (platform "unknown/unknown", or + annotated as an attestation manifest) are excluded. + + Returns: + The chosen manifest's digest, or None if no candidate remains. + """ + candidates = [] + for entry in manifest_list.get("manifests", []): + platform = entry.get("platform", {}) or {} + annotations = entry.get("annotations", {}) or {} + if ( + platform.get("architecture") == "unknown" + or platform.get("os") == "unknown" + ): + # Attestation manifests (SBOMs, provenance, signatures) are + # attached to the index as "unknown/unknown" platform entries. + continue + if annotations.get("vnd.docker.reference.type") == "attestation-manifest": + continue + candidates.append(entry) + + for entry in candidates: + platform = entry.get("platform", {}) or {} + if ( + platform.get("architecture") == "amd64" + and platform.get("os") == "linux" + ): + return entry.get("digest") + + return candidates[0].get("digest") if candidates else None + + @staticmethod + def _get_layer_download_url( + client, registry_id, repository_name, layer_digest + ) -> Optional[str]: + """Resolve the presigned download URL for one layer or config blob. + + Returns: + The presigned URL, or None if ECR did not return one. + """ + response = client.get_download_url_for_layer( + registryId=registry_id, + repositoryName=repository_name, + layerDigest=layer_digest, + ) + return response.get("downloadUrl") + + @staticmethod + def _download_layer( + client, registry_id, repository_name, layer_digest, max_bytes=None + ) -> Optional[bytes]: + """Download one layer or config blob via its presigned URL. + + Streams the response, aborting once `max_bytes` is exceeded, so a + lying or oversized blob is never buffered in full. Used for the config + blob and for zstd layers (which cannot be streamed into tarfile); + gzip/uncompressed layers are streamed by `_open_layer_tar_stream`. + + Returns: + The blob's bytes, or None if it could not be downloaded or + exceeded `max_bytes`. + """ + try: + download_url = ImageInspector._get_layer_download_url( + client, registry_id, repository_name, layer_digest + ) + if not download_url: + return None + + downloaded = bytearray() + with requests.get( + download_url, + stream=True, + timeout=LAYER_DOWNLOAD_TIMEOUT_SECONDS, + allow_redirects=False, + ) as http_response: + http_response.raise_for_status() + for chunk in http_response.iter_content(chunk_size=1024 * 1024): + downloaded.extend(chunk) + if max_bytes and len(downloaded) > max_bytes: + return None + return bytes(downloaded) + except Exception as error: + logger.warning( + f"{repository_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return None + + @contextmanager + def _open_layer_tar_stream( + self, + client, + registry_id, + repository_name, + layer_digest, + media_type: str, + decompressed_budget: _DecompressedByteBudget, + ): + """Yield an open TarFile for one layer, streamed from the download. + + gzip, zstd, and uncompressed tar layers are all streamed straight from + the download into `tarfile` (streaming mode reads a file-like object + sequentially), so neither the compressed blob nor a decompressed copy is + ever buffered in full. A `_CappedLayerReader` enforces + `MAX_LAYER_DOWNLOAD_BYTES` on the compressed bytes (guarding a manifest + that under-declares the layer size); zstd is decompressed incrementally + via `zstandard`'s streaming reader, so a crafted frame can no longer + expand unbounded in memory, and the decompressed side is bounded by the + caller's per-image budget as it iterates members. + + Yields: + An open TarFile, or None for an unrecognized media type or a + download/decompression failure. Raises `_LayerTooLargeError` if a + streamed layer's compressed bytes exceed `MAX_LAYER_DOWNLOAD_BYTES`. + """ + if media_type.endswith("gzip"): + decompress = "gzip" + elif media_type.endswith("zstd"): + decompress = "zstd" + elif media_type.endswith("tar"): + decompress = None + else: + yield None + return + + # Only the setup (URL resolution, connection, tar-header parse) is + # guarded here; a failure yields None. The `yield tar_stream` below is + # kept out of this try so exceptions raised while the caller iterates + # members (e.g. _LayerTooLargeError) propagate instead of triggering a + # forbidden second yield. + try: + download_url = ImageInspector._get_layer_download_url( + client, registry_id, repository_name, layer_digest + ) + if not download_url: + yield None + return + http_response = requests.get( + download_url, + stream=True, + timeout=LAYER_DOWNLOAD_TIMEOUT_SECONDS, + allow_redirects=False, + ) + try: + http_response.raise_for_status() + # Cap the compressed bytes read from the network; for zstd, + # decompress that capped stream incrementally so the decompressed + # data is never materialized in full. + source = _CappedLayerReader(http_response.raw, MAX_LAYER_DOWNLOAD_BYTES) + if decompress == "gzip": + source = gzip.GzipFile(fileobj=source) + elif decompress == "zstd": + source = zstandard.ZstdDecompressor().stream_reader(source) + source = decompressed_budget.wrap(source) + tar_stream = tarfile.open(fileobj=source, mode="r|") + except (_LayerTooLargeError, _ImageTooLargeError): + http_response.close() + raise + except Exception: + http_response.close() + raise + except Exception as error: + logger.warning( + f"{repository_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + yield None + return + + with http_response, tar_stream: + yield tar_stream diff --git a/prowler/providers/aws/services/ecs/ecs_service.py b/prowler/providers/aws/services/ecs/ecs_service.py index 65c3aabf56..3006185210 100644 --- a/prowler/providers/aws/services/ecs/ecs_service.py +++ b/prowler/providers/aws/services/ecs/ecs_service.py @@ -149,8 +149,8 @@ class ECS(AWSService): "TAGS", ], ) - container_definitions = response["taskDefinition"]["containerDefinitions"] - for container in container_definitions: + container_definitions = [] + for container in response["taskDefinition"]["containerDefinitions"]: environment = [] if "environment" in container: for env_var in container["environment"]: @@ -159,7 +159,7 @@ class ECS(AWSService): name=env_var["name"], value=env_var["value"] ) ) - task_definition.container_definitions.append( + container_definitions.append( ContainerDefinition( name=container["name"], privileged=container.get("privileged", False), @@ -176,14 +176,16 @@ class ECS(AWSService): .get("mode", ""), ) ) - task_definition.pid_mode = response["taskDefinition"].get("pidMode", "") - task_definition.registered_at = response["taskDefinition"].get( - "registeredAt" - ) - task_definition.tags = response.get("tags") - task_definition.network_mode = response["taskDefinition"].get( - "networkMode", "bridge" - ) + pid_mode = response["taskDefinition"].get("pidMode", "") + registered_at = response["taskDefinition"].get("registeredAt") + tags = response.get("tags") + network_mode = response["taskDefinition"].get("networkMode", "bridge") + + task_definition.container_definitions = container_definitions + task_definition.pid_mode = pid_mode + task_definition.registered_at = registered_at + task_definition.tags = tags + task_definition.network_mode = network_mode except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" @@ -302,7 +304,7 @@ class TaskDefinition(BaseModel): arn: str revision: str region: str - container_definitions: list[ContainerDefinition] = [] + container_definitions: Optional[list[ContainerDefinition]] = None pid_mode: Optional[str] registered_at: Optional[datetime] = None tags: Optional[list] = [] diff --git a/prowler/providers/aws/services/ecs/ecs_task_definitions_containers_readonly_access/ecs_task_definitions_containers_readonly_access.py b/prowler/providers/aws/services/ecs/ecs_task_definitions_containers_readonly_access/ecs_task_definitions_containers_readonly_access.py index 3d9b33ddf7..85f11817a9 100644 --- a/prowler/providers/aws/services/ecs/ecs_task_definitions_containers_readonly_access/ecs_task_definitions_containers_readonly_access.py +++ b/prowler/providers/aws/services/ecs/ecs_task_definitions_containers_readonly_access/ecs_task_definitions_containers_readonly_access.py @@ -6,6 +6,8 @@ class ecs_task_definitions_containers_readonly_access(Check): def execute(self): findings = [] for task_definition in ecs_client.task_definitions.values(): + if task_definition.container_definitions is None: + continue report = Check_Report_AWS( metadata=self.metadata(), resource=task_definition ) diff --git a/prowler/providers/aws/services/ecs/ecs_task_definitions_host_namespace_not_shared/ecs_task_definitions_host_namespace_not_shared.py b/prowler/providers/aws/services/ecs/ecs_task_definitions_host_namespace_not_shared/ecs_task_definitions_host_namespace_not_shared.py index ee57dd4ea8..5cfdd526fe 100644 --- a/prowler/providers/aws/services/ecs/ecs_task_definitions_host_namespace_not_shared/ecs_task_definitions_host_namespace_not_shared.py +++ b/prowler/providers/aws/services/ecs/ecs_task_definitions_host_namespace_not_shared/ecs_task_definitions_host_namespace_not_shared.py @@ -6,6 +6,8 @@ class ecs_task_definitions_host_namespace_not_shared(Check): def execute(self): findings = [] for task_definition in ecs_client.task_definitions.values(): + if task_definition.container_definitions is None: + continue report = Check_Report_AWS( metadata=self.metadata(), resource=task_definition ) diff --git a/prowler/providers/aws/services/ecs/ecs_task_definitions_host_networking_mode_users/ecs_task_definitions_host_networking_mode_users.py b/prowler/providers/aws/services/ecs/ecs_task_definitions_host_networking_mode_users/ecs_task_definitions_host_networking_mode_users.py index b8354ed510..8376afef6d 100644 --- a/prowler/providers/aws/services/ecs/ecs_task_definitions_host_networking_mode_users/ecs_task_definitions_host_networking_mode_users.py +++ b/prowler/providers/aws/services/ecs/ecs_task_definitions_host_networking_mode_users/ecs_task_definitions_host_networking_mode_users.py @@ -6,6 +6,8 @@ class ecs_task_definitions_host_networking_mode_users(Check): def execute(self): findings = [] for task_definition in ecs_client.task_definitions.values(): + if task_definition.container_definitions is None: + continue report = Check_Report_AWS( metadata=self.metadata(), resource=task_definition ) diff --git a/prowler/providers/aws/services/ecs/ecs_task_definitions_logging_block_mode/ecs_task_definitions_logging_block_mode.py b/prowler/providers/aws/services/ecs/ecs_task_definitions_logging_block_mode/ecs_task_definitions_logging_block_mode.py index 5e3eff00a9..9224b3fb4b 100644 --- a/prowler/providers/aws/services/ecs/ecs_task_definitions_logging_block_mode/ecs_task_definitions_logging_block_mode.py +++ b/prowler/providers/aws/services/ecs/ecs_task_definitions_logging_block_mode/ecs_task_definitions_logging_block_mode.py @@ -6,6 +6,8 @@ class ecs_task_definitions_logging_block_mode(Check): def execute(self): findings = [] for task_definition in ecs_client.task_definitions.values(): + if task_definition.container_definitions is None: + continue report = Check_Report_AWS( metadata=self.metadata(), resource=task_definition ) diff --git a/prowler/providers/aws/services/ecs/ecs_task_definitions_logging_enabled/ecs_task_definitions_logging_enabled.py b/prowler/providers/aws/services/ecs/ecs_task_definitions_logging_enabled/ecs_task_definitions_logging_enabled.py index caf57177ca..4de607912a 100644 --- a/prowler/providers/aws/services/ecs/ecs_task_definitions_logging_enabled/ecs_task_definitions_logging_enabled.py +++ b/prowler/providers/aws/services/ecs/ecs_task_definitions_logging_enabled/ecs_task_definitions_logging_enabled.py @@ -6,6 +6,8 @@ class ecs_task_definitions_logging_enabled(Check): def execute(self): findings = [] for task_definition in ecs_client.task_definitions.values(): + if task_definition.container_definitions is None: + continue report = Check_Report_AWS( metadata=self.metadata(), resource=task_definition ) diff --git a/prowler/providers/aws/services/ecs/ecs_task_definitions_no_environment_secrets/ecs_task_definitions_no_environment_secrets.py b/prowler/providers/aws/services/ecs/ecs_task_definitions_no_environment_secrets/ecs_task_definitions_no_environment_secrets.py index b0bc7198ee..51c2efd5dc 100644 --- a/prowler/providers/aws/services/ecs/ecs_task_definitions_no_environment_secrets/ecs_task_definitions_no_environment_secrets.py +++ b/prowler/providers/aws/services/ecs/ecs_task_definitions_no_environment_secrets/ecs_task_definitions_no_environment_secrets.py @@ -16,7 +16,11 @@ class ecs_task_definitions_no_environment_secrets(Check): "secrets_ignore_patterns", [] ) validate = ecs_client.audit_config.get("secrets_validate", False) - task_definitions = list(ecs_client.task_definitions.values()) + task_definitions = [ + task_definition + for task_definition in ecs_client.task_definitions.values() + if task_definition.container_definitions is not None + ] # Scan every (task definition, container) environment in batched # Kingfisher invocations instead of one subprocess per container. diff --git a/prowler/providers/aws/services/ecs/ecs_task_definitions_no_privileged_containers/ecs_task_definitions_no_privileged_containers.py b/prowler/providers/aws/services/ecs/ecs_task_definitions_no_privileged_containers/ecs_task_definitions_no_privileged_containers.py index 3cd56de532..f0a18227ea 100644 --- a/prowler/providers/aws/services/ecs/ecs_task_definitions_no_privileged_containers/ecs_task_definitions_no_privileged_containers.py +++ b/prowler/providers/aws/services/ecs/ecs_task_definitions_no_privileged_containers/ecs_task_definitions_no_privileged_containers.py @@ -6,6 +6,8 @@ class ecs_task_definitions_no_privileged_containers(Check): def execute(self): findings = [] for task_definition in ecs_client.task_definitions.values(): + if task_definition.container_definitions is None: + continue report = Check_Report_AWS( metadata=self.metadata(), resource=task_definition ) 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/elasticbeanstalk/elasticbeanstalk_environment_no_secrets_in_configuration/__init__.py b/prowler/providers/aws/services/elasticbeanstalk/elasticbeanstalk_environment_no_secrets_in_configuration/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/elasticbeanstalk/elasticbeanstalk_environment_no_secrets_in_configuration/elasticbeanstalk_environment_no_secrets_in_configuration.metadata.json b/prowler/providers/aws/services/elasticbeanstalk/elasticbeanstalk_environment_no_secrets_in_configuration/elasticbeanstalk_environment_no_secrets_in_configuration.metadata.json new file mode 100644 index 0000000000..04a449298d --- /dev/null +++ b/prowler/providers/aws/services/elasticbeanstalk/elasticbeanstalk_environment_no_secrets_in_configuration/elasticbeanstalk_environment_no_secrets_in_configuration.metadata.json @@ -0,0 +1,43 @@ +{ + "Provider": "aws", + "CheckID": "elasticbeanstalk_environment_no_secrets_in_configuration", + "CheckTitle": "Elastic Beanstalk environment configuration has no hardcoded secrets", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "TTPs/Credential Access", + "Effects/Data Exposure", + "Sensitive Data Identifications/Security" + ], + "ServiceName": "elasticbeanstalk", + "SubServiceName": "", + "ResourceIdTemplate": "arn:partition:elasticbeanstalk:region:account-id:environment/environment-name", + "Severity": "high", + "ResourceType": "AwsElasticBeanstalkEnvironment", + "ResourceGroup": "compute", + "Description": "AWS Elastic Beanstalk environments are inspected for hardcoded secrets in configuration option settings. Secrets such as API keys, passwords, access tokens, or credentials should not be stored in environment configuration.", + "Risk": "Plaintext secrets stored in Elastic Beanstalk environment configuration can be viewed by users with read access to the environment configuration, increasing the risk of credential exposure and unauthorized access to downstream resources.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/elasticbeanstalk/latest/api/API_DescribeConfigurationSettings.html", + "https://docs.aws.amazon.com/boto3/latest/reference/services/elasticbeanstalk/client/describe_configuration_settings.html", + "https://docs.prowler.com/developer-guide/secret-scanning-checks" + ], + "Remediation": { + "Code": { + "CLI": "aws elasticbeanstalk update-environment --environment-name --option-settings Namespace=,OptionName=,Value=", + "NativeIaC": "", + "Other": "1. Review the Elastic Beanstalk environment configuration.\n2. Remove hardcoded secrets from OptionSettings.\n3. Store secrets in AWS Secrets Manager or AWS Systems Manager Parameter Store.\n4. Configure the application to retrieve secrets securely at runtime instead of storing them in environment configuration.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Avoid storing secrets in Elastic Beanstalk environment configuration. Store sensitive values in AWS Secrets Manager or AWS Systems Manager Parameter Store and retrieve them securely at runtime.", + "Url": "https://hub.prowler.com/check/elasticbeanstalk_environment_no_secrets_in_configuration" + } + }, + "Categories": [ + "secrets" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "False positives can be suppressed with the `secrets_ignore_patterns` configuration option. When `secrets_validate` is enabled and a detected secret is confirmed to be live, the finding is escalated to critical." +} diff --git a/prowler/providers/aws/services/elasticbeanstalk/elasticbeanstalk_environment_no_secrets_in_configuration/elasticbeanstalk_environment_no_secrets_in_configuration.py b/prowler/providers/aws/services/elasticbeanstalk/elasticbeanstalk_environment_no_secrets_in_configuration/elasticbeanstalk_environment_no_secrets_in_configuration.py new file mode 100644 index 0000000000..6b0ed01942 --- /dev/null +++ b/prowler/providers/aws/services/elasticbeanstalk/elasticbeanstalk_environment_no_secrets_in_configuration/elasticbeanstalk_environment_no_secrets_in_configuration.py @@ -0,0 +1,110 @@ +import json + +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.lib.utils.utils import ( + SecretsScanError, + annotate_verified_secrets, + detect_secrets_scan_batch, +) +from prowler.providers.aws.services.elasticbeanstalk.elasticbeanstalk_client import ( + elasticbeanstalk_client, +) + + +class elasticbeanstalk_environment_no_secrets_in_configuration(Check): + """Check that Elastic Beanstalk environment configurations contain no hardcoded secrets.""" + + def execute(self) -> list[Check_Report_AWS]: + """Scan the option settings of each Elastic Beanstalk environment for secrets. + + Every option setting is scanned as ``{OptionName: Value}`` so the scanner + gets the same name context the other secrets checks provide. Findings are + keyed by ``(environment index, option setting index)`` because the same + namespace and option name can appear more than once per environment (one + entry per ``ResourceName``). + + Returns: + list[Check_Report_AWS]: A report for each Elastic Beanstalk environment. + """ + findings = [] + secrets_ignore_patterns = elasticbeanstalk_client.audit_config.get( + "secrets_ignore_patterns", [] + ) + validate = elasticbeanstalk_client.audit_config.get("secrets_validate", False) + environments = list(elasticbeanstalk_client.environments.values()) + + # Phase 1: collect — build the payload strings only, no scan yet. + def payloads(): + for environment_index, environment in enumerate(environments): + for option_index, option_setting in enumerate( + environment.option_settings or [] + ): + value = option_setting.get("Value") + if not value: + continue + yield (environment_index, option_index), json.dumps( + {option_setting.get("OptionName", ""): value} + ) + + # Phase 2: batch — one scan for every environment. + scan_error = None + try: + batch_results = detect_secrets_scan_batch( + payloads(), excluded_secrets=secrets_ignore_patterns, validate=validate + ) + except SecretsScanError as error: + batch_results = {} + scan_error = error + + # Phase 3: report — one finding per environment. + for environment_index, environment in enumerate(environments): + report = Check_Report_AWS(metadata=self.metadata(), resource=environment) + + if environment.option_settings is None: + report.status = "MANUAL" + report.status_extended = ( + f"Could not retrieve the configuration of Elastic Beanstalk " + f"environment {environment.name}; manual review is required." + ) + findings.append(report) + continue + + if scan_error and any( + option_setting.get("Value") + for option_setting in environment.option_settings + ): + report.status = "MANUAL" + report.status_extended = ( + f"Could not scan the configuration of Elastic Beanstalk " + f"environment {environment.name} for secrets; manual review is required." + ) + findings.append(report) + continue + + report.status = "PASS" + report.status_extended = f"No secrets found in the configuration of Elastic Beanstalk environment {environment.name}." + + secret_settings = [] + all_secrets = [] + for option_index, option_setting in enumerate(environment.option_settings): + detect_secrets_output = batch_results.get( + (environment_index, option_index) + ) + if detect_secrets_output: + all_secrets.extend(detect_secrets_output) + secret_settings.append( + f"{option_setting.get('Namespace', '')}/{option_setting.get('OptionName', '')}" + ) + + if secret_settings: + report.status = "FAIL" + report.status_extended = ( + f"Potential {'secrets' if len(secret_settings) > 1 else 'secret'} " + f"found in the configuration of Elastic Beanstalk environment " + f"{environment.name} -> {', '.join(secret_settings)}." + ) + annotate_verified_secrets(report, all_secrets) + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/elasticbeanstalk/elasticbeanstalk_service.py b/prowler/providers/aws/services/elasticbeanstalk/elasticbeanstalk_service.py index c8d821687d..04db5d52dd 100644 --- a/prowler/providers/aws/services/elasticbeanstalk/elasticbeanstalk_service.py +++ b/prowler/providers/aws/services/elasticbeanstalk/elasticbeanstalk_service.py @@ -56,6 +56,8 @@ class ElasticBeanstalk(AWSService): option_settings = configuration_settings["ConfigurationSettings"][0].get( "OptionSettings", {} ) + environment.option_settings = option_settings + for option in option_settings: if ( option["Namespace"] == "aws:elasticbeanstalk:healthreporting:system" @@ -123,3 +125,4 @@ class Environment(BaseModel): managed_platform_updates: Optional[str] cloudwatch_stream_logs: Optional[str] tags: Optional[list] = [] + option_settings: Optional[list] = None 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/memorydb/memorydb_cluster_in_transit_encryption_enabled/__init__.py b/prowler/providers/aws/services/memorydb/memorydb_cluster_in_transit_encryption_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/memorydb/memorydb_cluster_in_transit_encryption_enabled/memorydb_cluster_in_transit_encryption_enabled.metadata.json b/prowler/providers/aws/services/memorydb/memorydb_cluster_in_transit_encryption_enabled/memorydb_cluster_in_transit_encryption_enabled.metadata.json new file mode 100644 index 0000000000..04523c7c63 --- /dev/null +++ b/prowler/providers/aws/services/memorydb/memorydb_cluster_in_transit_encryption_enabled/memorydb_cluster_in_transit_encryption_enabled.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "aws", + "CheckID": "memorydb_cluster_in_transit_encryption_enabled", + "CheckTitle": "MemoryDB cluster has in-transit encryption enabled", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices/Network Security", + "Effects/Data Exposure" + ], + "ServiceName": "memorydb", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Other", + "ResourceGroup": "database", + "Description": "**MemoryDB clusters** are evaluated for **in-transit encryption (TLS)** on client and inter-node traffic (`TLSEnabled=true`).", + "Risk": "Without **in-transit encryption**, client and inter-node traffic travels in cleartext inside the VPC.\n\nAnyone able to observe it can **read cached data** and the **ACL credentials** clients send to authenticate, or **tamper with commands**, enabling **MITM** and replication corruption. This harms **confidentiality** and **integrity**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/memorydb/latest/devguide/in-transit-encryption.html", + "https://docs.aws.amazon.com/memorydb/latest/devguide/API_CreateCluster.html", + "https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/memorydb_cluster#tls_enabled" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "```yaml\n# Enable in-transit encryption for a MemoryDB cluster\nResources:\n :\n Type: AWS::MemoryDB::Cluster\n Properties:\n ClusterName: \n ACLName: \n NodeType: \n NumShards: 1\n TLSEnabled: true # CRITICAL: enables TLS in-transit; replaces the cluster if changed\n```", + "Other": "1. In the AWS Console, go to MemoryDB > Clusters\n2. Select the cluster and choose Actions > Take snapshot\n3. Once the snapshot is available, go to Snapshots, select it and choose Actions > Restore\n4. In the restore form, enable \"Encryption in transit\" and create the cluster\n5. Repoint clients at the new cluster endpoint using a TLS-enabled client, then delete the old cluster", + "Terraform": "```hcl\nresource \"aws_memorydb_cluster\" \"\" {\n name = \"\"\n acl_name = \"\"\n node_type = \"\"\n num_shards = 1\n\n tls_enabled = true # CRITICAL: enables TLS in-transit; forces replacement if changed\n}\n```" + }, + "Recommendation": { + "Text": "Create MemoryDB clusters with `TLSEnabled=true`. Because this flag is **immutable after creation**, an unencrypted cluster must be replaced: snapshot it, restore into a new TLS-enabled cluster, then cut clients over and delete the original.\n\nConfigure clients to validate the server certificate, and pair TLS with **ACL users**, least-privilege security groups, and private subnets for defense in depth.", + "Url": "https://hub.prowler.com/check/memorydb_cluster_in_transit_encryption_enabled" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "MemoryDB enables in-transit encryption by default, and `TLSEnabled` cannot be changed after the cluster is created, so a failing cluster must be recreated from a snapshot rather than updated in place." +} diff --git a/prowler/providers/aws/services/memorydb/memorydb_cluster_in_transit_encryption_enabled/memorydb_cluster_in_transit_encryption_enabled.py b/prowler/providers/aws/services/memorydb/memorydb_cluster_in_transit_encryption_enabled/memorydb_cluster_in_transit_encryption_enabled.py new file mode 100644 index 0000000000..0ade116e6f --- /dev/null +++ b/prowler/providers/aws/services/memorydb/memorydb_cluster_in_transit_encryption_enabled/memorydb_cluster_in_transit_encryption_enabled.py @@ -0,0 +1,30 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.memorydb.memorydb_client import memorydb_client + + +class memorydb_cluster_in_transit_encryption_enabled(Check): + """Verify that every MemoryDB cluster encrypts data in transit. + + A cluster PASSES when ``TLSEnabled`` is set. The flag can only be chosen at + creation time, so a failing cluster has to be rebuilt from a snapshot. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the MemoryDB in-transit encryption check. + + Returns: + A list of reports with each cluster in-transit encryption status. + """ + findings = [] + for cluster in memorydb_client.clusters.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=cluster) + if cluster.tls_enabled: + report.status = "PASS" + report.status_extended = f"Memory DB Cluster {cluster.name} has in-transit encryption enabled." + else: + report.status = "FAIL" + report.status_extended = f"Memory DB Cluster {cluster.name} does not have in-transit encryption enabled." + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/rolesanywhere/rolesanywhere_profile_restricts_session_permissions/__init__.py b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_profile_restricts_session_permissions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/rolesanywhere/rolesanywhere_profile_restricts_session_permissions/rolesanywhere_profile_restricts_session_permissions.metadata.json b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_profile_restricts_session_permissions/rolesanywhere_profile_restricts_session_permissions.metadata.json new file mode 100644 index 0000000000..0dfa072d65 --- /dev/null +++ b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_profile_restricts_session_permissions/rolesanywhere_profile_restricts_session_permissions.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "aws", + "CheckID": "rolesanywhere_profile_restricts_session_permissions", + "CheckTitle": "IAM Roles Anywhere profiles scope down the vended session permissions", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "rolesanywhere", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "AwsRolesAnywhereProfile", + "ResourceGroup": "security", + "Description": "**IAM Roles Anywhere profiles** that reference an administrative role are assessed for **session scoping**. A profile defining neither an inline `sessionPolicy` nor `managedPolicyArns` vends credentials with the full permissions of its roles. It is flagged only when a referenced role is administrative, since an unscoped session on a least-privilege role is already constrained.", + "Risk": "Roles Anywhere profiles bind X.509 certificates to IAM roles. When a profile references an administrative role and does not scope the session, vended credentials carry full administrative permissions. An attacker presenting a valid certificate - or planting a rogue trust anchor and profile - gains durable privileged access that rotating IAM keys does not revoke.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/rolesanywhere/latest/userguide/introduction.html", + "https://docs.aws.amazon.com/rolesanywhere/latest/APIReference/API_CreateProfile.html", + "https://docs.aws.amazon.com/rolesanywhere/latest/userguide/security-best-practices.html" + ], + "Remediation": { + "Code": { + "CLI": "aws rolesanywhere update-profile --profile-id --session-policy '{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"\"],\"Resource\":[\"\"]}]}'", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::RolesAnywhere::Profile\n Properties:\n Name: scoped-profile\n Enabled: true\n RoleArns:\n - \n SessionPolicy: '{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"\"],\"Resource\":[\"\"]}]}' # FIX: scope down the vended session\n```", + "Other": "1. Identify the least-privilege actions the workload actually needs\n2. Attach a sessionPolicy or managedPolicyArns to the Roles Anywhere profile that grants only those actions\n3. Prefer purpose-built roles per workload over broad roles referenced by many profiles\n4. Review trust anchors and profiles regularly for entries you did not create", + "Terraform": "```hcl\nresource \"aws_rolesanywhere_profile\" \"\" {\n name = \"scoped-profile\"\n enabled = true\n role_arns = []\n session_policy = jsonencode({\n Version = \"2012-10-17\"\n Statement = [{\n Effect = \"Allow\"\n Action = []\n Resource = []\n }]\n }) # FIX: scope down the vended session\n}\n```" + }, + "Recommendation": { + "Text": "Attach a session policy or managed policies to every enabled IAM Roles Anywhere profile so the vended credentials are scoped below the referenced role's permissions. Pair each profile with a purpose-built least-privilege role and audit trust anchors and profiles regularly for unexpected entries.", + "Url": "https://hub.prowler.com/check/rolesanywhere_profile_restricts_session_permissions" + } + }, + "Categories": [ + "identity-access", + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "A profile is failed only when it is enabled, its session is unscoped, and a referenced role effectively grants administrative (*:*) access. The session-policy set (inline sessionPolicy plus every managedPolicyArns entry, resolved to its policy document) is evaluated as a union: any member granting *:* leaves the session unrestricted. Role classification merges all attached and inline identity-policy documents so explicit denies negate allows across policies, excludes condition-guarded statements (not statically provable), and intersects the result with the role's permissions boundary: a role whose boundary does not grant *:* - or whose boundary document cannot be resolved - is not classified as administrative. Disabled profiles, scoped profiles, and profiles with no role identified as administrative are reported as PASS. Referenced roles absent from the IAM inventory (for example cross-account roles or denied ListRoles) and policy documents that could not be collected are treated as non-administrative to avoid false positives." +} 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 new file mode 100644 index 0000000000..2960518a67 --- /dev/null +++ b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_profile_restricts_session_permissions/rolesanywhere_profile_restricts_session_permissions.py @@ -0,0 +1,274 @@ +import json + +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.lib.logger import logger +from prowler.providers.aws.services.iam.iam_client import iam_client +from prowler.providers.aws.services.iam.lib.policy import check_full_service_access +from prowler.providers.aws.services.rolesanywhere.rolesanywhere_client import ( + rolesanywhere_client, +) + +# AWS-managed AdministratorAccess ARN suffix, partition-agnostic +# (arn:aws:..., arn:aws-cn:..., arn:aws-us-gov:...). +ADMIN_POLICY_ARN_SUFFIX = ":iam::aws:policy/AdministratorAccess" + +# Synthetic statement equivalent to the AWS-managed AdministratorAccess +# document, used when a policy is identified by that ARN but its document was +# not collected by the IAM service. +_ADMIN_STATEMENT = {"Effect": "Allow", "Action": "*", "Resource": "*"} + + +def _grants_full_access(document) -> bool: + """Return True when a policy document grants administrative (``*:*``) access. + + Args: + document: Decoded IAM policy document, or None when unavailable. + """ + if not document: + return False + try: + return check_full_service_access("*", document) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return False + + +def _full_access_status(documents) -> bool | None: + """Return whether a policy-document set grants ``*:*``, or None when unknown. + + Merges every condition-free statement across the given documents into a + single evaluation so an explicit deny in one document negates an allow in + another (Deny > Allow via the shared policy-evaluation helpers). + + Unresolved or malformed documents and context-dependent semantics cannot + produce a definitive classification and propagate as None: + + - a document that is missing or not a well-formed statement container; + - a ``Condition``-guarded Deny that could negate an otherwise proven + full-access grant; + - a ``Condition``-guarded Allow that could grant full access not proven + by the unconditional statements. + + Args: + documents: Iterable of decoded IAM policy documents (None members mark + documents that could not be resolved). + """ + statements = [] + conditional_effects = set() + + for document in documents: + if not isinstance(document, dict) or "Statement" not in document: + return None + + document_statements = document.get("Statement", []) + if not isinstance(document_statements, list): + document_statements = [document_statements] + + for statement in document_statements: + if not isinstance(statement, dict): + return None + + effect = str(statement.get("Effect", "")).lower() + if ( + effect not in {"allow", "deny"} + or not ("Action" in statement or "NotAction" in statement) + or not ("Resource" in statement or "NotResource" in statement) + ): + return None + + if statement.get("Condition"): + conditional_effects.add(effect) + else: + statements.append(statement) + + grants_full_access = _grants_full_access({"Statement": statements}) + if (grants_full_access and "deny" in conditional_effects) or ( + not grants_full_access and "allow" in conditional_effects + ): + return None + + return grants_full_access + + +def _role_is_privileged(role, policies) -> bool | None: + """Return whether an IAM role is administrative, or None when unknown. + + Effective permissions are the intersection of the role's identity policies + (attached and inline, evaluated together) and its permissions boundary. + Unresolved policy documents, malformed policies, and condition-guarded + statements that could change the outcome propagate as None instead of + being collapsed into a definitive classification. + + Args: + role: An ``iam_service.Role`` referenced by a Roles Anywhere profile. + policies: Mapping of policy ARN to ``iam_service.Policy`` from iam_client. + """ + documents = [] + for attached in role.attached_policies: + policy_arn = attached.get("PolicyArn", "") + document = getattr(policies.get(policy_arn), "document", None) + if policy_arn.endswith(ADMIN_POLICY_ARN_SUFFIX) and not document: + documents.append({"Statement": [_ADMIN_STATEMENT]}) + else: + documents.append(document) + for inline_name in role.inline_policies: + policy = policies.get(f"{role.arn}:policy/{inline_name}") + documents.append(getattr(policy, "document", None)) + + identity_status = _full_access_status(documents) + if identity_status is False: + # Identity policies provably do not grant *:*; no boundary can widen them. + return False + + boundary = getattr(role, "permissions_boundary", None) + if not boundary: + return identity_status + boundary_arn = ( + boundary.get("PermissionsBoundaryArn", "") if isinstance(boundary, dict) else "" + ) + if boundary_arn.endswith(ADMIN_POLICY_ARN_SUFFIX): + # An AdministratorAccess boundary restricts nothing. + return identity_status + + boundary_status = _full_access_status( + [getattr(policies.get(boundary_arn), "document", None)] + ) + if boundary_status is False: + # The boundary provably does not grant *:*: the intersection cannot be + # administrative regardless of the identity policies. + return False + if boundary_status is None: + return None + return identity_status + + +def _session_is_scoped(profile, policies) -> bool | None: + """Return whether session policies restrict permissions, or None when unknown. + + AWS evaluates the inline ``sessionPolicy`` and every ``managedPolicyArns`` + entry together as a single session-policy category, so the complete set is + merged into one evaluation: the session is scoped only when at least one + session policy exists and the set does not grant ``*:*``. Managed entries + are resolved through the collected IAM policies. An invalid inline policy + or an unresolved managed policy does not prove that the session is + restricted and propagates as None. + + Args: + profile: A ``rolesanywhere_service.Profile``. + policies: Mapping of policy ARN to ``iam_service.Policy`` from iam_client. + """ + if not profile.session_policy and not profile.managed_policy_arns: + return False + + documents = [] + if profile.session_policy: + try: + documents.append(json.loads(profile.session_policy)) + except (ValueError, TypeError): + return None + for arn in profile.managed_policy_arns or []: + if arn.endswith(ADMIN_POLICY_ARN_SUFFIX): + documents.append({"Statement": [_ADMIN_STATEMENT]}) + else: + documents.append(getattr(policies.get(arn), "document", None)) + + grants_full_access = _full_access_status(documents) + return None if grants_full_access is None else not grants_full_access + + +class rolesanywhere_profile_restricts_session_permissions(Check): + """Flag Roles Anywhere profiles that vend an unscoped session on a privileged role. + + A Roles Anywhere profile that does not restrict the session with an inline + ``sessionPolicy`` or ``managedPolicyArns`` vends temporary credentials + carrying the full permissions of every role it references. This is only a + real risk when a referenced role is itself administrative: any certificate + accepted by the trust anchor then wields administrative permissions, turning + the profile into a durable privileged-access path that surviving key rotation + does not remove. Profiles that scope the session, whose referenced roles were + proven not administrative, or that are disabled are reported as PASS. When + session scoping or role permissions cannot be evaluated (unresolved or + invalid policy documents, condition-guarded grants, unknown roles), the + report is MANUAL rather than a proven outcome. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Evaluate session-permission scoping for Roles Anywhere profiles. + + Returns: + list[Check_Report_AWS]: One report per Roles Anywhere profile. FAIL + for enabled, unscoped profiles that reference a proven administrative + role; MANUAL when session scoping or role permissions could not be + evaluated; PASS for scoped profiles, profiles whose roles were proven + not administrative, and disabled profiles. + """ + findings = [] + # 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 = { + arn: ( + _role_is_privileged(roles_by_arn[arn], iam_client.policies) + if arn in roles_by_arn + else None + ) + for arn in profile.role_arns + } + privileged_role_arns = [ + arn for arn, status in role_statuses.items() if status is True + ] + unknown_role_arns = [ + arn for arn, status in role_statuses.items() if status is None + ] + session_scoped = _session_is_scoped(profile, iam_client.policies) + + if not profile.enabled: + report.status = "PASS" + report.status_extended = ( + f"IAM Roles Anywhere profile {profile.name} is disabled and " + "cannot vend session credentials." + ) + elif session_scoped is True: + report.status = "PASS" + report.status_extended = ( + f"IAM Roles Anywhere profile {profile.name} restricts vended " + "session permissions with a session policy or managed policies." + ) + elif session_scoped is None: + report.status = "MANUAL" + report.status_extended = ( + f"IAM Roles Anywhere profile {profile.name} session scoping " + "could not be evaluated because an inline or managed session " + "policy was invalid or unresolved." + ) + elif privileged_role_arns: + report.status = "FAIL" + report.status_extended = ( + f"IAM Roles Anywhere profile {profile.name} does not scope down " + "sessions and references administrative role(s) " + f"{', '.join(privileged_role_arns)}; certificates authenticated " + "through it inherit administrative permissions, enabling durable " + "privileged access." + ) + elif unknown_role_arns: + report.status = "MANUAL" + report.status_extended = ( + f"IAM Roles Anywhere profile {profile.name} does not scope down " + "sessions, and the effective permissions of referenced role(s) " + f"{', '.join(unknown_role_arns)} could not be evaluated." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"IAM Roles Anywhere profile {profile.name} does not scope down " + "sessions, but no referenced role was identified as " + "administrative; scoping the session is recommended as " + "defense-in-depth." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/rolesanywhere/rolesanywhere_service.py b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_service.py index 46dca281d6..aa53352b4a 100644 --- a/prowler/providers/aws/services/rolesanywhere/rolesanywhere_service.py +++ b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_service.py @@ -11,7 +11,9 @@ class RolesAnywhere(AWSService): def __init__(self, provider): super().__init__(__class__.__name__, provider) self.trust_anchors = {} + self.profiles = {} self.__threading_call__(self._list_trust_anchors) + self.__threading_call__(self._list_profiles) def _list_trust_anchors(self, regional_client): logger.info("RolesAnywhere - Listing Trust Anchors...") @@ -52,6 +54,53 @@ class RolesAnywhere(AWSService): f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _list_profiles(self, regional_client): + """List and cache IAM Roles Anywhere profiles for one AWS Region. + + Args: + regional_client: Roles Anywhere client for the audited Region. + """ + logger.info("RolesAnywhere - Listing Profiles...") + try: + paginator = regional_client.get_paginator("list_profiles") + for page in paginator.paginate(): + for profile in page.get("profiles", []): + arn = profile.get("profileArn", "") + if not arn: + continue + if self.audit_resources and not is_resource_filtered( + arn, self.audit_resources + ): + continue + tags = [] + try: + tags = regional_client.list_tags_for_resource( + resourceArn=arn + ).get("tags", []) + except Exception as error: + logger.warning( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + self.profiles[arn] = Profile( + arn=arn, + id=profile.get("profileId", ""), + name=profile.get("name", ""), + region=regional_client.region, + enabled=profile.get("enabled", False), + role_arns=profile.get("roleArns", []) or [], + session_policy=profile.get("sessionPolicy", "") or "", + managed_policy_arns=profile.get("managedPolicyArns", []) or [], + duration_seconds=profile.get("durationSeconds", 0) or 0, + accept_role_session_name=profile.get( + "acceptRoleSessionName", False + ), + tags=tags, + ) + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + class TrustAnchor(BaseModel): arn: str @@ -62,3 +111,19 @@ class TrustAnchor(BaseModel): source_type: str = "" acm_pca_arn: str = "" tags: List[Dict[str, str]] = Field(default_factory=list) + + +class Profile(BaseModel): + """Represent an IAM Roles Anywhere profile.""" + + arn: str + id: str + name: str + region: str + enabled: bool = False + role_arns: List[str] = Field(default_factory=list) + session_policy: str = "" + managed_policy_arns: List[str] = Field(default_factory=list) + duration_seconds: int = 0 + accept_role_session_name: bool = False + tags: List[Dict[str, str]] = Field(default_factory=list) 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/ses/ses_identity_not_publicly_accessible/ses_identity_not_publicly_accessible.py b/prowler/providers/aws/services/ses/ses_identity_not_publicly_accessible/ses_identity_not_publicly_accessible.py index f668b05d06..6f4440a679 100644 --- a/prowler/providers/aws/services/ses/ses_identity_not_publicly_accessible/ses_identity_not_publicly_accessible.py +++ b/prowler/providers/aws/services/ses/ses_identity_not_publicly_accessible/ses_identity_not_publicly_accessible.py @@ -1,25 +1,58 @@ +from copy import deepcopy + from prowler.lib.check.models import Check, Check_Report_AWS from prowler.providers.aws.services.iam.lib.policy import is_policy_public from prowler.providers.aws.services.ses.ses_client import ses_client +def _normalize_policy_statements(policy: dict) -> dict: + statements = policy.get("Statement", []) + if isinstance(statements, dict): + return {**policy, "Statement": [statements]} + return policy + + +def _has_explicit_deny(policy: dict) -> bool: + return any( + isinstance(statement, dict) and statement.get("Effect") == "Deny" + for statement in _normalize_policy_statements(policy).get("Statement", []) + ) + + class ses_identity_not_publicly_accessible(Check): - def execute(self): + """Ensure SES identities are not publicly accessible through authorization policies.""" + + def execute(self) -> list[Check_Report_AWS]: + """Evaluate every authorization policy attached to each SES identity. + + Returns: + A list of reports containing the public-access result for each identity. + """ findings = [] for identity in ses_client.email_identities.values(): - if identity.policy is None: + if not identity.policies: continue report = Check_Report_AWS(metadata=self.metadata(), resource=identity) report.status = "PASS" report.status_extended = ( f"SES identity {identity.name} is not publicly accessible." ) - if is_policy_public( - identity.policy, - ses_client.audited_account, - ): - report.status = "FAIL" - report.status_extended = f"SES identity {identity.name} is publicly accessible due to its resource policy." + has_public_allow = any( + is_policy_public( + _normalize_policy_statements(deepcopy(policy)), + ses_client.audited_account, + ) + for policy in identity.policies.values() + ) + if has_public_allow: + if any( + _has_explicit_deny(policy) for policy in identity.policies.values() + ): + report.status = "MANUAL" + report.status_extended = f"SES identity {identity.name} has public Allow and explicit Deny statements in its resource policies. Effective public access requires manual review." + else: + report.status = "FAIL" + report.status_extended = f"SES identity {identity.name} is publicly accessible due to its resource policies." findings.append(report) diff --git a/prowler/providers/aws/services/ses/ses_service.py b/prowler/providers/aws/services/ses/ses_service.py index 7d0feb8f54..b865b0128e 100644 --- a/prowler/providers/aws/services/ses/ses_service.py +++ b/prowler/providers/aws/services/ses/ses_service.py @@ -1,7 +1,7 @@ from json import loads from typing import Optional -from pydantic.v1 import BaseModel +from pydantic.v1 import BaseModel, Field from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered @@ -46,8 +46,11 @@ class SES(AWSService): identity_attributes = regional_client.get_email_identity( EmailIdentity=identity.name ) - for _, content in identity_attributes.get("Policies", {}).items(): - identity.policy = loads(content) + identity.policies = { + name: loads(content) + for name, content in identity_attributes.get("Policies", {}).items() + } + identity.policy = next(reversed(identity.policies.values()), None) identity.tags = identity_attributes.get("Tags", []) dkim_attrs = identity_attributes.get("DkimAttributes", {}) or {} identity.dkim_status = dkim_attrs.get("Status") @@ -72,6 +75,7 @@ class Identity(BaseModel): region: str type: Optional[str] policy: Optional[dict] = None + policies: dict[str, dict] = Field(default_factory=dict) tags: Optional[list] = [] dkim_status: Optional[str] = None dkim_signing_attributes_origin: Optional[str] = None 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 987a86068c..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 @@ -17,6 +18,8 @@ class IAM(GCPService): self.service_accounts = [] self._get_service_accounts() self._get_service_accounts_keys() + self.workload_identity_pool_providers = [] + self._get_workload_identity_pool_providers() def _get_service_accounts(self): for project_id in self.project_ids: @@ -87,6 +90,94 @@ class IAM(GCPService): f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _get_workload_identity_pool_providers(self): + for project_id in self.project_ids: + try: + pools_request = ( + self.client.projects() + .locations() + .workloadIdentityPools() + .list(parent=f"projects/{project_id}/locations/global") + ) + while pools_request is not None: + pools_response = pools_request.execute( + num_retries=DEFAULT_RETRY_ATTEMPTS + ) + for pool in pools_response.get("workloadIdentityPools", []): + self._get_providers_for_pool(project_id, pool) + pools_request = ( + self.client.projects() + .locations() + .workloadIdentityPools() + .list_next( + previous_request=pools_request, + previous_response=pools_response, + ) + ) + except Exception as error: + logger.error( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _get_providers_for_pool(self, project_id, pool): + try: + pool_name = pool.get("name", "") + pool_id = pool_name.split("/")[-1] + # A provider can remain ACTIVE while its parent pool is disabled or + # soft-deleted; a disabled pool cannot vend credentials, so the + # pool's effective availability must travel with the provider. + pool_disabled = ( + pool.get("disabled", False) or pool.get("state", "ACTIVE") != "ACTIVE" + ) + request = ( + self.client.projects() + .locations() + .workloadIdentityPools() + .providers() + .list(parent=pool_name) + ) + while request is not None: + response = request.execute(num_retries=DEFAULT_RETRY_ATTEMPTS) + for provider in response.get("workloadIdentityPoolProviders", []): + provider_type = next( + ( + key + for key in ("oidc", "aws", "saml", "x509") + if key in provider + ), + "", + ) + self.workload_identity_pool_providers.append( + WorkloadIdentityPoolProvider( + name=provider.get("name", ""), + id=provider.get("name", "").split("/")[-1], + pool_id=pool_id, + pool_disabled=pool_disabled, + project_id=project_id, + state=provider.get("state", ""), + disabled=provider.get("disabled", False), + attribute_condition=provider.get("attributeCondition", ""), + attribute_mapping=provider.get("attributeMapping", {}) + or {}, + provider_type=provider_type, + issuer_uri=(provider.get("oidc", {}) or {}).get( + "issuerUri", "" + ), + display_name=provider.get("displayName", ""), + ) + ) + request = ( + self.client.projects() + .locations() + .workloadIdentityPools() + .providers() + .list_next(previous_request=request, previous_response=response) + ) + except Exception as error: + logger.error( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + class Key(BaseModel): name: str @@ -106,10 +197,33 @@ class ServiceAccount(BaseModel): disabled: bool = False +class WorkloadIdentityPoolProvider(BaseModel): + """Represent a GCP Workload Identity Federation pool provider.""" + + name: str + id: str + pool_id: str + # True when the parent pool is disabled or not ACTIVE; such a pool cannot + # vend credentials regardless of the provider's own state. + pool_disabled: bool = False + project_id: str + state: str = "" + disabled: bool = False + attribute_condition: str = "" + attribute_mapping: dict = {} + provider_type: str = "" + issuer_uri: str = "" + display_name: str = "" + + 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): @@ -125,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/gcp/services/iam/iam_workload_identity_pool_provider_attribute_condition/__init__.py b/prowler/providers/gcp/services/iam/iam_workload_identity_pool_provider_attribute_condition/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/iam/iam_workload_identity_pool_provider_attribute_condition/iam_workload_identity_pool_provider_attribute_condition.metadata.json b/prowler/providers/gcp/services/iam/iam_workload_identity_pool_provider_attribute_condition/iam_workload_identity_pool_provider_attribute_condition.metadata.json new file mode 100644 index 0000000000..5caa3cb774 --- /dev/null +++ b/prowler/providers/gcp/services/iam/iam_workload_identity_pool_provider_attribute_condition/iam_workload_identity_pool_provider_attribute_condition.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "gcp", + "CheckID": "iam_workload_identity_pool_provider_attribute_condition", + "CheckTitle": "Workload Identity Federation providers trusting a multi-tenant issuer enforce an attribute condition", + "CheckType": [], + "ServiceName": "iam", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "iam.googleapis.com/WorkloadIdentityPoolProvider", + "Description": "**Workload Identity Federation providers** define an `attributeCondition` (CEL) restricting which external identities may impersonate Google Cloud principals. When a provider trusts a **multi-tenant issuer** (GitHub Actions, GitLab.com and other shared issuers), omitting the condition trusts every identity that issuer can mint. Providers trusting a dedicated single-tenant issuer are not flagged.", + "Risk": "A provider trusting a multi-tenant issuer without an attribute condition accepts any external identity from that issuer - for example any GitHub repository when the issuer is GitHub Actions. An attacker controlling any tenant on that platform can authenticate through the provider and exchange tokens for federated credentials. Because no key is stored, this access survives credential rotation.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://cloud.google.com/iam/docs/workload-identity-federation", + "https://cloud.google.com/iam/docs/workload-identity-federation#mapping", + "https://cloud.google.com/iam/docs/best-practices-for-using-workload-identity-federation" + ], + "Remediation": { + "Code": { + "CLI": "gcloud iam workload-identity-pools providers update- --location=global --workload-identity-pool= --attribute-condition=\"\" # use the update subcommand matching the provider type", + "NativeIaC": "", + "Other": "1. In the Google Cloud console, go to IAM & Admin > Workload Identity Federation\n2. Open the affected pool and provider\n3. Set an attribute condition (CEL) restricting the allowed external identities, using an assertion that fits the provider type: OIDC by subject or claim (assertion.sub), SAML by NameID or attribute (assertion.subject or assertion.attributes[...]), AWS by account/role (assertion.account or assertion.arn), X.509 by certificate subject\n4. For OIDC providers, also restrict allowedAudiences to your own audience\n5. Review providers regularly for entries you did not create", + "Terraform": "```hcl\nresource \"google_iam_workload_identity_pool_provider\" \"example\" {\n workload_identity_pool_id = \"my-pool\"\n workload_identity_pool_provider_id = \"my-provider\"\n attribute_condition = \"assertion.repository_owner == 'my-org'\" # FIX: restrict trusted identities\n\n # Declare exactly one of oidc {}, aws {}, saml {}, or x509 {} to match the provider type, e.g.:\n oidc {\n issuer_uri = \"https://token.actions.githubusercontent.com\"\n allowed_audiences = [\"https://my-audience.example.com\"]\n }\n}\n```" + }, + "Recommendation": { + "Text": "Set an attribute condition on any active Workload Identity Federation provider that trusts a multi-tenant issuer so only the intended external identities can impersonate Google Cloud principals, and pair OIDC providers with a restricted audience. Providers trusting a dedicated single-tenant issuer should still add a condition as defense-in-depth. Audit pools and providers regularly for unexpected entries.", + "Url": "https://hub.prowler.com/check/iam_workload_identity_pool_provider_attribute_condition" + } + }, + "Categories": [ + "identity-access", + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Only active providers that trust a known multi-tenant issuer (GitHub Actions, GitLab.com, Google, HCP Terraform) are failed when they omit an attribute condition; providers trusting a dedicated single-tenant issuer, AWS/SAML/X.509 providers, and disabled or non-ACTIVE providers are reported as PASS. This check verifies that an attribute condition is present; it does not evaluate whether the condition's expression is sufficiently restrictive." +} diff --git a/prowler/providers/gcp/services/iam/iam_workload_identity_pool_provider_attribute_condition/iam_workload_identity_pool_provider_attribute_condition.py b/prowler/providers/gcp/services/iam/iam_workload_identity_pool_provider_attribute_condition/iam_workload_identity_pool_provider_attribute_condition.py new file mode 100644 index 0000000000..79577e888f --- /dev/null +++ b/prowler/providers/gcp/services/iam/iam_workload_identity_pool_provider_attribute_condition/iam_workload_identity_pool_provider_attribute_condition.py @@ -0,0 +1,107 @@ +from urllib.parse import urlparse + +from prowler.lib.check.models import Check, Check_Report_GCP +from prowler.providers.gcp.services.iam.iam_client import iam_client + +# OIDC issuers whose tokens are minted for many independent tenants (any GitHub +# repository, any GitLab project, any Google account, ...). A provider that +# trusts one of these without an ``attributeCondition`` accepts identities +# outside the operator's control, so omitting the condition genuinely expands +# trust. A dedicated, single-tenant issuer only vends tokens to the operator's +# own workloads, so an attribute condition there is defense-in-depth rather than +# a requirement (see Google's guidance for GitHub and other shared issuers). +MULTI_TENANT_OIDC_ISSUER_HOSTS = { + "token.actions.githubusercontent.com", # GitHub Actions (any repository) + "gitlab.com", # GitLab.com SaaS (any project) + "accounts.google.com", # any Google account + "app.terraform.io", # HCP Terraform (any organization) +} + + +def _is_multi_tenant_issuer(issuer_uri: str) -> bool: + """Return True when the OIDC issuer is a known multi-tenant/shared issuer.""" + if not issuer_uri: + return False + # hostname lowercases and strips port/userinfo (gitlab.com:443, user@host); + # fall back to the raw string for bare hosts without a scheme. + host = urlparse(issuer_uri).hostname or issuer_uri.lower() + return host in MULTI_TENANT_OIDC_ISSUER_HOSTS + + +class iam_workload_identity_pool_provider_attribute_condition(Check): + """Ensure WIF providers trusting a multi-tenant issuer enforce an attribute condition. + + A workload identity pool provider that trusts a multi-tenant issuer (GitHub + Actions, GitLab.com, ...) without an ``attributeCondition`` accepts every + external identity that issuer can mint. An attacker controlling any tenant on + that platform can then authenticate through the provider and exchange tokens + for federated credentials, surviving credential rotation. Providers that + enforce an attribute condition, that trust a dedicated single-tenant issuer, + that are not OIDC-based, that are disabled/inactive, or whose parent pool is + disabled are reported as PASS. + """ + + def execute(self) -> list[Check_Report_GCP]: + """Evaluate the attribute condition of each Workload Identity provider. + + Returns: + list[Check_Report_GCP]: One report per workload identity pool + provider. FAIL for active providers that trust a multi-tenant issuer + without an attribute condition; PASS for providers that enforce one, + trust a dedicated issuer, are not OIDC-based, or are + disabled/inactive. + """ + findings = [] + for provider in iam_client.workload_identity_pool_providers: + report = Check_Report_GCP( + metadata=self.metadata(), + resource=provider, + resource_id=provider.name, + resource_name=provider.display_name or provider.id, + location="global", + ) + if provider.pool_disabled: + report.status = "PASS" + report.status_extended = ( + f"Workload Identity Federation provider {provider.id} belongs " + f"to the disabled pool {provider.pool_id}, which cannot vend " + "credentials." + ) + elif provider.disabled or provider.state != "ACTIVE": + report.status = "PASS" + report.status_extended = ( + f"Workload Identity Federation provider {provider.id} in pool " + f"{provider.pool_id} is not active and cannot vend credentials." + ) + elif provider.attribute_condition: + report.status = "PASS" + report.status_extended = ( + f"Workload Identity Federation provider {provider.id} in pool " + f"{provider.pool_id} enforces an attribute condition." + ) + elif _is_multi_tenant_issuer(provider.issuer_uri): + report.status = "FAIL" + report.status_extended = ( + f"Workload Identity Federation provider {provider.id} in pool " + f"{provider.pool_id} trusts the multi-tenant issuer " + f"{provider.issuer_uri} without an attribute condition, so any " + "identity from that issuer can authenticate through this provider." + ) + elif provider.provider_type != "oidc": + report.status = "PASS" + report.status_extended = ( + f"Workload Identity Federation provider {provider.id} in pool " + f"{provider.pool_id} is not an OIDC provider trusting a " + "multi-tenant issuer; an attribute condition is recommended as " + "defense-in-depth but not required." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Workload Identity Federation provider {provider.id} in pool " + f"{provider.pool_id} trusts a dedicated issuer; an attribute " + "condition is recommended as defense-in-depth but not required." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/github/services/organization/organization_actions_pull_request_approval_disabled/__init__.py b/prowler/providers/github/services/organization/organization_actions_pull_request_approval_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/github/services/organization/organization_actions_pull_request_approval_disabled/organization_actions_pull_request_approval_disabled.metadata.json b/prowler/providers/github/services/organization/organization_actions_pull_request_approval_disabled/organization_actions_pull_request_approval_disabled.metadata.json new file mode 100644 index 0000000000..6a6af964eb --- /dev/null +++ b/prowler/providers/github/services/organization/organization_actions_pull_request_approval_disabled/organization_actions_pull_request_approval_disabled.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "github", + "CheckID": "organization_actions_pull_request_approval_disabled", + "CheckTitle": "Organization prevents GitHub Actions from approving pull requests", + "CheckType": [], + "ServiceName": "organization", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "governance", + "Description": "GitHub Actions organization settings define whether workflows are allowed to create and approve pull requests.\n\nThe evaluation determines whether that permission is disabled, so that approving a pull request always requires a human reviewer.", + "Risk": "When workflows can approve pull requests, automation can satisfy the review requirement it was meant to be checked by, leading to:\n- Required reviews being cleared without any human inspecting the change\n- Self-approving pull requests opened by a workflow\n- Merge gates that appear enforced while providing no real oversight", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.github.com/en/organizations/managing-organization-settings/disabling-or-limiting-github-actions-for-your-organization#preventing-github-actions-from-creating-or-approving-pull-requests", + "https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication", + "https://docs.github.com/en/rest/actions/permissions" + ], + "Remediation": { + "Code": { + "CLI": "gh api --method PUT /orgs//actions/permissions/workflow -F can_approve_pull_request_reviews=false", + "NativeIaC": "", + "Other": "1. Sign in to GitHub as an organization owner\n2. Go to your organization > Settings\n3. In the left sidebar, click Actions > General\n4. Under Workflow permissions, clear Allow GitHub Actions to create and approve pull requests\n5. Click Save", + "Terraform": "" + }, + "Recommendation": { + "Text": "Disable **Allow GitHub Actions to create and approve pull requests** so that pull request approvals always come from a human reviewer.\n\nWhere automation genuinely needs to open pull requests, let it create them without granting approval rights, and keep the approval step with a reviewer who is not the author.", + "Url": "https://hub.prowler.com/check/organization_actions_pull_request_approval_disabled" + } + }, + "Categories": [ + "ci-cd", + "software-supply-chain" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/github/services/organization/organization_actions_pull_request_approval_disabled/organization_actions_pull_request_approval_disabled.py b/prowler/providers/github/services/organization/organization_actions_pull_request_approval_disabled/organization_actions_pull_request_approval_disabled.py new file mode 100644 index 0000000000..92d53cf22b --- /dev/null +++ b/prowler/providers/github/services/organization/organization_actions_pull_request_approval_disabled/organization_actions_pull_request_approval_disabled.py @@ -0,0 +1,38 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGithub +from prowler.providers.github.services.organization.organization_client import ( + organization_client, +) + + +class organization_actions_pull_request_approval_disabled(Check): + """Check if GitHub Actions workflows are prevented from approving pull requests. + + This class verifies whether each organization disallows workflows from creating and + approving pull requests, so that required reviews cannot be satisfied by automation. + """ + + def execute(self) -> List[CheckReportGithub]: + """Execute the Github Organization Actions Pull Request Approval Disabled check. + + Iterates over all organizations and checks whether GitHub Actions workflows are + allowed to approve pull requests. + + Returns: + List[CheckReportGithub]: A list of reports for each organization + """ + findings = [] + for org in organization_client.organizations.values(): + if org.can_approve_pull_request_reviews is not None: + report = CheckReportGithub(metadata=self.metadata(), resource=org) + report.status = "PASS" + report.status_extended = f"Organization {org.name} does not allow GitHub Actions to approve pull requests." + + if org.can_approve_pull_request_reviews: + report.status = "FAIL" + report.status_extended = f"Organization {org.name} allows GitHub Actions to approve pull requests." + + findings.append(report) + + return findings diff --git a/prowler/providers/github/services/organization/organization_default_workflow_permissions_read_only/__init__.py b/prowler/providers/github/services/organization/organization_default_workflow_permissions_read_only/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/github/services/organization/organization_default_workflow_permissions_read_only/organization_default_workflow_permissions_read_only.metadata.json b/prowler/providers/github/services/organization/organization_default_workflow_permissions_read_only/organization_default_workflow_permissions_read_only.metadata.json new file mode 100644 index 0000000000..af2ef2ffa2 --- /dev/null +++ b/prowler/providers/github/services/organization/organization_default_workflow_permissions_read_only/organization_default_workflow_permissions_read_only.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "github", + "CheckID": "organization_default_workflow_permissions_read_only", + "CheckTitle": "Organization grants workflows a read-only default GITHUB_TOKEN", + "CheckType": [], + "ServiceName": "organization", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "governance", + "Description": "GitHub Actions organization settings define the default **GITHUB_TOKEN** permissions granted to every workflow run.\n\nThe evaluation determines whether the organization default is read-only, so that workflows have to opt in to write access through an explicit `permissions` block.", + "Risk": "A write-capable default **GITHUB_TOKEN** is available to every workflow in the organization, so any compromised step or dependency inherits it, leading to:\n- Code integrity loss from pushed commits, moved tags, or altered releases\n- Supply chain compromise when the token publishes packages or artifacts\n- Weakened review controls when the token acts on pull requests and issues", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.github.com/en/organizations/managing-organization-settings/disabling-or-limiting-github-actions-for-your-organization#setting-the-permissions-of-the-github_token-for-your-organization", + "https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication", + "https://docs.github.com/en/actions/reference/security/secure-use#using-the-github_token" + ], + "Remediation": { + "Code": { + "CLI": "gh api --method PUT /orgs//actions/permissions/workflow -f default_workflow_permissions=read", + "NativeIaC": "", + "Other": "1. Sign in to GitHub as an organization owner\n2. Go to your organization > Settings\n3. In the left sidebar, click Actions > General\n4. Under Workflow permissions, select Read repository contents and packages permissions\n5. Click Save", + "Terraform": "" + }, + "Recommendation": { + "Text": "Set the organization default **GITHUB_TOKEN** permissions to read-only and grant write scopes per workflow with an explicit `permissions` block, following **least privilege**.\n\nAlso prevent Actions from approving pull requests, and prefer short-lived **GitHub App** tokens over broad credentials when a workflow genuinely needs write access.", + "Url": "https://hub.prowler.com/check/organization_default_workflow_permissions_read_only" + } + }, + "Categories": [ + "ci-cd", + "software-supply-chain" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/github/services/organization/organization_default_workflow_permissions_read_only/organization_default_workflow_permissions_read_only.py b/prowler/providers/github/services/organization/organization_default_workflow_permissions_read_only/organization_default_workflow_permissions_read_only.py new file mode 100644 index 0000000000..364bbc9333 --- /dev/null +++ b/prowler/providers/github/services/organization/organization_default_workflow_permissions_read_only/organization_default_workflow_permissions_read_only.py @@ -0,0 +1,38 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGithub +from prowler.providers.github.services.organization.organization_client import ( + organization_client, +) + + +class organization_default_workflow_permissions_read_only(Check): + """Check if the default GITHUB_TOKEN permissions granted to workflows are read-only. + + This class verifies whether each organization grants workflows a read-only GITHUB_TOKEN + by default, instead of a token with write access to the repository contents. + """ + + def execute(self) -> List[CheckReportGithub]: + """Execute the Github Organization Default Workflow Permissions Read Only check. + + Iterates over all organizations and checks the default GITHUB_TOKEN permissions + granted to GitHub Actions workflows. + + Returns: + List[CheckReportGithub]: A list of reports for each organization + """ + findings = [] + for org in organization_client.organizations.values(): + if org.default_workflow_permissions is not None: + report = CheckReportGithub(metadata=self.metadata(), resource=org) + report.status = "FAIL" + report.status_extended = f"Organization {org.name} grants workflows a default GITHUB_TOKEN with {org.default_workflow_permissions} permissions." + + if org.default_workflow_permissions == "read": + report.status = "PASS" + report.status_extended = f"Organization {org.name} grants workflows a read-only default GITHUB_TOKEN." + + findings.append(report) + + return findings diff --git a/prowler/providers/github/services/organization/organization_service.py b/prowler/providers/github/services/organization/organization_service.py index 0aceca423e..e137db29db 100644 --- a/prowler/providers/github/services/organization/organization_service.py +++ b/prowler/providers/github/services/organization/organization_service.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Optional, Tuple import github from pydantic.v1 import BaseModel @@ -63,6 +63,10 @@ class Organization(GithubService): try: org = client.get_organization(org_name) self._process_organization(org, organizations) + except github.RateLimitExceededException: + # Rate limits are transient and must not be mistaken + # for a missing organization or a permissions gap + raise except github.GithubException as org_error: # If organization fails, try as a user (personal account) if "404" in str(org_error): @@ -201,6 +205,10 @@ class Organization(GithubService): else None ) is_verified = _extract_flag("is_verified", bool) + ( + default_workflow_permissions, + can_approve_pull_request_reviews, + ) = self._get_actions_workflow_permissions(org) organizations[org.id] = Org( id=org.id, name=org.login, @@ -223,8 +231,73 @@ class Organization(GithubService): ], base_permission=base_permission, is_verified=is_verified, + default_workflow_permissions=default_workflow_permissions, + can_approve_pull_request_reviews=can_approve_pull_request_reviews, ) + def _get_actions_workflow_permissions( + self, org + ) -> Tuple[Optional[str], Optional[bool]]: + """Fetch the Actions workflow permissions settings of the organization. + + The API returns a response in the format: + { + "default_workflow_permissions": "read", + "can_approve_pull_request_reviews": false + } + + Args: + org: PyGithub Organization object. + + Returns: + Tuple[Optional[str], Optional[bool]]: The default GITHUB_TOKEN permissions + ("read" or "write") and whether workflows can approve pull requests. + Each value is None when it cannot be read. + + Raises: + github.RateLimitExceededException: When API rate limits are exceeded + """ + default_workflow_permissions = None + can_approve_pull_request_reviews = None + try: + _, response = org._requester.requestJsonAndCheck( # type: ignore[attr-defined] + "GET", + f"/orgs/{org.login}/actions/permissions/workflow", + headers={ + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + if isinstance(response, dict): + permissions = response.get("default_workflow_permissions") + if permissions in ("read", "write"): + default_workflow_permissions = permissions + can_approve = response.get("can_approve_pull_request_reviews") + if isinstance(can_approve, bool): + can_approve_pull_request_reviews = can_approve + except github.RateLimitExceededException as error: + logger.error(f"GitHub API rate limit exceeded: {error}") + raise # Re-raise rate limit errors as they need special handling + except github.GithubException as error: + status_code = getattr(error, "status", None) + if status_code == 404: + logger.info( + f"'{org.login}': Actions workflow permissions endpoint not available for this account." + ) + elif status_code == 403: + logger.warning( + f"Access denied reading Actions workflow permissions for '{org.login}' - insufficient permissions" + ) + else: + logger.error( + f"GitHub API error reading Actions workflow permissions for '{org.login}': {error}" + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return default_workflow_permissions, can_approve_pull_request_reviews + class Org(BaseModel): """Model for Github Organization""" @@ -240,3 +313,5 @@ class Org(BaseModel): members_allowed_repository_creation_type: Optional[str] = None base_permission: Optional[str] = None is_verified: Optional[bool] = None + default_workflow_permissions: Optional[str] = None + can_approve_pull_request_reviews: Optional[bool] = None diff --git a/prowler/providers/github/services/repository/repository_default_workflow_permissions_read_only/__init__.py b/prowler/providers/github/services/repository/repository_default_workflow_permissions_read_only/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/github/services/repository/repository_default_workflow_permissions_read_only/repository_default_workflow_permissions_read_only.metadata.json b/prowler/providers/github/services/repository/repository_default_workflow_permissions_read_only/repository_default_workflow_permissions_read_only.metadata.json new file mode 100644 index 0000000000..dac80aeeb4 --- /dev/null +++ b/prowler/providers/github/services/repository/repository_default_workflow_permissions_read_only/repository_default_workflow_permissions_read_only.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "github", + "CheckID": "repository_default_workflow_permissions_read_only", + "CheckTitle": "Repository grants workflows a read-only default GITHUB_TOKEN", + "CheckType": [], + "ServiceName": "repository", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "devops", + "Description": "GitHub Actions repository settings define the default **GITHUB_TOKEN** permissions granted to every workflow run in the repository.\n\nThe evaluation determines whether the repository default is read-only, so that workflows have to opt in to write access through an explicit `permissions` block.", + "Risk": "A write-capable default **GITHUB_TOKEN** is available to every workflow in the repository, so any compromised step or dependency inherits it, leading to:\n- Code integrity loss from pushed commits, moved tags, or altered releases\n- Supply chain compromise when the token publishes packages or artifacts\n- Weakened review controls when the token acts on pull requests and issues", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository#setting-the-permissions-of-the-github_token-for-your-repository", + "https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication", + "https://docs.github.com/en/rest/actions/permissions" + ], + "Remediation": { + "Code": { + "CLI": "gh api --method PUT /repos///actions/permissions/workflow -f default_workflow_permissions=read", + "NativeIaC": "", + "Other": "1. Sign in to GitHub as a repository administrator\n2. Go to the repository > Settings\n3. In the left sidebar, click Actions > General\n4. Under Workflow permissions, select Read repository contents and packages permissions\n5. Click Save", + "Terraform": "" + }, + "Recommendation": { + "Text": "Set the repository default **GITHUB_TOKEN** permissions to read-only and grant write scopes per workflow with an explicit `permissions` block, following **least privilege**.\n\nA repository can override an organization default, so verify this setting per repository even when the organization already defaults to read-only. Prefer short-lived **GitHub App** tokens over broad credentials when a workflow genuinely needs write access.", + "Url": "https://hub.prowler.com/check/repository_default_workflow_permissions_read_only" + } + }, + "Categories": [ + "ci-cd", + "software-supply-chain" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/github/services/repository/repository_default_workflow_permissions_read_only/repository_default_workflow_permissions_read_only.py b/prowler/providers/github/services/repository/repository_default_workflow_permissions_read_only/repository_default_workflow_permissions_read_only.py new file mode 100644 index 0000000000..0a0e3a9eb4 --- /dev/null +++ b/prowler/providers/github/services/repository/repository_default_workflow_permissions_read_only/repository_default_workflow_permissions_read_only.py @@ -0,0 +1,38 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGithub +from prowler.providers.github.services.repository.repository_client import ( + repository_client, +) + + +class repository_default_workflow_permissions_read_only(Check): + """Ensure repositories grant workflows a read-only default GITHUB_TOKEN. + + A read-only default forces workflows to request write access explicitly, instead of + every workflow run starting with a token that can write to the repository. + """ + + def execute(self) -> List[CheckReportGithub]: + """Run the default workflow permissions verification for each discovered repository. + + Returns: + List[CheckReportGithub]: Collection of check reports describing the default GITHUB_TOKEN permissions. + """ + findings: List[CheckReportGithub] = [] + for repo in repository_client.repositories.values(): + if repo.default_workflow_permissions is None: + continue + + report = CheckReportGithub(metadata=self.metadata(), resource=repo) + + if repo.default_workflow_permissions == "read": + report.status = "PASS" + report.status_extended = f"Repository {repo.name} grants workflows a read-only default GITHUB_TOKEN." + else: + report.status = "FAIL" + report.status_extended = f"Repository {repo.name} grants workflows a default GITHUB_TOKEN with {repo.default_workflow_permissions} permissions." + + findings.append(report) + + return findings diff --git a/prowler/providers/github/services/repository/repository_service.py b/prowler/providers/github/services/repository/repository_service.py index fd8aa28662..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 @@ -398,6 +464,10 @@ class Repository(GithubService): try: repo = client.get_repo(repo_name) self._process_repository(repo, repos) + except github.RateLimitExceededException: + # Rate limits are transient and must not leave the + # scan running with an incomplete repository set + raise except Exception as error: self._handle_github_api_error( error, "accessing repository", repo_name @@ -414,6 +484,10 @@ class Repository(GithubService): ) for repo in repos_list: self._process_repository(repo, repos) + except github.RateLimitExceededException: + # Rate limits are transient and must not leave the + # scan running with an incomplete repository set + raise except Exception as error: self._handle_github_api_error( error, "processing organization", org_name @@ -433,6 +507,10 @@ class Repository(GithubService): ) for repo in repos_list: self._process_repository(repo, repos) + except github.RateLimitExceededException: + # Rate limits are transient and must not leave the + # scan running with an incomplete repository set + raise except Exception as error: self._handle_github_api_error( error, "processing organization", org_name @@ -455,6 +533,10 @@ class Repository(GithubService): f"Processing repository found via GraphQL: {repo.full_name}" ) self._process_repository(repo, repos) + except github.RateLimitExceededException: + # Rate limits are transient and must not leave the + # scan running with an incomplete repository set + raise except Exception as error: if hasattr(self, "_handle_github_api_error"): self._handle_github_api_error( @@ -721,6 +803,9 @@ class Repository(GithubService): immutable_releases_enabled=self._get_repository_immutable_releases_status( repo ), + default_workflow_permissions=self._get_default_workflow_permissions( + repo + ), default_branch=Branch( name=default_branch, protected=branch_protection, @@ -758,6 +843,8 @@ class Repository(GithubService): dependabot_alerts_enabled=dependabot_alerts_enabled, delete_branch_on_merge=delete_branch_on_merge, ) + except github.RateLimitExceededException: + raise # Re-raise rate limit errors so the listing flow can handle them except Exception as error: logger.error( f"{repo.full_name}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" @@ -811,6 +898,66 @@ class Repository(GithubService): ) return None + def _get_default_workflow_permissions(self, repo) -> Optional[str]: + """Retrieve the default GITHUB_TOKEN permissions granted to workflows in the repository. + + The API returns a response in the format: + { + "default_workflow_permissions": "read", + "can_approve_pull_request_reviews": false + } + + Args: + repo: The PyGithub repository instance to query. + + Returns: + Optional[str]: "read" or "write", and None when the setting cannot be determined. + + Raises: + github.RateLimitExceededException: When API rate limits are exceeded + """ + try: + _, response = repo._requester.requestJsonAndCheck( # type: ignore[attr-defined] + "GET", + f"/repos/{repo.full_name}/actions/permissions/workflow", + headers={ + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + if isinstance(response, dict): + permissions = response.get("default_workflow_permissions") + if permissions in ("read", "write"): + return permissions + return None + except github.RateLimitExceededException as error: + self._handle_github_api_error( + error, + "fetching default workflow permissions", + repo.full_name, + reraise_rate_limit=True, + ) + except github.GithubException as error: + status_code = getattr(error, "status", None) + if status_code == 404: + logger.info( + f"{repo.full_name}: Actions workflow permissions endpoint not available for this repository." + ) + return None + if status_code == 403: + logger.warning( + f"{repo.full_name}: insufficient permissions to query Actions workflow permissions." + ) + return None + self._handle_github_api_error( + error, "fetching default workflow permissions", repo.full_name + ) + except Exception as error: + logger.error( + f"{repo.full_name}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return None + class Branch(BaseModel): """Model for Github Branch""" @@ -851,6 +998,7 @@ class Repo(BaseModel): owner: str full_name: str immutable_releases_enabled: Optional[bool] = None + default_workflow_permissions: Optional[str] = None default_branch: Branch private: bool archived: bool 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/huaweicloud/services/vpc/vpc_security_group_open_egress/__init__.py b/prowler/providers/huaweicloud/services/vpc/vpc_security_group_open_egress/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/huaweicloud/services/vpc/vpc_security_group_open_egress/vpc_security_group_open_egress.metadata.json b/prowler/providers/huaweicloud/services/vpc/vpc_security_group_open_egress/vpc_security_group_open_egress.metadata.json new file mode 100644 index 0000000000..493d3d1835 --- /dev/null +++ b/prowler/providers/huaweicloud/services/vpc/vpc_security_group_open_egress/vpc_security_group_open_egress.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "huaweicloud", + "CheckID": "vpc_security_group_open_egress", + "CheckTitle": "VPC security groups do not allow open egress to the internet", + "CheckType": [], + "ServiceName": "vpc", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "HUAWEICLOUD::VPC::SecurityGroup", + "ResourceGroup": "network", + "Description": "Security groups should not allow unrestricted egress to all destinations. Huawei Cloud represents unrestricted destinations as 0.0.0.0/0, ::/0, or an empty remote IP prefix without a remote security group or address group. Open egress allows instances to reach any destination on the internet, increasing the risk of data exfiltration and command-and-control communication.", + "Risk": "Unrestricted egress rules allow instances to communicate with any external destination. This increases the risk of data exfiltration, unauthorized outbound connections, and command-and-control channel establishment by compromised instances.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://support.huaweicloud.com/intl/en-us/usermanual-vpc/vpc_SecurityGroup_0001.html" + ], + "Remediation": { + "Code": { + "CLI": "hcloud VPC UpdateSecurityGroupRule --security_group_rule_id --remote_ip_prefix ", + "NativeIaC": "", + "Other": "1. Log on to the Huawei Cloud console.\n2. Choose VPC.\n3. Click Security Groups.\n4. Select the security group.\n5. Edit each allow egress rule whose destination is 0.0.0.0/0, ::/0, or empty and unscoped.\n6. Restrict the destination to required IP ranges or an appropriate security group or address group.\n7. Click OK.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Restrict allow egress rules to required destination IP ranges, security groups, or address groups instead of allowing all IPv4 or IPv6 destinations.", + "Url": "https://hub.prowler.com/check/vpc_security_group_open_egress" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/huaweicloud/services/vpc/vpc_security_group_open_egress/vpc_security_group_open_egress.py b/prowler/providers/huaweicloud/services/vpc/vpc_security_group_open_egress/vpc_security_group_open_egress.py new file mode 100644 index 0000000000..0362087f53 --- /dev/null +++ b/prowler/providers/huaweicloud/services/vpc/vpc_security_group_open_egress/vpc_security_group_open_egress.py @@ -0,0 +1,56 @@ +from prowler.lib.check.models import Check, CheckReportHuaweiCloud +from prowler.providers.huaweicloud.services.vpc.vpc_client import vpc_client + + +class vpc_security_group_open_egress(Check): + """Check if VPC security groups allow unrestricted egress to the internet.""" + + def execute(self) -> list[CheckReportHuaweiCloud]: + """Execute the unrestricted egress check. + + Returns: + list[CheckReportHuaweiCloud]: Reports for the evaluated security groups. + """ + findings = [] + + for sg in vpc_client.security_groups.values(): + report = CheckReportHuaweiCloud(metadata=self.metadata(), resource=sg) + report.region = sg.region + report.resource_id = sg.id + report.resource_arn = ( + f"huaweicloud:vpc:{sg.region}:" + f"{vpc_client.audited_account}:security-group/{sg.id}" + ) + + open_egress_destination = None + for rule in sg.rules: + if ( + rule.direction != "egress" + or rule.action != "allow" + or rule.remote_group_id + or rule.remote_address_group_id + ): + continue + if rule.remote_ip_prefix in ("0.0.0.0/0", "::/0"): + open_egress_destination = rule.remote_ip_prefix + break + if not rule.remote_ip_prefix: + open_egress_destination = "all destinations" + break + + if open_egress_destination: + report.status = "FAIL" + report.status_extended = ( + f"Security group {sg.name} ({sg.id}) allows open egress " + f"({open_egress_destination}) to the internet." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Security group {sg.name} ({sg.id}) does not allow open egress " + f"to the internet." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/huaweicloud/services/vpc/vpc_service.py b/prowler/providers/huaweicloud/services/vpc/vpc_service.py index 842fd0bcb3..e62f1b5568 100644 --- a/prowler/providers/huaweicloud/services/vpc/vpc_service.py +++ b/prowler/providers/huaweicloud/services/vpc/vpc_service.py @@ -91,6 +91,8 @@ class VPC(HuaweiCloudService): id=getattr(rule_data, "id", None) or "", direction=getattr(rule_data, "direction", None) or "", + action=getattr(rule_data, "action", None) + or "allow", protocol=getattr(rule_data, "protocol", None) or "", ethertype=getattr(rule_data, "ethertype", None) @@ -109,6 +111,12 @@ class VPC(HuaweiCloudService): rule_data, "remote_group_id", None ) or "", + remote_address_group_id=getattr( + rule_data, + "remote_address_group_id", + None, + ) + or "", description=getattr( rule_data, "description", None ) @@ -148,12 +156,14 @@ class SecurityGroupRule(HuaweiCloudBaseModel): id: str direction: str + action: str = "allow" protocol: str ethertype: str port_range_min: Optional[int] = None port_range_max: Optional[int] = None remote_ip_prefix: str = "" remote_group_id: str = "" + remote_address_group_id: str = "" description: str = "" @@ -181,13 +191,16 @@ def rule_source_is_open(rule: SecurityGroupRule) -> bool: Huawei Cloud represents "any source" in two ways: an explicit ``0.0.0.0/0`` (or ``::/0``) in ``remote_ip_prefix``, or leaving both ``remote_ip_prefix`` - and ``remote_group_id`` empty. Rules that reference another security group - via ``remote_group_id`` are NOT open even when ``remote_ip_prefix`` is - empty. + and both group identifiers empty. Rules that reference a security group or + address group are NOT open even when ``remote_ip_prefix`` is empty. """ if rule.remote_ip_prefix in ("0.0.0.0/0", "::/0"): return True - return not rule.remote_ip_prefix and not rule.remote_group_id + return ( + not rule.remote_ip_prefix + and not rule.remote_group_id + and not rule.remote_address_group_id + ) def rule_covers_all_ports(rule: SecurityGroupRule) -> bool: diff --git a/prowler/providers/iac/exceptions/__init__.py b/prowler/providers/iac/exceptions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/iac/exceptions/exceptions.py b/prowler/providers/iac/exceptions/exceptions.py new file mode 100644 index 0000000000..bbc3889b39 --- /dev/null +++ b/prowler/providers/iac/exceptions/exceptions.py @@ -0,0 +1,80 @@ +from prowler.exceptions.exceptions import ProwlerException + + +# Exceptions codes from 21000 to 21999 are reserved for IaC exceptions +class IacBaseException(ProwlerException): + """Base class for IaC provider errors.""" + + IAC_ERROR_CODES = { + (21000, "IacRepositoryCloneError"): { + "message": "Unable to clone the repository to scan", + "remediation": "Check that the repository URL is correct, that it is reachable, and that the provided credentials can read it.", + }, + (21001, "IacTrivyNotFoundError"): { + "message": "Trivy binary not found", + "remediation": "Install Trivy from https://trivy.dev/latest/getting-started/installation/ or use your system package manager (e.g., 'brew install trivy' on macOS, 'apt-get install trivy' on Ubuntu).", + }, + (21002, "IacScanError"): { + "message": "Error running the IaC scan", + "remediation": "Check the Trivy output and the scanned path, then try again.", + }, + (21003, "IacOutputProcessingError"): { + "message": "Error processing the IaC scan output", + "remediation": "Check the Trivy output format and try again.", + }, + } + + def __init__(self, code, file=None, original_exception=None, message=None): + provider = "IaC" + error_info = self.IAC_ERROR_CODES.get((code, self.__class__.__name__)) + if error_info is None: + error_info = { + "message": message or "Unknown IaC error", + "remediation": "Check the error message and try again.", + } + elif message: + error_info = error_info.copy() + error_info["message"] = message + super().__init__( + code=code, + source=provider, + file=file, + original_exception=original_exception, + error_info=error_info, + ) + + +class IacRepositoryCloneError(IacBaseException): + """Exception raised when the repository to scan cannot be cloned.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 21000, file=file, original_exception=original_exception, message=message + ) + + +class IacTrivyNotFoundError(IacBaseException): + """Exception raised when the Trivy binary is not available.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 21001, file=file, original_exception=original_exception, message=message + ) + + +class IacScanError(IacBaseException): + """Exception raised when the Trivy scan fails.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 21002, file=file, original_exception=original_exception, message=message + ) + + +class IacOutputProcessingError(IacBaseException): + """Exception raised when a Trivy finding cannot be processed.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 21003, file=file, original_exception=original_exception, message=message + ) diff --git a/prowler/providers/iac/iac_provider.py b/prowler/providers/iac/iac_provider.py index df9114e033..f9477bc5ce 100644 --- a/prowler/providers/iac/iac_provider.py +++ b/prowler/providers/iac/iac_provider.py @@ -24,6 +24,13 @@ from prowler.lib.utils.vulnerability_references import ( ) from prowler.providers.common.models import Audit_Metadata, Connection from prowler.providers.common.provider import Provider +from prowler.providers.iac.exceptions.exceptions import ( + IacBaseException, + IacOutputProcessingError, + IacRepositoryCloneError, + IacScanError, + IacTrivyNotFoundError, +) class IacProvider(Provider): @@ -270,7 +277,9 @@ class IacProvider(Provider): logger.critical( f"{error.__class__.__name__}:{error.__traceback__.tb_lineno} -- {error}" ) - sys.exit(1) + raise IacOutputProcessingError( + file=__file__, original_exception=error + ) from error def _detect_branch_name(self, repo_path: str) -> str: """ @@ -376,6 +385,9 @@ class IacProvider(Provider): logger.critical( f"{error.__class__.__name__}:{error.__traceback__.tb_lineno} -- {error}" ) + raise IacRepositoryCloneError( + file=__file__, original_exception=error + ) from error def run(self) -> List[CheckReportIAC]: """ @@ -499,7 +511,7 @@ class IacProvider(Provider): logger.critical( f"{error.__class__.__name__}:{error.__traceback__.tb_lineno} -- {error}" ) - sys.exit(1) + raise IacScanError(file=__file__, original_exception=error) from error batch = [] batch_size = 100 @@ -551,16 +563,20 @@ class IacProvider(Provider): if batch: yield batch + except IacBaseException: + raise except Exception as error: if "No such file or directory: 'trivy'" in str(error): logger.critical( "Trivy binary not found. Please install Trivy from https://trivy.dev/latest/getting-started/installation/ or use your system package manager (e.g., 'brew install trivy' on macOS, 'apt-get install trivy' on Ubuntu)" ) - sys.exit(1) + raise IacTrivyNotFoundError( + file=__file__, original_exception=error + ) from error logger.critical( f"{error.__class__.__name__}:{error.__traceback__.tb_lineno} -- {error}" ) - sys.exit(1) + raise IacScanError(file=__file__, original_exception=error) from error def print_credentials(self): if self.scan_repository_url: 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/kubernetes/services/apiserver/apiserver_service.py b/prowler/providers/kubernetes/services/apiserver/apiserver_service.py index 75f20c5afb..ec247d02bb 100644 --- a/prowler/providers/kubernetes/services/apiserver/apiserver_service.py +++ b/prowler/providers/kubernetes/services/apiserver/apiserver_service.py @@ -12,15 +12,21 @@ class APIServer(KubernetesService): self.apiserver_pods = self._get_apiserver_pods() def _get_apiserver_pods(self): + """Collect the `kube-apiserver` pods from the `kube-system` namespace. + + Returns: + list: Matching pods. Any pods collected before an error are kept, so + the checks always receive an iterable instead of `None`. + """ + apiserver_pods = [] try: - apiserver_pods = [] for pod in self.client.pods.values(): if pod.namespace == "kube-system" and pod.name.startswith( "kube-apiserver" ): apiserver_pods.append(pod) - return apiserver_pods except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + return apiserver_pods diff --git a/prowler/providers/kubernetes/services/controllermanager/controllermanager_service.py b/prowler/providers/kubernetes/services/controllermanager/controllermanager_service.py index 31b342fc7d..f83bae6eb6 100644 --- a/prowler/providers/kubernetes/services/controllermanager/controllermanager_service.py +++ b/prowler/providers/kubernetes/services/controllermanager/controllermanager_service.py @@ -12,15 +12,21 @@ class ControllerManager(KubernetesService): self.controllermanager_pods = self._get_controllermanager_pods() def _get_controllermanager_pods(self): + """Collect the `kube-controller-manager` pods from the `kube-system` namespace. + + Returns: + list: Matching pods. Any pods collected before an error are kept, so + the checks always receive an iterable instead of `None`. + """ + controllermanager_pods = [] try: - controllermanager_pods = [] for pod in self.client.pods.values(): if pod.namespace == "kube-system" and pod.name.startswith( "kube-controller-manager" ): controllermanager_pods.append(pod) - return controllermanager_pods except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + return controllermanager_pods diff --git a/prowler/providers/kubernetes/services/etcd/etcd_service.py b/prowler/providers/kubernetes/services/etcd/etcd_service.py index 1508a0a501..56c8507761 100644 --- a/prowler/providers/kubernetes/services/etcd/etcd_service.py +++ b/prowler/providers/kubernetes/services/etcd/etcd_service.py @@ -12,13 +12,19 @@ class Etcd(KubernetesService): self.etcd_pods = self._get_etcd_pods() def _get_etcd_pods(self): + """Collect the `etcd` pods from the `kube-system` namespace. + + Returns: + list: Matching pods. Any pods collected before an error are kept, so + the checks always receive an iterable instead of `None`. + """ + etcd_pods = [] try: - etcd_pods = [] for pod in self.client.pods.values(): if pod.namespace == "kube-system" and pod.name.startswith("etcd"): etcd_pods.append(pod) - return etcd_pods except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + return etcd_pods diff --git a/prowler/providers/kubernetes/services/kubelet/kubelet_service.py b/prowler/providers/kubernetes/services/kubelet/kubelet_service.py index b5e3227f8d..e7e16ae820 100644 --- a/prowler/providers/kubernetes/services/kubelet/kubelet_service.py +++ b/prowler/providers/kubernetes/services/kubelet/kubelet_service.py @@ -14,16 +14,32 @@ class Kubelet(KubernetesService): self.kubelet_config_maps = self._get_kubelet_config_maps() def _get_kubelet_config_maps(self): + """Collect the ConfigMaps whose name starts with `kubelet-config`. + + Each ConfigMap is parsed independently: one with malformed YAML is + logged and skipped so the remaining ones are still evaluated, and one + without kubelet data is kept with empty arguments so the checks report + the missing settings. The result is always a list. + + Returns: + list: Matching ConfigMaps with their `kubelet_args` parsed. + """ + kubelet_config_maps = [] try: - kubelet_config_maps = [] for cm in self.client.config_maps.values(): - if cm.name.startswith("kubelet-config"): - cm.kubelet_args = yaml.safe_load(cm.data.get("kubelet", "")) - if not cm.kubelet_args: - cm.kubelet_args = [] + if not cm.name.startswith("kubelet-config"): + continue + try: + cm.kubelet_args = yaml.safe_load((cm.data or {}).get("kubelet", "")) + if not isinstance(cm.kubelet_args, dict): + cm.kubelet_args = {} kubelet_config_maps.append(cm) - return kubelet_config_maps + except Exception as error: + logger.error( + f"{cm.namespace}/{cm.name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + return kubelet_config_maps diff --git a/prowler/providers/kubernetes/services/scheduler/scheduler_service.py b/prowler/providers/kubernetes/services/scheduler/scheduler_service.py index 95b442976b..41610234c4 100644 --- a/prowler/providers/kubernetes/services/scheduler/scheduler_service.py +++ b/prowler/providers/kubernetes/services/scheduler/scheduler_service.py @@ -12,15 +12,21 @@ class Scheduler(KubernetesService): self.scheduler_pods = self._get_scheduler_pods() def _get_scheduler_pods(self): + """Collect the `kube-scheduler` pods from the `kube-system` namespace. + + Returns: + list: Matching pods. Any pods collected before an error are kept, so + the checks always receive an iterable instead of `None`. + """ + scheduler_pods = [] try: - scheduler_pods = [] for pod in self.client.pods.values(): if pod.namespace == "kube-system" and pod.name.startswith( "kube-scheduler" ): scheduler_pods.append(pod) - return scheduler_pods except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + return scheduler_pods 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_domain_dmarc_records_published/__init__.py b/prowler/providers/m365/services/defender/defender_domain_dmarc_records_published/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/defender/defender_domain_dmarc_records_published/defender_domain_dmarc_records_published.metadata.json b/prowler/providers/m365/services/defender/defender_domain_dmarc_records_published/defender_domain_dmarc_records_published.metadata.json new file mode 100644 index 0000000000..63bff30a32 --- /dev/null +++ b/prowler/providers/m365/services/defender/defender_domain_dmarc_records_published/defender_domain_dmarc_records_published.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "m365", + "CheckID": "defender_domain_dmarc_records_published", + "CheckTitle": "Exchange Online domain has a DMARC record published with an enforcement policy", + "CheckType": [], + "ServiceName": "defender", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "**Microsoft 365 Exchange Online domains** should publish a **DMARC** DNS TXT record at `_dmarc.` with an enforcement policy (`p=quarantine` or `p=reject`). This evaluates each accepted domain to confirm the published record and that it does not rely solely on the monitoring-only `p=none` policy.", + "Risk": "Without a **DMARC** record, or with one set to `p=none`, receiving mail systems cannot act on messages that fail **SPF**/**DKIM** alignment, enabling **domain spoofing** and **BEC phishing**.\n\nAttackers can impersonate the domain to deliver phishing or malware, and the domain owner loses enforcement over unauthorized senders, impacting **confidentiality** and **integrity**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.rfc-editor.org/rfc/rfc7489", + "https://learn.microsoft.com/en-us/microsoft-365/security/office-365-security/email-authentication-dmarc-configure?view=o365-worldwide" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Publish a DMARC TXT record at _dmarc. such as: v=DMARC1; p=quarantine; rua=mailto:dmarcreports@. 2. Prefer p=reject once monitoring confirms legitimate mail is aligned. 3. Avoid leaving the policy at p=none, which only monitors and does not enforce.", + "Terraform": "" + }, + "Recommendation": { + "Text": "- Publish a DMARC record for every accepted domain with an enforcement policy (`p=quarantine` or `p=reject`)\n- Combine with **SPF** and **DKIM** so messages can be evaluated for alignment\n- Start with `p=quarantine` while monitoring `rua`/`ruf` reports, then move to `p=reject`\n- Review DMARC aggregate reports regularly to catch legitimate senders before enforcing", + "Url": "https://hub.prowler.com/check/defender_domain_dmarc_records_published" + } + }, + "Categories": [ + "email-security", + "e3" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/defender/defender_domain_dmarc_records_published/defender_domain_dmarc_records_published.py b/prowler/providers/m365/services/defender/defender_domain_dmarc_records_published/defender_domain_dmarc_records_published.py new file mode 100644 index 0000000000..e849c7c067 --- /dev/null +++ b/prowler/providers/m365/services/defender/defender_domain_dmarc_records_published/defender_domain_dmarc_records_published.py @@ -0,0 +1,114 @@ +from typing import List, Optional + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.defender.defender_client import defender_client + + +class defender_domain_dmarc_records_published(Check): + """ + Check if DMARC records with an enforcement policy are published for all + Exchange Online domains. + + Attributes: + metadata: Metadata associated with the check (inherited from Check). + """ + + def execute(self) -> List[CheckReportM365]: + """ + Execute the check to verify if DMARC records are published for all domains. + + This method inspects the DMARC DNS TXT record resolved for each domain + (at ``_dmarc.``) and validates that an enforcement policy + (``p=quarantine`` or ``p=reject``) is configured. + + Returns: + List[CheckReportM365]: A list of reports containing the result of the check. + """ + findings = [] + + # If the Graph domain list could not be retrieved, an empty result means + # the DMARC status is unknown for the tenant, not that it has no domains. + if ( + not defender_client.domain_dmarc_configurations + and defender_client.domain_discovery_failed + ): + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name=defender_client.tenant_domain, + resource_id=defender_client.tenant_domain, + ) + report.status = "MANUAL" + report.status_extended = "DMARC records could not be verified because the Exchange Online domain list could not be retrieved; manual review is required." + findings.append(report) + return findings + + for ( + domain_id, + domain, + ) in defender_client.domain_dmarc_configurations.items(): + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name=domain_id, + resource_id=domain_id, + ) + + policy = self._get_dmarc_policy(domain.dmarc_record) + + if domain.lookup_failed: + report.status = "MANUAL" + report.status_extended = f"DMARC record for domain with ID {domain_id} could not be verified because the DNS lookup did not complete; manual review is required." + elif policy in ("quarantine", "reject"): + report.status = "PASS" + report.status_extended = f"DMARC record is published on Exchange Online for domain with ID {domain_id} with enforcement policy p={policy}." + elif policy == "none": + report.status = "FAIL" + report.status_extended = f"DMARC record is published on Exchange Online for domain with ID {domain_id} but uses monitoring-only policy p=none." + elif not domain.dmarc_record: + report.status = "FAIL" + report.status_extended = f"DMARC record is not published on Exchange Online for domain with ID {domain_id}." + else: + report.status = "FAIL" + report.status_extended = f"DMARC record for domain with ID {domain_id} is malformed and does not include a valid enforcement policy." + + findings.append(report) + + return findings + + @staticmethod + def _get_dmarc_policy(record: Optional[str]) -> Optional[str]: + """ + Extract the DMARC policy (``p=``) tag value from a raw DMARC TXT record. + + Args: + record: The raw ``_dmarc.`` TXT record content, or ``None`` + if no record was found. + + Returns: + Optional[str]: The lowercase policy value (e.g. ``"reject"``), or + ``None`` if the record is missing or malformed. Per RFC 7489 the + record must start with an exact ``v=DMARC1`` version tag and the + ``p`` policy tag must immediately follow it. + """ + if not record: + return None + + tags = [tag.strip() for tag in record.split(";") if tag.strip()] + if len(tags) < 2: + return None + + # RFC 7489: the version tag MUST be first and equal to "DMARC1" exactly + # (e.g. "v=DMARC10" is not a DMARC record). + version_name, _, version_value = tags[0].partition("=") + if version_name.strip().lower() != "v" or version_value.strip().lower() != ( + "dmarc1" + ): + return None + + # RFC 7489: the policy tag MUST immediately follow the version tag. + policy_name, _, policy_value = tags[1].partition("=") + if policy_name.strip().lower() != "p": + return None + + return policy_value.strip().lower() 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/defender/defender_service.py b/prowler/providers/m365/services/defender/defender_service.py index 6700bc19d6..f31ee69afa 100644 --- a/prowler/providers/m365/services/defender/defender_service.py +++ b/prowler/providers/m365/services/defender/defender_service.py @@ -1,5 +1,9 @@ +import asyncio from typing import List, Optional +import dns.asyncresolver +import dns.exception +import dns.resolver from pydantic.v1 import BaseModel from prowler.lib.logger import logger @@ -92,6 +96,38 @@ class Defender(M365Service): self.email_tenant_settings = self._get_email_tenant_settings() self.powershell.close() + self.tenant_domain = provider.identity.tenant_domain + self.domain_dmarc_configurations = {} + # Set when the Graph domain list could not be retrieved, so an empty + # result can be told apart from a tenant that genuinely has no domains. + self.domain_discovery_failed = False + + created_loop = False + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + created_loop = True + + if loop.is_closed(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + created_loop = True + + if loop.is_running(): + raise RuntimeError( + "Cannot initialize Defender service while event loop is running" + ) + + self.domain_dmarc_configurations = loop.run_until_complete( + self._get_domain_dmarc_configurations() + ) + + if created_loop: + asyncio.set_event_loop(None) + loop.close() + def _parse_protection_policy_rules(self, rules_data): """Parse preset security policy rules into PresetSecurityPolicyRule models.""" rules = [] @@ -732,6 +768,102 @@ class Defender(M365Service): ) return teams_protection_policy + async def _get_domain_dmarc_configurations(self): + """ + Get the DMARC DNS configuration for each accepted Exchange Online domain. + + Retrieves the list of verified domains from Microsoft Graph and, for each + one, performs a DNS TXT lookup at ``_dmarc.`` to capture the + published DMARC record, if any. + + Returns: + dict[str, DomainDmarcConfiguration]: DMARC configuration keyed by domain name. + """ + logger.info("M365 - Getting Defender domain DMARC configurations...") + domain_dmarc_configurations = {} + try: + verified_domain_ids = [] + domains_list = await self.client.domains.get() + + while domains_list: + for domain in getattr(domains_list, "value", []) or []: + if not domain or not getattr(domain, "is_verified", False): + continue + verified_domain_ids.append(domain.id) + + next_link = getattr(domains_list, "odata_next_link", None) + if not next_link: + break + domains_list = await self.client.domains.with_url(next_link).get() + + dmarc_records = await asyncio.gather( + *( + self._get_dmarc_txt_record(domain_id) + for domain_id in verified_domain_ids + ) + ) + for domain_id, (dmarc_record, lookup_failed) in zip( + verified_domain_ids, dmarc_records + ): + domain_dmarc_configurations[domain_id] = DomainDmarcConfiguration( + domain=domain_id, + dmarc_record=dmarc_record, + lookup_failed=lookup_failed, + ) + except Exception as error: + # The domain list could not be retrieved, so DMARC status is unknown + # for the whole tenant rather than confirmed absent. + self.domain_discovery_failed = True + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return domain_dmarc_configurations + + @staticmethod + async def _get_dmarc_txt_record(domain: str) -> tuple[Optional[str], bool]: + """ + Resolve the DMARC DNS TXT record published at ``_dmarc.``. + + Args: + domain: The domain name to look up the DMARC record for. + + Returns: + tuple[Optional[str], bool]: A ``(record, lookup_failed)`` pair. + ``record`` is the raw content of the first ``v=DMARC1`` TXT + record found, or ``None`` when the domain publishes no DMARC + record or the lookup could not be completed. ``lookup_failed`` + is ``True`` only when the DNS lookup could not be completed + (timeout / no reachable nameserver), distinguishing an + unverifiable result from a confirmed absence. + """ + try: + answers = await dns.asyncresolver.resolve(f"_dmarc.{domain}", "TXT") + for answer in answers: + record = "".join( + part.decode() if isinstance(part, bytes) else part + for part in answer.strings + ) + # RFC 7489: a DMARC record's first tag must be exactly "v=DMARC1" + # ("v=DMARC10" and other TXT records at the name are not DMARC). + name, _, value = record.split(";")[0].partition("=") + if name.strip().lower() == "v" and value.strip().lower() == "dmarc1": + return record, False + except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer): + # The domain resolves but publishes no DMARC record: a confirmed absence. + return None, False + except (dns.resolver.NoNameservers, dns.exception.Timeout) as error: + # The lookup could not be completed, so the DMARC status is unknown. + logger.warning( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return None, True + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return None, True + return None, False + class MalwarePolicy(BaseModel): enable_file_filter: bool @@ -925,6 +1057,23 @@ class TeamsProtectionPolicy(BaseModel): zap_enabled: bool +class DomainDmarcConfiguration(BaseModel): + """Model for a domain's published DMARC DNS TXT record. + + Attributes: + domain: The domain name the DMARC record was looked up for. + dmarc_record: The raw ``_dmarc.`` TXT record content, or + ``None`` if no DMARC record was found. + lookup_failed: ``True`` when the DNS lookup could not be completed + (timeout / no reachable nameserver), so the DMARC status is unknown + rather than a confirmed absence. + """ + + domain: str + dmarc_record: Optional[str] = None + lookup_failed: bool = False + + class PresetSecurityPolicyRule(BaseModel): """Model for a preset security policy rule (EOP or ATP). 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_app_registration_redirect_uris_secure/__init__.py b/prowler/providers/m365/services/entra/entra_app_registration_redirect_uris_secure/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_app_registration_redirect_uris_secure/entra_app_registration_redirect_uris_secure.metadata.json b/prowler/providers/m365/services/entra/entra_app_registration_redirect_uris_secure/entra_app_registration_redirect_uris_secure.metadata.json new file mode 100644 index 0000000000..d75131d6b5 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_app_registration_redirect_uris_secure/entra_app_registration_redirect_uris_secure.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "m365", + "CheckID": "entra_app_registration_redirect_uris_secure", + "CheckTitle": "Secure redirect URIs protect application registration tokens from interception and theft", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Entra ID **application registrations** must use **secure redirect URIs**. This check flags web, SPA and public-client redirect URIs that use plain `http://` on non-loopback hosts, reside on shared platform domains like `azurewebsites.net`, contain wildcards, or are malformed.\n\nTokens are delivered to redirect URIs, so insecure URIs expose them to interception or theft.", + "Risk": "An insecure redirect URI allows an attacker to **intercept authorization codes and tokens** in transit (plain HTTP), **redirect them** to a host they control (wildcard or malformed URI), or **claim a dangling subdomain** on a shared platform domain like `azurewebsites.net` to receive valid tokens for the application's users.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/entra/identity-platform/reply-url", + "https://learn.microsoft.com/azure/security/fundamentals/subdomain-takeover", + "https://learn.microsoft.com/graph/api/resources/application" + ], + "Remediation": { + "Code": { + "CLI": "az ad app update --id \"$APP_ID\" --web-redirect-uris \"https://$OWNED_DOMAIN/signin-oidc\"", + "NativeIaC": "", + "Other": "1. Navigate to Microsoft Entra admin center (https://entra.microsoft.com)\n2. Go to Applications > App registrations\n3. Select the flagged application\n4. Go to Authentication\n5. Replace any `http://` redirect URIs with `https://`\n6. Remove any wildcard URIs and unused or malformed URIs\n7. Move applications off `*.azurewebsites.net` onto a custom domain you own\n8. Delete redirect URIs whose domain no longer resolves in DNS", + "Terraform": "" + }, + "Recommendation": { + "Text": "Use only `https://` redirect URIs pointing to domains you own and control. Remove **wildcard** entries, replace shared platform domains with **custom domains**, and clean up unused or malformed URIs to prevent token theft via redirect manipulation or subdomain takeover.", + "Url": "https://hub.prowler.com/check/entra_app_registration_redirect_uris_secure" + } + }, + "Categories": [ + "internet-exposed", + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "DNS resolution checks for dangling domains are not performed by this check due to network egress requirements, non-deterministic results, and false positives on internal-only hostnames. Custom URI schemes used by public clients (msal://, ms-appx-web://, brk-://, urn:ietf:wg:oauth:2.0:oob, com.company.app://) are excluded from validation. Loopback hosts (localhost, 127.0.0.1, [::1]) are allowed over plain HTTP per Microsoft's platform behaviour." +} diff --git a/prowler/providers/m365/services/entra/entra_app_registration_redirect_uris_secure/entra_app_registration_redirect_uris_secure.py b/prowler/providers/m365/services/entra/entra_app_registration_redirect_uris_secure/entra_app_registration_redirect_uris_secure.py new file mode 100644 index 0000000000..c86b6c9b0a --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_app_registration_redirect_uris_secure/entra_app_registration_redirect_uris_secure.py @@ -0,0 +1,165 @@ +"""Check that application registration redirect URIs are secure.""" + +from urllib.parse import urlparse + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client + +# Custom URI schemes used by public clients that are valid and must not be +# flagged as malformed or checked for DNS resolution. +_PUBLIC_CLIENT_CUSTOM_SCHEME_PREFIXES = ( + "msal", + "ms-appx-web", + "brk-", + "com.", + "msauth.", +) + +# Hosts considered loopback and therefore allowed over plain HTTP. +_LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"} + +# Platform domains susceptible to subdomain takeover. +_PLATFORM_DOMAINS = (".azurewebsites.net",) + + +def _is_custom_scheme_uri(uri: str) -> bool: + """Return True if the URI uses a known custom scheme for public clients. + + Args: + uri: The redirect URI string. + + Returns: + True when the scheme is a recognised custom/native scheme that should + not be validated further. + """ + if ":" not in uri: + return False + # Compare against the scheme only, never against the rest of the URI. + scheme = uri.split(":", 1)[0].lower() + if scheme == "urn": + return True + return any( + scheme.startswith(prefix) for prefix in _PUBLIC_CLIENT_CUSTOM_SCHEME_PREFIXES + ) + + +def _check_uri(uri: str) -> str | None: + """Evaluate a single redirect URI for insecure patterns. + + Args: + uri: The redirect URI string. + + Returns: + A reason string when the URI is insecure, or None when it is safe. + """ + # Wildcards are never legitimate, not even in native schemes, so this + # runs first (and before parsing, since wildcards may break urlparse). + if "*" in uri: + return "contains wildcard" + + # Skip recognised custom schemes (public-client / native). + if _is_custom_scheme_uri(uri): + return None + + try: + parsed = urlparse(uri) + except Exception: + return "malformed URI" + + scheme = (parsed.scheme or "").lower() + host = (parsed.hostname or "").lower() + + # A URI that has no scheme or host is malformed. + if not scheme or not host: + return "malformed URI" + + # urlparse accepts invalid ports; reading .port is what validates them. + try: + parsed.port + except ValueError: + return "malformed URI" + + # Plain HTTP with a non-loopback host. + if scheme == "http" and host not in _LOOPBACK_HOSTS: + return "uses http:// with non-localhost host" + + # Platform domain susceptible to subdomain takeover. + for domain in _PLATFORM_DOMAINS: + if host == domain.lstrip(".") or host.endswith(domain): + return ( + f"host on shared platform domain {domain.lstrip('.')}, " + "subdomain takeover risk if the App Service is removed" + ) + + return None + + +class entra_app_registration_redirect_uris_secure(Check): + """Ensure application registration redirect URIs are secure. + + This check evaluates every application registration in the tenant and + inspects its web, SPA and public-client redirect URIs for insecure + patterns: plain HTTP on non-loopback hosts, hosts on shared platform + domains (e.g. azurewebsites.net), wildcard characters, and malformed URIs. + + - PASS: None of the application's redirect URIs match a FAIL condition, + or the application has no redirect URIs configured. + - FAIL: At least one redirect URI is insecure; every offending URI and its + reason are listed in ``status_extended``. + """ + + def execute(self) -> list[CheckReportM365]: + """Execute the redirect URI security check. + + Returns: + A list of reports containing the result of the check. + """ + findings: list[CheckReportM365] = [] + + for app_id, app in entra_client.app_registrations.items(): + display_name = app.name or app.app_id + report = CheckReportM365( + metadata=self.metadata(), + resource=app, + resource_name=display_name, + resource_id=app_id, + ) + + # Collect all redirect URIs across platforms. + all_uris = ( + app.web_redirect_uris + + app.spa_redirect_uris + + app.public_client_redirect_uris + ) + + # Evaluate each URI. + insecure: list[str] = [] + for uri in all_uris: + reason = _check_uri(uri) + if reason: + insecure.append(f"{uri} ({reason})") + + if insecure: + report.status = "FAIL" + total = len(insecure) + if total > 5: + displayed = ", ".join(insecure[:5]) + displayed += f" (and {total - 5} more)" + else: + displayed = ", ".join(insecure) + report.status_extended = ( + f"App registration {display_name} has {total} insecure " + f"redirect URI(s): {displayed}." + ) + else: + report.status = "PASS" + if all_uris: + report.status_extended = ( + f"App registration {display_name} has all redirect URIs secure." + ) + else: + report.status_extended = f"App registration {display_name} has no redirect URIs configured." + + findings.append(report) + + return findings diff --git a/prowler/providers/m365/services/entra/entra_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_cross_tenant_access_default_outbound_restricted/__init__.py b/prowler/providers/m365/services/entra/entra_cross_tenant_access_default_outbound_restricted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_cross_tenant_access_default_outbound_restricted/entra_cross_tenant_access_default_outbound_restricted.metadata.json b/prowler/providers/m365/services/entra/entra_cross_tenant_access_default_outbound_restricted/entra_cross_tenant_access_default_outbound_restricted.metadata.json new file mode 100644 index 0000000000..0fd7fe46a5 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_cross_tenant_access_default_outbound_restricted/entra_cross_tenant_access_default_outbound_restricted.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "m365", + "CheckID": "entra_cross_tenant_access_default_outbound_restricted", + "CheckTitle": "Default cross-tenant access policy restricts outbound B2B collaboration and direct connect", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The default cross-tenant access policy should **not** allow every user and group to access every application of any external tenant through B2B collaboration outbound or B2B direct connect outbound.\n\nOutbound access should be scoped to specific groups and applications, or blocked by default and opened per partner organization.", + "Risk": "With unrestricted outbound defaults, any user can join any external organization, accept B2B invitations, or use Teams shared channels via direct connect without approval. This widens **data exfiltration paths**, lets attackers lure users into attacker-controlled tenants, and removes control over which external organizations the tenant collaborates with.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/entra/external-id/cross-tenant-access-overview", + "https://learn.microsoft.com/entra/external-id/cross-tenant-access-settings-b2b-collaboration" + ], + "Remediation": { + "Code": { + "CLI": "Invoke-MgGraphRequest -Method PATCH -Uri 'https://graph.microsoft.com/v1.0/policies/crossTenantAccessPolicy/default' -Body '{\"b2bCollaborationOutbound\": {\"usersAndGroups\": {\"accessType\": \"blocked\", \"targets\": [{\"target\": \"AllUsers\", \"targetType\": \"user\"}]}, \"applications\": {\"accessType\": \"blocked\", \"targets\": [{\"target\": \"AllApplications\", \"targetType\": \"application\"}]}}, \"b2bDirectConnectOutbound\": {\"usersAndGroups\": {\"accessType\": \"blocked\", \"targets\": [{\"target\": \"AllUsers\", \"targetType\": \"user\"}]}, \"applications\": {\"accessType\": \"blocked\", \"targets\": [{\"target\": \"AllApplications\", \"targetType\": \"application\"}]}}}'", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **External Identities** > **Cross-tenant access settings**\n3. Select the **Default settings** tab\n4. Under **Outbound access settings**, click **Edit outbound defaults**\n5. On the **B2B collaboration** tab, restrict **Users and groups** and **Applications** to specific selections or set access to **Block**\n6. On the **B2B direct connect** tab, restrict **Users and groups** and **Applications** to specific selections or set access to **Block**\n7. Click **Save**", + "Terraform": "" + }, + "Recommendation": { + "Text": "Restrict default outbound cross-tenant access to specific groups and applications, or block it by default and allow collaboration per partner organization using partner-specific policies.", + "Url": "https://hub.prowler.com/check/entra_cross_tenant_access_default_outbound_restricted" + } + }, + "Categories": [ + "trust-boundaries", + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [ + "entra_policy_guest_invitations_restricted_to_allowed_domains" + ], + "Notes": "" +} diff --git a/prowler/providers/m365/services/entra/entra_cross_tenant_access_default_outbound_restricted/entra_cross_tenant_access_default_outbound_restricted.py b/prowler/providers/m365/services/entra/entra_cross_tenant_access_default_outbound_restricted/entra_cross_tenant_access_default_outbound_restricted.py new file mode 100644 index 0000000000..63c0b2fe01 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_cross_tenant_access_default_outbound_restricted/entra_cross_tenant_access_default_outbound_restricted.py @@ -0,0 +1,161 @@ +from typing import List, Optional, Set + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client +from prowler.providers.m365.services.entra.entra_service import ( + CrossTenantB2BSetting, + CrossTenantTargetConfiguration, +) + +# Targets that cover every user or every application. AllMicrosoftApps is +# treated as broad too: allowing it opens and blocking it closes access to +# every Microsoft cloud application. +ALL_USERS_TARGETS = {"AllUsers"} +ALL_APPLICATIONS_TARGETS = {"AllApplications", "AllMicrosoftApps"} + + +class entra_cross_tenant_access_default_outbound_restricted(Check): + """Ensure default cross-tenant access outbound settings are restricted. + + The default cross-tenant access policy must not allow every user and group + to access every application of any external tenant through B2B collaboration + outbound or B2B direct connect outbound. + + - PASS: Neither b2bCollaborationOutbound nor b2bDirectConnectOutbound is + fully open (allowed for AllUsers AND AllApplications). + - FAIL: At least one of the outbound settings allows all users and all + applications. + - MANUAL: The default policy or one of its outbound settings could not be + read or is incomplete, and no setting is fully open. + """ + + def execute(self) -> List[CheckReportM365]: + """Execute the cross-tenant access default outbound restriction check. + + Evaluates the tenant's default cross-tenant access policy to determine + whether B2B collaboration outbound and/or B2B direct connect outbound + are unrestricted (allowed for all users and all applications). + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + policy = entra_client.cross_tenant_access_default + + report = CheckReportM365( + metadata=self.metadata(), + resource=policy if policy else {}, + resource_name="Cross-Tenant Access Default Policy", + resource_id="crossTenantAccessPolicyConfigurationDefault", + ) + + if policy is None: + report.status = "MANUAL" + report.status_extended = ( + "Cannot evaluate the default cross-tenant access outbound settings: " + "the policy could not be read. " + "Verify that the Policy.Read.All permission is granted to the scanning application." + ) + findings.append(report) + return findings + + collaboration_open = self._is_fully_open(policy.b2b_collaboration_outbound) + direct_connect_open = self._is_fully_open(policy.b2b_direct_connect_outbound) + + unrestricted_settings = [] + if collaboration_open: + unrestricted_settings.append("B2B collaboration outbound") + if direct_connect_open: + unrestricted_settings.append("B2B direct connect outbound") + + if unrestricted_settings: + report.status = "FAIL" + joined = " and ".join(unrestricted_settings) + report.status_extended = ( + f"Default cross-tenant access policy leaves {joined} broadly open: " + f"access is not limited to specific users or groups and to specific " + f"applications in external tenants." + ) + elif collaboration_open is None or direct_connect_open is None: + report.status = "MANUAL" + report.status_extended = ( + "Cannot evaluate the default cross-tenant access outbound settings: " + "the users and groups or applications configuration of an outbound " + "setting is incomplete." + ) + else: + report.status = "PASS" + report.status_extended = ( + "Default cross-tenant access policy restricts outbound access " + "for both B2B collaboration and B2B direct connect." + ) + + findings.append(report) + return findings + + @staticmethod + def _is_fully_open(setting: Optional[CrossTenantB2BSetting]) -> Optional[bool]: + """Determine whether a B2B outbound setting is broadly open. + + In the default policy a ``blocked`` list is an exception list: blocking + specific users, groups or applications leaves everything else allowed. + A dimension is therefore restricted only when it is ``allowed`` for + specific targets or ``blocked`` for everyone. The setting is open when + neither users and groups nor applications are restricted. + + Args: + setting: The B2B setting to evaluate, or None if absent. + + Returns: + True if the setting is broadly open, False if users or applications + are restricted, or None if its configuration is missing or incomplete. + """ + if setting is None: + return None + + configurations = (setting.users_and_groups, setting.applications) + if any( + config is None + or config.access_type not in ("allowed", "blocked") + or not config.targets + for config in configurations + ): + return None + + users_open = ( + entra_cross_tenant_access_default_outbound_restricted._dimension_open( + setting.users_and_groups, + allowed_all_targets=ALL_USERS_TARGETS, + blocked_all_targets=ALL_USERS_TARGETS, + ) + ) + apps_open = ( + entra_cross_tenant_access_default_outbound_restricted._dimension_open( + setting.applications, + allowed_all_targets=ALL_APPLICATIONS_TARGETS, + blocked_all_targets=ALL_APPLICATIONS_TARGETS, + ) + ) + return users_open and apps_open + + @staticmethod + def _dimension_open( + config: CrossTenantTargetConfiguration, + allowed_all_targets: Set[str], + blocked_all_targets: Set[str], + ) -> bool: + """Tell whether one dimension (users or applications) is left open. + + Args: + config: The users-and-groups or applications configuration. + allowed_all_targets: Targets that open the dimension when allowed. + blocked_all_targets: Targets that close the dimension when blocked. + + Returns: + True if everyone (or every application) can still be reached. + """ + targets = {target.target for target in config.targets} + if config.access_type == "allowed": + return bool(targets & allowed_all_targets) + # blocked: only blocking everyone restricts the dimension. + return not targets & blocked_all_targets diff --git a/prowler/providers/m365/services/entra/entra_dynamic_group_member_of_rule_not_used/__init__.py b/prowler/providers/m365/services/entra/entra_dynamic_group_member_of_rule_not_used/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_dynamic_group_member_of_rule_not_used/entra_dynamic_group_member_of_rule_not_used.metadata.json b/prowler/providers/m365/services/entra/entra_dynamic_group_member_of_rule_not_used/entra_dynamic_group_member_of_rule_not_used.metadata.json new file mode 100644 index 0000000000..6019b03cd8 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_dynamic_group_member_of_rule_not_used/entra_dynamic_group_member_of_rule_not_used.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "m365", + "CheckID": "entra_dynamic_group_member_of_rule_not_used", + "CheckTitle": "Dynamic group rules prevent stale access by avoiding the retiring memberOf operator", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "This check verifies that dynamic membership groups in Microsoft Entra ID do not use the **memberOf** rule operator (`user.memberof` / `device.memberof`).\n\nMicrosoft is retiring this public-preview operator on November 3, 2026. Groups that still use it will stop updating and remain frozen in their last known membership.", + "Risk": "A frozen dynamic group retains every user or device from retirement time and never updates membership. Departed or re-roled members keep app assignments, Teams/SharePoint access, group-based licences, and **Conditional Access** targeting (inclusion or exclusion). This creates a silent, growing access-control gap.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/entra/identity/users/groups-dynamic-rule-member-of", + "https://learn.microsoft.com/entra/identity/users/groups-dynamic-membership", + "https://maester.dev/docs/tests/MT.1197" + ], + "Remediation": { + "Code": { + "CLI": "Update-MgGroup -GroupId -MembershipRule '' -MembershipRuleProcessingState On", + "NativeIaC": "", + "Other": "1. Sign in to the Microsoft Entra admin center (https://entra.microsoft.com/)\n2. Navigate to Identity > Groups > All groups\n3. Select the affected dynamic group\n4. Open Dynamic membership rules\n5. Replace the memberOf expression with attribute-based rules (e.g. user.department, user.extensionAttributeN, user.employeeType)\n6. Alternatively, convert the group to Assigned membership under Properties > Membership type\n7. Validate the resulting membership and every app, Conditional Access policy, licence, and SharePoint/Teams assignment that uses the group", + "Terraform": "" + }, + "Recommendation": { + "Text": "Replace the **memberOf** operator in dynamic group rules with attribute-based expressions (e.g. `user.department`, `user.extensionAttributeN`) or convert the group to **Assigned** membership before the operator retirement date. Validate all dependent policies and assignments after migration.", + "Url": "https://hub.prowler.com/check/entra_dynamic_group_member_of_rule_not_used" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [ + "entra_dynamic_group_for_guests_created" + ], + "Notes": "Ported from Maester control MT.1197 (Test-MtDynamicGroupMemberOfRule, MIT licensed). The check does not depend on the current date for PASS/FAIL; the date only changes the wording in status_extended." +} diff --git a/prowler/providers/m365/services/entra/entra_dynamic_group_member_of_rule_not_used/entra_dynamic_group_member_of_rule_not_used.py b/prowler/providers/m365/services/entra/entra_dynamic_group_member_of_rule_not_used/entra_dynamic_group_member_of_rule_not_used.py new file mode 100644 index 0000000000..c808080893 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_dynamic_group_member_of_rule_not_used/entra_dynamic_group_member_of_rule_not_used.py @@ -0,0 +1,157 @@ +"""Check that dynamic membership groups do not use the retiring memberOf rule operator.""" + +import re +from datetime import date +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client + +# Pre-compiled patterns ------------------------------------------------- +# After stripping quoted literals, detect the memberOf property reference. +_MEMBEROF_PROPERTY_RE = re.compile(r"(?i)\b(user|device)\.memberof\b") + +# Extract GUIDs that appear in a memberOf expression (on the original rule). +_GUID_RE = re.compile(r"(?i)\b[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\b") + +# Matches single- or double-quoted string literals to strip before detection. +# Entra rules escape quotes inside literals with a backtick (e.g. "a`"b"). +_QUOTED_LITERAL_RE = re.compile(r"""('(?:`.|[^'`])*'|"(?:`.|[^"`])*")""") + +# The date Microsoft retires the memberOf preview operator. +_RETIREMENT_DATE = date(2026, 11, 3) + + +def _rule_uses_memberof(rule: str) -> bool: + """Return True if *rule* references the memberOf property outside quoted literals. + + Stripping quoted strings first avoids false positives like + ``user.department -eq "user.memberOf"``. + """ + stripped = _QUOTED_LITERAL_RE.sub("", rule) + return bool(_MEMBEROF_PROPERTY_RE.search(stripped)) + + +def _extract_source_group_ids(rule: str) -> List[str]: + """Return deduplicated GUIDs found after a memberOf reference in the original rule. + + GUIDs are collected from the full (unstripped) rule and deduplicated + case-insensitively while preserving the original casing. + """ + # Find the position of the first memberOf property reference. + match = _MEMBEROF_PROPERTY_RE.search(rule) + if not match: + return [] + + after_memberof = rule[match.end() :] + seen: set = set() + result: list = [] + for guid_match in _GUID_RE.finditer(after_memberof): + guid = guid_match.group() + lower = guid.lower() + if lower not in seen: + seen.add(lower) + result.append(guid) + return result + + +class entra_dynamic_group_member_of_rule_not_used(Check): + """Ensure dynamic membership groups do not use the retiring memberOf rule operator. + + This check evaluates every Entra ID group whose ``groupTypes`` contains + ``DynamicMembership``. The ``memberOf`` operator + (``user.memberof`` / ``device.memberof``) is a public preview that + Microsoft is ending on November 3, 2026. After that date, dynamic groups + still using the operator stop updating and stay frozen in their last known + membership, creating a silent access-control gap. + + - PASS: The dynamic group's membership rule does not reference the + ``memberOf`` property, or the group has no membership rule. + - FAIL: The dynamic group's membership rule references ``user.memberof`` + or ``device.memberof``. + - MANUAL: Groups could not be read (permissions, throttling, API error). + """ + + def execute(self) -> List[CheckReportM365]: + """Execute the memberOf rule check for all dynamic membership groups. + + Returns: + A list of reports, one per dynamic membership group evaluated. + """ + findings: List[CheckReportM365] = [] + + # If groups could not be retrieved, emit a single tenant-level MANUAL. + if entra_client.groups_error: + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Dynamic groups", + resource_id="groups", + ) + report.status = "MANUAL" + report.status_extended = ( + f"Cannot evaluate dynamic group memberOf rules: " + f"{entra_client.groups_error}." + ) + findings.append(report) + return findings + + today = date.today() + + for group in entra_client.groups: + if "DynamicMembership" not in group.groupTypes: + continue + + report = CheckReportM365( + metadata=self.metadata(), + resource=group, + resource_name=group.name, + resource_id=group.id, + ) + + # A dynamic group without a rule cannot use the memberOf operator. + if not group.membershipRule: + report.status = "PASS" + report.status_extended = ( + f"Dynamic group {group.name} has no membership rule, so it " + f"does not use the memberOf rule operator." + ) + findings.append(report) + continue + + # Normalise CR/LF to spaces for matching. + normalised_rule = group.membershipRule.replace("\r", " ").replace("\n", " ") + + if _rule_uses_memberof(normalised_rule): + processing_state = group.membership_rule_processing_state or "Unknown" + source_ids = _extract_source_group_ids(normalised_rule) + source_ids_text = ( + f" referencing source group(s): {', '.join(source_ids)}" + if source_ids + else "" + ) + + if today < _RETIREMENT_DATE: + retirement_text = "is being retired on November 3, 2026" + else: + retirement_text = ( + "was retired on November 3, 2026 and the group " + "membership may already be stale" + ) + + report.status = "FAIL" + report.status_extended = ( + f"Dynamic group {group.name} (processing: {processing_state}) " + f"uses the memberOf rule operator which {retirement_text}" + f"{source_ids_text}." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Dynamic group {group.name} does not use the memberOf " + f"rule operator." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/m365/services/entra/entra_policy_admin_self_service_password_reset_disabled/__init__.py b/prowler/providers/m365/services/entra/entra_policy_admin_self_service_password_reset_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_policy_admin_self_service_password_reset_disabled/entra_policy_admin_self_service_password_reset_disabled.metadata.json b/prowler/providers/m365/services/entra/entra_policy_admin_self_service_password_reset_disabled/entra_policy_admin_self_service_password_reset_disabled.metadata.json new file mode 100644 index 0000000000..5e79cf9fe6 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_policy_admin_self_service_password_reset_disabled/entra_policy_admin_self_service_password_reset_disabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "m365", + "CheckID": "entra_policy_admin_self_service_password_reset_disabled", + "CheckTitle": "Disabled Self-Service Password Reset protects administrator accounts from unauthorized recovery", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The tenant authorization policy property `allowedToUseSSPR` controls whether administrators can reset their own passwords through **Self-Service Password Reset (SSPR)**.\n\nWhen disabled, password recovery for privileged accounts must go through other administrators or break-glass procedures, reducing the risk of account takeover via compromised recovery methods.", + "Risk": "If SSPR is enabled for administrators, anyone who controls an admin's registered recovery methods (e.g., **SIM swap**, compromised alternate mailbox, social engineering) can reset the admin password and take over a **privileged account** without going through a controlled recovery process.\n\nThis directly threatens the **confidentiality** and **integrity** of the entire tenant.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/entra/identity/authentication/concept-sspr-policy#administrator-reset-policy-differences", + "https://learn.microsoft.com/graph/api/resources/authorizationpolicy", + "https://learn.microsoft.com/graph/api/authorizationpolicy-get" + ], + "Remediation": { + "Code": { + "CLI": "Update-MgPolicyAuthorizationPolicy -AllowedToUseSspr:$false", + "NativeIaC": "", + "Other": "Administrator SSPR cannot be disabled from the Microsoft Entra admin center: the **Password reset** settings there only apply to non-administrator users. Use Microsoft Graph PowerShell instead:\n1. Connect-MgGraph -Scopes Policy.ReadWrite.Authorization\n2. Update-MgPolicyAuthorizationPolicy -AllowedToUseSspr:$false\n3. Verify with (Get-MgPolicyAuthorizationPolicy).AllowedToUseSspr", + "Terraform": "" + }, + "Recommendation": { + "Text": "Disable **Self-Service Password Reset** for administrator accounts. Privileged account password recovery should be handled by other administrators or **break-glass procedures** to prevent account takeover through compromised recovery methods.\n\nEnsure a documented recovery path exists before disabling SSPR for admins.", + "Url": "https://hub.prowler.com/check/entra_policy_admin_self_service_password_reset_disabled" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "The service default for allowedToUseSSPR is true, so most tenants that never changed it will FAIL. Before disabling, ensure there is a documented recovery path for admin passwords (other admins, break-glass accounts). Requires Policy.ReadWrite.Authorization to remediate." +} diff --git a/prowler/providers/m365/services/entra/entra_policy_admin_self_service_password_reset_disabled/entra_policy_admin_self_service_password_reset_disabled.py b/prowler/providers/m365/services/entra/entra_policy_admin_self_service_password_reset_disabled/entra_policy_admin_self_service_password_reset_disabled.py new file mode 100644 index 0000000000..f1d3a5957f --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_policy_admin_self_service_password_reset_disabled/entra_policy_admin_self_service_password_reset_disabled.py @@ -0,0 +1,64 @@ +"""Check that Self-Service Password Reset is disabled for administrators.""" + +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client + + +class entra_policy_admin_self_service_password_reset_disabled(Check): + """Ensure that Self-Service Password Reset (SSPR) is disabled for administrators. + + This check evaluates the tenant authorization policy to verify that the + ``allowedToUseSSPR`` property is set to ``false``, preventing administrators + from resetting their own passwords through SSPR. + + - PASS: ``allowedToUseSSPR`` is ``false`` (administrators cannot use SSPR). + - FAIL: ``allowedToUseSSPR`` is ``true`` (SSPR is enabled for administrators). + - MANUAL: The authorization policy or the property could not be read. + """ + + def execute(self) -> List[CheckReportM365]: + """Evaluate allowedToUseSSPR on the tenant authorization policy.""" + findings = [] + auth_policy = entra_client.authorization_policy + + if auth_policy is None or auth_policy.allowed_to_use_sspr is None: + report = CheckReportM365( + metadata=self.metadata(), + resource=auth_policy if auth_policy else {}, + resource_name="Authorization Policy", + resource_id="authorizationPolicy", + ) + report.status = "MANUAL" + report.status_extended = ( + "Cannot evaluate whether SSPR is disabled for administrators: the " + "authorization policy or its allowedToUseSSPR setting could not be " + "retrieved. Check the scanning application's permissions and any " + "Microsoft Graph API errors in the scan log." + ) + findings.append(report) + return findings + + report = CheckReportM365( + metadata=self.metadata(), + resource=auth_policy, + resource_name=auth_policy.name, + resource_id=auth_policy.id, + ) + + if not auth_policy.allowed_to_use_sspr: + report.status = "PASS" + report.status_extended = ( + "Self-Service Password Reset (SSPR) is disabled for administrators " + "(allowedToUseSSPR is false in the authorization policy)." + ) + else: + report.status = "FAIL" + report.status_extended = ( + "Self-Service Password Reset (SSPR) is enabled for administrators " + "(allowedToUseSSPR is true in the authorization policy)." + ) + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_policy_msol_powershell_blocked/__init__.py b/prowler/providers/m365/services/entra/entra_policy_msol_powershell_blocked/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_policy_msol_powershell_blocked/entra_policy_msol_powershell_blocked.metadata.json b/prowler/providers/m365/services/entra/entra_policy_msol_powershell_blocked/entra_policy_msol_powershell_blocked.metadata.json new file mode 100644 index 0000000000..0268918f8a --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_policy_msol_powershell_blocked/entra_policy_msol_powershell_blocked.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "m365", + "CheckID": "entra_policy_msol_powershell_blocked", + "CheckTitle": "Tenant blocks the legacy MSOnline (MSOL) PowerShell module", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The tenant authorization policy property `blockMsolPowerShell` controls whether the retired **MSOnline (MSOL) PowerShell** module and its legacy service endpoint are accessible. When set to `true`, user-based access through that endpoint is disabled without affecting Microsoft Entra Connect or Microsoft Graph PowerShell.", + "Risk": "The MSOnline and AzureAD PowerShell modules are **retired** and no longer receive security updates. They use a legacy endpoint that is less monitored than Microsoft Graph, providing an **unsupported administrative channel** to identity data and configuration. Attackers or insiders could exploit this path to read or modify directory objects while bypassing modern controls and audit coverage.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/graph/api/resources/authorizationpolicy?view=graph-rest-1.0", + "https://learn.microsoft.com/powershell/azure/active-directory/overview", + "https://learn.microsoft.com/powershell/microsoftgraph/migration-steps", + "https://maester.dev/docs/tests/MT.1185" + ], + "Remediation": { + "Code": { + "CLI": "Update-MgPolicyAuthorizationPolicy -BodyParameter @{ blockMsolPowerShell = $true }", + "NativeIaC": "", + "Other": "1. Connect to Microsoft Graph PowerShell with the required scope:\n `Connect-MgGraph -Scopes \"Policy.ReadWrite.Authorization\"`\n2. Run:\n `Update-MgPolicyAuthorizationPolicy -BodyParameter @{ blockMsolPowerShell = $true }`\n3. Verify the change:\n `(Get-MgPolicyAuthorizationPolicy).BlockMsolPowerShell`\n\n*Note: There is no Entra admin center toggle for this setting. Before enabling, migrate any remaining scripts from MSOnline/AzureAD to Microsoft Graph PowerShell.*", + "Terraform": "" + }, + "Recommendation": { + "Text": "Block the legacy MSOnline PowerShell endpoint by setting `blockMsolPowerShell` to **true** in the authorization policy. Before enabling, migrate all automation and scripts from the retired MSOnline/AzureAD modules to **Microsoft Graph PowerShell**. This does not affect Microsoft Entra Connect sync or Microsoft Graph-based tooling.", + "Url": "https://hub.prowler.com/check/entra_policy_msol_powershell_blocked" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "The blockMsolPowerShell setting is not enabled by default in every tenant and must be verified explicitly. It does not affect Microsoft Entra Connect or Microsoft Graph PowerShell." +} diff --git a/prowler/providers/m365/services/entra/entra_policy_msol_powershell_blocked/entra_policy_msol_powershell_blocked.py b/prowler/providers/m365/services/entra/entra_policy_msol_powershell_blocked/entra_policy_msol_powershell_blocked.py new file mode 100644 index 0000000000..935235a011 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_policy_msol_powershell_blocked/entra_policy_msol_powershell_blocked.py @@ -0,0 +1,67 @@ +"""Check if legacy MSOnline (MSOL) PowerShell module is blocked in the tenant.""" + +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client + + +class entra_policy_msol_powershell_blocked(Check): + """Ensure legacy MSOnline (MSOL) PowerShell is blocked in the tenant authorization policy. + + This check verifies the `block_msol_powershell` property of the authorization policy. + The MSOnline and AzureAD PowerShell modules are retired and use a legacy endpoint + that is less monitored than Microsoft Graph. Blocking them removes an unsupported + administrative access channel. + + - PASS: blockMsolPowerShell is true. + - FAIL: blockMsolPowerShell is false. + - MANUAL: The authorization policy could not be read or the property is missing/null. + """ + + def execute(self) -> List[CheckReportM365]: + """Execute the MSOL PowerShell blocked check. + + Retrieves the authorization policy from the Microsoft Entra client and checks + whether the 'block_msol_powershell' property is set to true. + + Returns: + List[CheckReportM365]: A list containing a single check report with + the pass/fail/manual status and description. + """ + findings = [] + auth_policy = entra_client.authorization_policy + + if auth_policy is None: + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Authorization Policy", + resource_id="authorizationPolicy", + ) + report.status = "MANUAL" + report.status_extended = "Cannot evaluate whether legacy MSOnline (MSOL) PowerShell is blocked: the authorization policy could not be retrieved. Check the scanning application's permissions and any Microsoft Graph API errors in the scan log." + findings.append(report) + return findings + + report = CheckReportM365( + metadata=self.metadata(), + resource=auth_policy, + resource_name=auth_policy.name, + resource_id=auth_policy.id, + ) + + block_msol = auth_policy.block_msol_powershell + + if block_msol is None: + report.status = "MANUAL" + report.status_extended = "Legacy MSOnline (MSOL) PowerShell blocked status could not be determined from the tenant authorization policy." + elif block_msol: + report.status = "PASS" + report.status_extended = "Legacy MSOnline (MSOL) PowerShell is blocked in the tenant authorization policy." + else: + report.status = "FAIL" + report.status_extended = "Legacy MSOnline (MSOL) PowerShell is not blocked in the tenant authorization policy." + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_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..aaa7072af5 100644 --- a/prowler/providers/m365/services/entra/entra_service.py +++ b/prowler/providers/m365/services/entra/entra_service.py @@ -49,6 +49,7 @@ class Entra(M365Service): oauth_apps (dict): Dictionary of OAuth applications from Defender XDR. authentication_method_configurations (dict): Dictionary of authentication method configurations. service_principals (dict): Dictionary of service principals with credentials and role assignments. + cross_tenant_access_default (CrossTenantAccessDefault): The default cross-tenant access policy configuration. """ def __init__(self, provider: M365Provider): @@ -86,6 +87,14 @@ 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 + # Set when the Microsoft Graph /groups request fails (permissions, + # throttling, mid-pagination error), so checks can emit a MANUAL + # finding instead of silently evaluating an empty group list. + self.groups_error: Optional[str] = None self.exchange_mailbox_permission_service_principals_error: Optional[str] = None attributes = loop.run_until_complete( gather( @@ -110,6 +119,7 @@ class Entra(M365Service): self._get_authentication_methods_policy_settings(), self._get_pim_role_approval_settings(), self._get_access_review_definitions(), + self._get_cross_tenant_access_default(), ) ) @@ -146,6 +156,9 @@ class Entra(M365Service): 19 ] self.access_review_definitions: List[AccessReviewDefinition] = attributes[20] + self.cross_tenant_access_default: Optional[CrossTenantAccessDefault] = ( + attributes[21] + ) self.user_accounts_status = {} # Resolve directory-object identifiers referenced by Conditional Access @@ -223,6 +236,12 @@ class Entra(M365Service): ), guest_invite_settings=auth_policy.allow_invites_from, guest_user_role_id=auth_policy.guest_user_role_id, + allowed_to_use_sspr=getattr( + auth_policy, "allowed_to_use_s_s_p_r", None + ), + block_msol_powershell=getattr( + auth_policy, "block_msol_power_shell", None + ), ) except Exception as error: logger.error( @@ -803,6 +822,14 @@ class Entra(M365Service): ) async def _get_groups(self): + """Retrieve all Entra ID groups via Microsoft Graph. + + Fetches groups with their core properties including dynamic membership + configuration. On any API error (permissions, throttling, + mid-pagination failure) the partial result collected so far is returned + **and** ``self.groups_error`` is set so that checks can emit a MANUAL + finding instead of silently evaluating an incomplete group list. + """ logger.info("Entra - Getting groups...") groups = [] try: @@ -813,6 +840,7 @@ class Entra(M365Service): "displayName", "groupTypes", "membershipRule", + "membershipRuleProcessingState", "isAssignableToRole", "isManagementRestricted", ], @@ -833,6 +861,9 @@ class Entra(M365Service): name=group.display_name, groupTypes=group.group_types or [], membershipRule=group.membership_rule, + membership_rule_processing_state=getattr( + group, "membership_rule_processing_state", None + ), is_assignable_to_role=getattr( group, "is_assignable_to_role", False ) @@ -847,11 +878,25 @@ class Entra(M365Service): next_link = getattr(groups_data, "odata_next_link", None) if not next_link: break - groups_data = await self.client.groups.with_url(next_link).get() + try: + groups_data = await self.client.groups.with_url(next_link).get() + except Exception as pagination_error: + logger.error( + f"{pagination_error.__class__.__name__}[{pagination_error.__traceback__.tb_lineno}]: {pagination_error}" + ) + self.groups_error = ( + f"Groups list may be incomplete due to a pagination error: " + f"{pagination_error.__class__.__name__}" + ) + break except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + self.groups_error = ( + f"Unable to retrieve groups from Microsoft Graph " + f"({error.__class__.__name__})" + ) return groups async def _get_organization(self): @@ -924,7 +969,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 +986,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 +1084,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 +1192,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", "") @@ -1516,6 +1592,86 @@ OAuthAppInfo ) return b2b_policy + async def _get_cross_tenant_access_default(self): + """Retrieve the default cross-tenant access policy configuration. + + Fetches the tenant's default cross-tenant access policy from + ``policies/crossTenantAccessPolicy/default`` to determine whether + B2B collaboration outbound and B2B direct connect outbound settings + are unrestricted (allowed for all users and all applications). + + Returns: + Optional[CrossTenantAccessDefault]: The parsed default cross-tenant + access policy, or None on error. + """ + logger.info("Entra - Getting cross-tenant access default policy...") + cross_tenant_default = None + try: + policy = await self.client.policies.cross_tenant_access_policy.default.get() + if policy: + cross_tenant_default = CrossTenantAccessDefault( + b2b_collaboration_outbound=self._parse_b2b_setting( + policy.b2b_collaboration_outbound + ), + b2b_direct_connect_outbound=self._parse_b2b_setting( + policy.b2b_direct_connect_outbound + ), + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return cross_tenant_default + + @staticmethod + def _parse_b2b_setting(setting) -> Optional["CrossTenantB2BSetting"]: + """Parse a crossTenantAccessPolicyB2BSetting into a model. + + Extracts the ``usersAndGroups`` and ``applications`` target configuration + from a Graph SDK B2B setting object and converts them into a + ``CrossTenantB2BSetting`` model. + + Args: + setting: A Graph SDK ``CrossTenantAccessPolicyB2BSetting`` object, + or None if the setting is absent. + + Returns: + Optional[CrossTenantB2BSetting]: The parsed B2B setting, or None + if the input is None. + """ + if setting is None: + return None + + def parse_target_config(config) -> Optional["CrossTenantTargetConfiguration"]: + """Parse a target configuration (usersAndGroups or applications).""" + if config is None: + return None + access_type = None + if config.access_type is not None: + access_type = config.access_type.value + targets = [] + if config.targets: + for t in config.targets: + target_value = getattr(t, "target", None) + target_type = getattr(t, "target_type", None) + if target_type is not None: + target_type = target_type.value + targets.append( + CrossTenantTarget( + target=target_value, + target_type=target_type, + ) + ) + return CrossTenantTargetConfiguration( + access_type=access_type, + targets=targets, + ) + + return CrossTenantB2BSetting( + users_and_groups=parse_target_config(setting.users_and_groups), + applications=parse_target_config(setting.applications), + ) + async def _get_named_locations(self): """Retrieve Conditional Access named locations from Microsoft Entra. @@ -2081,11 +2237,34 @@ OAuthAppInfo ) ) + # Extract redirect URIs from web, spa and publicClient + web_obj = getattr(app, "web", None) + web_redirect_uris = ( + list(getattr(web_obj, "redirect_uris", []) or []) + if web_obj + else [] + ) + spa_obj = getattr(app, "spa", None) + spa_redirect_uris = ( + list(getattr(spa_obj, "redirect_uris", []) or []) + if spa_obj + else [] + ) + public_client_obj = getattr(app, "public_client", None) + public_client_redirect_uris = ( + list(getattr(public_client_obj, "redirect_uris", []) or []) + if public_client_obj + else [] + ) + app_registrations[object_id] = AppRegistration( id=object_id, app_id=app_id, name=getattr(app, "display_name", "") or "", password_credentials=password_credentials, + web_redirect_uris=web_redirect_uris, + spa_redirect_uris=spa_redirect_uris, + public_client_redirect_uris=public_client_redirect_uris, ) next_link = getattr(app_response, "odata_next_link", None) @@ -2475,12 +2654,16 @@ class DefaultUserRolePermissions(BaseModel): class AuthorizationPolicy(BaseModel): + """Tenant-wide authorization policy from Microsoft Entra ID.""" + id: str name: str description: str default_user_role_permissions: Optional[DefaultUserRolePermissions] guest_invite_settings: Optional[str] guest_user_role_id: Optional[UUID] + allowed_to_use_sspr: Optional[bool] = None + block_msol_powershell: Optional[bool] = None # Well-known directory setting template IDs (from /groupSettings). @@ -2608,10 +2791,13 @@ class AuthenticationMethodConfiguration(BaseModel): class Group(BaseModel): + """Model representing a Microsoft Entra ID group.""" + id: str name: str groupTypes: List[str] membershipRule: Optional[str] + membership_rule_processing_state: Optional[str] = None is_assignable_to_role: bool = False is_management_restricted: bool = False @@ -2894,9 +3080,69 @@ class AppRegistration(BaseModel): name: The application's display name. password_credentials: List of password credentials (client secrets) registered on the application. + web_redirect_uris: Redirect URIs configured for the web platform. + spa_redirect_uris: Redirect URIs configured for the SPA platform. + public_client_redirect_uris: Redirect URIs configured for the + public client (mobile/desktop) platform. """ id: str app_id: str = "" name: str = "" password_credentials: List[PasswordCredential] = [] + web_redirect_uris: List[str] = [] + spa_redirect_uris: List[str] = [] + public_client_redirect_uris: List[str] = [] + + +class CrossTenantTarget(BaseModel): + """A single target entry in a cross-tenant access target configuration. + + Attributes: + target: The target identifier (e.g. ``AllUsers``, ``AllApplications``, + or a specific group/application ID). + target_type: The type of the target (e.g. ``user``, ``application``). + """ + + target: Optional[str] = None + target_type: Optional[str] = None + + +class CrossTenantTargetConfiguration(BaseModel): + """Target configuration for users/groups or applications in a B2B setting. + + Attributes: + access_type: The access type (``allowed``, ``blocked``, or + ``unknownFutureValue``). None if not set. + targets: List of targets for this configuration. + """ + + access_type: Optional[str] = None + targets: List[CrossTenantTarget] = [] + + +class CrossTenantB2BSetting(BaseModel): + """A B2B setting (collaboration or direct connect) within the cross-tenant policy. + + Attributes: + users_and_groups: Target configuration for users and groups. + applications: Target configuration for applications. + """ + + users_and_groups: Optional[CrossTenantTargetConfiguration] = None + applications: Optional[CrossTenantTargetConfiguration] = None + + +class CrossTenantAccessDefault(BaseModel): + """Default cross-tenant access policy configuration. + + Represents the tenant's default cross-tenant access policy, specifically + the B2B collaboration outbound and B2B direct connect outbound settings. + + Attributes: + b2b_collaboration_outbound: The outbound B2B collaboration setting. + b2b_direct_connect_outbound: The outbound B2B direct connect setting. + """ + + b2b_collaboration_outbound: Optional[CrossTenantB2BSetting] = None + b2b_direct_connect_outbound: Optional[CrossTenantB2BSetting] = None diff --git a/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/prowler/providers/oraclecloud/oraclecloud_provider.py b/prowler/providers/oraclecloud/oraclecloud_provider.py index a4794f6269..932333ee74 100644 --- a/prowler/providers/oraclecloud/oraclecloud_provider.py +++ b/prowler/providers/oraclecloud/oraclecloud_provider.py @@ -89,6 +89,7 @@ class OraclecloudProvider(Provider): key_content: str = None, tenancy: str = None, pass_phrase: str = None, + home_region: str = None, ): """ Initializes the OCI provider. @@ -110,6 +111,7 @@ class OraclecloudProvider(Provider): - key_content: Content of the private key (base64 encoded). - tenancy: The OCID of the tenancy. - pass_phrase: The passphrase for the private key, if encrypted. + - home_region: Region used to bootstrap identity calls with API key credentials; it does not filter the audited regions. Raises: - OCISetUpSessionError: If an error occurs during the setup process. @@ -140,7 +142,7 @@ class OraclecloudProvider(Provider): ) has_direct_credentials = user and fingerprint and tenancy bootstrap_region = single_region or ( - self._bootstrap_region if has_direct_credentials else None + (home_region or self._bootstrap_region) if has_direct_credentials else None ) # Setup OCI Session diff --git a/prowler/providers/stackit/services/ske/__init__.py b/prowler/providers/stackit/services/ske/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/services/ske/ske_client.py b/prowler/providers/stackit/services/ske/ske_client.py new file mode 100644 index 0000000000..f1b53cc544 --- /dev/null +++ b/prowler/providers/stackit/services/ske/ske_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.stackit.services.ske.ske_service import SKEService + +ske_client = SKEService(Provider.get_global_provider()) diff --git a/prowler/providers/stackit/services/ske/ske_cluster_no_public_endpoint/__init__.py b/prowler/providers/stackit/services/ske/ske_cluster_no_public_endpoint/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/services/ske/ske_cluster_no_public_endpoint/ske_cluster_no_public_endpoint.metadata.json b/prowler/providers/stackit/services/ske/ske_cluster_no_public_endpoint/ske_cluster_no_public_endpoint.metadata.json new file mode 100644 index 0000000000..e9ee504d3d --- /dev/null +++ b/prowler/providers/stackit/services/ske/ske_cluster_no_public_endpoint/ske_cluster_no_public_endpoint.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "stackit", + "CheckID": "ske_cluster_no_public_endpoint", + "CheckTitle": "SKE clusters do not expose their Kubernetes API endpoint to the internet", + "CheckType": [], + "ServiceName": "ske", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "container", + "Description": "The **Kubernetes API server** of an SKE cluster should not be reachable from the entire internet. Enable the **ACL extension** to restrict access to a known set of source CIDRs, or run the control plane inside a **STACKIT Network Area (SNA)** so that it is not published on the internet at all.", + "Risk": "A publicly reachable **Kubernetes API server** lets anyone on the internet attempt authentication, exploit unpatched control plane vulnerabilities, or brute-force credentials. Combined with leaked or weak credentials, this can lead to **full cluster takeover**, workload tampering, and data exfiltration.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.stackit.cloud/products/runtime/kubernetes-engine/how-tos/enhance-the-security-of-your-cluster/", + "https://docs.stackit.cloud/products/runtime/kubernetes-engine/how-tos/enable-private-clusters/", + "https://docs.stackit.cloud/products/runtime/kubernetes-engine/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. In the STACKIT Portal open Kubernetes Engine (SKE) and select the affected cluster. 2. Open the cluster configuration and enable the ACL extension. 3. Add only the source CIDRs that must reach the Kubernetes API (for example your office or VPN egress ranges) and make sure 0.0.0.0/0 and ::/0 are not present. 4. Save the cluster configuration and re-run Prowler to confirm the finding is resolved.", + "Terraform": "" + }, + "Recommendation": { + "Text": "**Restrict access to the Kubernetes API.** Enable the **SKE ACL extension** and allowlist only the source CIDRs that legitimately need to reach the API server, never `0.0.0.0/0` or `::/0`. Where available, place the control plane in a **STACKIT Network Area** so the endpoint is not exposed to the internet.", + "Url": "https://hub.prowler.com/check/ske_cluster_no_public_endpoint" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "A cluster passes when its control plane access scope is SNA, or when the ACL extension is enabled and its allowed CIDR list contains no unrestricted range. A cluster with the ACL extension disabled is reported as FAIL because the Kubernetes API accepts connections from any source address. The SNA private control plane is not generally available yet, so most clusters are expected to rely on the ACL extension." +} diff --git a/prowler/providers/stackit/services/ske/ske_cluster_no_public_endpoint/ske_cluster_no_public_endpoint.py b/prowler/providers/stackit/services/ske/ske_cluster_no_public_endpoint/ske_cluster_no_public_endpoint.py new file mode 100644 index 0000000000..f079ed0e4b --- /dev/null +++ b/prowler/providers/stackit/services/ske/ske_cluster_no_public_endpoint/ske_cluster_no_public_endpoint.py @@ -0,0 +1,61 @@ +from prowler.lib.check.models import Check, CheckReportStackIT +from prowler.providers.stackit.services.ske.ske_client import ske_client + + +class ske_cluster_no_public_endpoint(Check): + """ + Check if SKE clusters expose their Kubernetes API endpoint to the internet. + + A cluster passes when its control plane is confined to a STACKIT Network + Area, or when the ACL extension restricts the Kubernetes API to a set of + source CIDRs that does not include an unrestricted range. + """ + + def execute(self) -> list[CheckReportStackIT]: + """ + Execute the check for all SKE clusters in the StackIT project. + + Returns: + list: A list of CheckReportStackIT findings + """ + findings = [] + + for cluster in ske_client.clusters: + report = CheckReportStackIT( + metadata=self.metadata(), + resource=cluster, + ) + + if cluster.has_public_endpoint(): + report.status = "FAIL" + unrestricted_cidrs = cluster.unrestricted_cidrs() + if unrestricted_cidrs: + report.status_extended = ( + f"SKE cluster {cluster.name} exposes its Kubernetes API " + f"endpoint to the internet because its ACL allows " + f"unrestricted access from {', '.join(unrestricted_cidrs)}." + ) + else: + report.status_extended = ( + f"SKE cluster {cluster.name} exposes its Kubernetes API " + f"endpoint to the internet because the ACL extension is " + f"not enabled." + ) + else: + report.status = "PASS" + if cluster.has_private_control_plane(): + report.status_extended = ( + f"SKE cluster {cluster.name} has a private control plane " + f"and its Kubernetes API endpoint is not reachable from " + f"the internet." + ) + else: + report.status_extended = ( + f"SKE cluster {cluster.name} restricts access to its " + f"Kubernetes API endpoint to " + f"{len(cluster.allowed_cidrs)} allowed CIDR(s)." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/stackit/services/ske/ske_service.py b/prowler/providers/stackit/services/ske/ske_service.py new file mode 100644 index 0000000000..02389d60f6 --- /dev/null +++ b/prowler/providers/stackit/services/ske/ske_service.py @@ -0,0 +1,305 @@ +import json +from typing import Optional + +from pydantic.v1 import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.stackit.stackit_provider import StackitProvider, suppress_stderr + +# Source ranges that place no restriction on who may reach the Kubernetes API. +UNRESTRICTED_CIDRS = frozenset({"0.0.0.0/0", "::/0"}) + +# Control plane access scope that keeps the Kubernetes API inside a STACKIT +# Network Area instead of publishing it on the internet. +PRIVATE_ACCESS_SCOPE = "SNA" + + +class SKEService: + """ + StackIT Kubernetes Engine (SKE) service class to handle cluster operations. + + This service uses the StackIT Python SDK to access SKE resources. + Authentication is delegated to the SDK, which signs the RSA challenge + in the configured service account key and refreshes access tokens + internally for the life of the scan. + """ + + def __init__(self, provider: StackitProvider): + """ + Initialize the SKE service. + + Args: + provider: The StackIT provider instance + """ + self.provider = provider + self.project_id = provider.identity.project_id + self.service_account_key_path = provider.session.get("service_account_key_path") + + # Generate regional clients (AWS pattern) + self.regional_clients = provider.generate_regional_clients("ske") + self.audited_regions = provider.identity.audited_regions + + # Initialize cluster list + self.clusters: list[Cluster] = [] + + # Fetch resources from all regions + self._fetch_all_regions() + + def _fetch_all_regions(self): + """Fetch SKE clusters from all audited regions. + + A project is not necessarily provisioned in every StackIT region. A + region where the project does not exist answers the SKE endpoints with + HTTP 404 (``resource not found: project``). That is expected, so the + region is skipped and the scan continues with the remaining regions + instead of aborting. + + A project that enabled SKE in some regions but not others answers the + remaining ones with HTTP 403 ``Service not enabled``; that region is + skipped too. Genuine credential and permission failures still propagate + via ``handle_api_error`` so a misconfigured account fails loudly. + """ + for region, client in self.regional_clients.items(): + try: + self._list_clusters(client, region) + except Exception as error: + if getattr(error, "status", None) == 404: + logger.info( + f"StackIT project {self.project_id} has no SKE presence " + f"in region {region} (404 resource not found); skipping " + f"this region." + ) + continue + if self._is_service_not_enabled(error): + logger.info( + f"StackIT project {self.project_id} has SKE disabled in " + f"region {region} (403 service not enabled); skipping " + f"this region." + ) + continue + raise + + @staticmethod + def _extract_items(response, endpoint_name: str) -> list: + """Extract the items list from a StackIT SDK response. + + Handles three response shapes safely: + - SDK model exposing an ``items`` attribute (not the ``dict.items`` method) + - Raw ``dict`` with an ``"items"`` key + - Plain ``list`` + + ``isinstance(response, dict)`` is checked first because ``dict`` has an + ``items`` *method*; ``hasattr(response, "items")`` is otherwise True for + plain dicts and silently returns the bound method. + """ + if isinstance(response, dict): + return response.get("items", []) + if isinstance(response, list): + return response + items_attr = getattr(response, "items", None) + if items_attr is not None and not callable(items_attr): + return items_attr + logger.warning( + f"Unexpected response type from {endpoint_name}: {type(response)}" + ) + return [] + + @staticmethod + def _get_field(item, *keys, default=None): + """Read a field from an SDK model (attribute) or a raw ``dict`` (key). + + ``_extract_items`` yields either SDK models or raw dicts, so the nested + cluster parsing must read fields from both shapes. Multiple key aliases + are accepted so snake_case SDK attributes and camelCase API/dict keys + are both supported (e.g. ``allowed_cidrs`` / ``allowedCidrs``). ``None`` + items return ``default`` so nested lookups can be chained safely. + Returns the first non-None match, otherwise ``default``. + """ + if item is None: + return default + if isinstance(item, dict): + for key in keys: + value = item.get(key) + if value is not None: + return value + return default + for key in keys: + value = getattr(item, key, None) + if value is not None: + return value + return default + + @staticmethod + def _is_service_not_enabled(error) -> bool: + """Return True for the 403 SKE returns in a region that never enabled it. + + Status alone cannot separate this from a missing IAM role -- both are + 403 -- so the body message is the only discriminator. + """ + if getattr(error, "status", None) != 403: + return False + body = getattr(error, "body", None) + if body is None: + return False + if isinstance(body, bytes): + body = body.decode("utf-8", errors="replace") + message = "" + if isinstance(body, str): + try: + message = json.loads(body).get("message", "") + except (ValueError, AttributeError): + message = body + elif isinstance(body, dict): + message = body.get("message", "") + return "service not enabled" in str(message).lower() + + def _handle_api_call(self, api_function, *args, **kwargs): + """ + Centralized API call handler with authentication error detection. + + Args: + api_function: The API function to call + *args: Positional arguments to pass to the API function + **kwargs: Keyword arguments to pass to the API function + + Returns: + The API response + + Raises: + StackITInvalidTokenError: If authentication fails (401) + """ + try: + # Suppress StackIT SDK stderr messages during API calls + with suppress_stderr(): + return api_function(*args, **kwargs) + except Exception as e: + # A region that never enabled SKE also answers 403; handing it to + # handle_api_error would abort the scan as a credentials failure + # instead of letting _fetch_all_regions skip the region. + if self._is_service_not_enabled(e): + raise + # Use centralized error handler from provider + self.provider.handle_api_error(e) + raise + + @classmethod + def _parse_access_scope(cls, cluster_data) -> Optional[str]: + """Return the control plane access scope of a cluster, if declared. + + The scope lives at ``network.controlPlane.accessScope``. Every level of + that chain is optional because the private (``SNA``) control plane is an + opt-in preview feature, so most clusters omit it entirely. ``None`` is + returned in that case and the caller falls back to the ACL extension. + """ + network = cls._get_field(cluster_data, "network") + control_plane = cls._get_field(network, "control_plane", "controlPlane") + access_scope = cls._get_field(control_plane, "access_scope", "accessScope") + if access_scope is None: + return None + # ``AccessScope`` is a ``str`` Enum, whose ``str()`` renders as + # "AccessScope.SNA" rather than the "SNA" wire value. + return str(getattr(access_scope, "value", access_scope)) + + @classmethod + def _parse_acl(cls, cluster_data) -> tuple[bool, list[str]]: + """Return the ``(enabled, allowed_cidrs)`` pair of the cluster ACL extension. + + The ACL extension restricts which source CIDRs may reach the Kubernetes + API and lives at ``extensions.acl``. When the extension is absent the + API server accepts connections from any address, which is reported here + as ``(False, [])``. + """ + extensions = cls._get_field(cluster_data, "extensions") + acl = cls._get_field(extensions, "acl") + enabled = bool(cls._get_field(acl, "enabled", default=False)) + allowed_cidrs = ( + cls._get_field(acl, "allowed_cidrs", "allowedCidrs", default=[]) or [] + ) + return enabled, [str(cidr) for cidr in allowed_cidrs] + + def _list_clusters(self, client, region: str): + """ + List all SKE clusters in the StackIT project for a single region. + + Populates ``self.clusters`` with :class:`Cluster` objects describing the + control plane access scope and the ACL extension of each cluster. + """ + if not client: + logger.warning( + f"Cannot list SKE clusters in {region}: StackIT SKE client not available" + ) + return + + response = self._handle_api_call( + client.list_clusters, project_id=self.project_id, region=region + ) + + clusters_list = self._extract_items(response, "list_clusters") + + for cluster_data in clusters_list: + try: + cluster_name = str(self._get_field(cluster_data, "name") or "") + acl_enabled, allowed_cidrs = self._parse_acl(cluster_data) + cluster = Cluster( + # SKE addresses a cluster by its name; the API exposes no + # separate identifier, so the name doubles as the id. + id=cluster_name, + name=cluster_name, + project_id=self.project_id, + region=region, + access_scope=self._parse_access_scope(cluster_data), + acl_enabled=acl_enabled, + allowed_cidrs=allowed_cidrs, + ) + self.clusters.append(cluster) + except Exception as e: + logger.error(f"Error processing SKE cluster: {e}") + continue + + logger.info( + f"Successfully listed {len(clusters_list)} SKE clusters in {region}" + ) + + +class Cluster(BaseModel): + """ + Represents a StackIT SKE Cluster. + + Attributes: + id: The unique identifier of the cluster (SKE uses the cluster name) + name: The name of the cluster + project_id: The StackIT project ID containing the cluster + region: The region where the cluster runs + access_scope: Control plane access scope ("PUBLIC"/"SNA"), None when unset + acl_enabled: Whether the ACL extension restricting API access is enabled + allowed_cidrs: Source CIDRs allowed to reach the Kubernetes API + """ + + id: str + name: str + project_id: str + region: str + access_scope: Optional[str] = None + acl_enabled: bool = False + allowed_cidrs: list[str] = [] + + def has_private_control_plane(self) -> bool: + """Check whether the control plane is confined to a STACKIT Network Area.""" + return self.access_scope == PRIVATE_ACCESS_SCOPE + + def unrestricted_cidrs(self) -> list[str]: + """Return the allowed CIDRs that permit access from any source address.""" + return [cidr for cidr in self.allowed_cidrs if cidr in UNRESTRICTED_CIDRS] + + def has_public_endpoint(self) -> bool: + """Check whether the Kubernetes API endpoint is reachable from the internet. + + A cluster is publicly reachable when its control plane is not confined to + a STACKIT Network Area and either the ACL extension is disabled (no source + restriction at all) or the allowlist itself contains an unrestricted range. + """ + if self.has_private_control_plane(): + return False + if not self.acl_enabled: + return True + return bool(self.unrestricted_cidrs()) diff --git a/prowler/providers/stackit/stackit_provider.py b/prowler/providers/stackit/stackit_provider.py index 4c34afd31b..66af79ce84 100644 --- a/prowler/providers/stackit/stackit_provider.py +++ b/prowler/providers/stackit/stackit_provider.py @@ -17,6 +17,7 @@ from stackit.core.configuration import Configuration from stackit.iaas import DefaultApi as IaasDefaultApi from stackit.objectstorage import DefaultApi as ObjectStorageDefaultApi from stackit.resourcemanager import DefaultApi as ResourceManagerDefaultApi +from stackit.ske import DefaultApi as SkeDefaultApi from prowler.config.config import ( default_config_file_path, @@ -228,6 +229,7 @@ class StackitProvider(Provider): _SERVICE_API_CLASS = { "iaas": IaasDefaultApi, "objectstorage": ObjectStorageDefaultApi, + "ske": SkeDefaultApi, } def generate_regional_clients(self, service: str = "iaas") -> dict: @@ -557,10 +559,11 @@ class StackitProvider(Provider): """ Test connection to StackIT by validating credentials. - This method validates the service account credentials and project ID - by making a Resource Manager ``get_project`` call. Pass either the - key file path or the inline key content; the SDK signs the RSA - challenge and mints a short-lived access token internally. + This method probes Resource Manager with a ``get_project`` call. A 403 + response is non-fatal because service-specific permissions are checked + during discovery. Pass either the key file path or the inline key + content; the SDK signs the RSA challenge and mints a short-lived access + token internally. Args: project_id (str): StackIT project ID @@ -600,6 +603,13 @@ class StackitProvider(Provider): raise error return Connection(error=error) except Exception as test_error: + if getattr(test_error, "status", None) == 403: + logger.warning( + "StackIT test_connection: Resource Manager access could not be " + "verified (403). Service permissions will be checked during " + "discovery." + ) + return Connection(is_connected=True) try: StackitProvider.handle_api_error(test_error) except StackITInvalidTokenError as auth_error: diff --git a/prowler/providers/stackit/stackit_regions_by_service.json b/prowler/providers/stackit/stackit_regions_by_service.json index 5841d4e32d..2f662b7475 100644 --- a/prowler/providers/stackit/stackit_regions_by_service.json +++ b/prowler/providers/stackit/stackit_regions_by_service.json @@ -11,6 +11,12 @@ "eu01", "eu02" ] + }, + "ske": { + "regions": [ + "eu01", + "eu02" + ] } } } diff --git a/pyproject.toml b/pyproject.toml index 4d730c5364..a2173c3ac7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [build-system] build-backend = "hatchling.build" -requires = ["hatchling"] +requires = ["hatchling==1.32.0"] [dependency-groups] dev = [ @@ -68,11 +68,15 @@ dependencies = [ "boto3==1.40.61", "botocore==1.40.61", "colorama==0.4.6", + # cryptography 50 needs alibabacloud-tea-openapi>=0.4.6, oci>=2.184.1 and, in the + # [tool.uv] pins, msal>=1.37.0 and pyopenssl>=26.4.0: earlier releases cap it below 49 + # or 50. Keep the five in step. Never widen a cap with [tool.uv] override-dependencies: + # overrides do not ship in the wheel, and 5.38.0 was uninstallable with pip because of one. "cryptography==50.0.0", "dash==3.1.1", "dash-bootstrap-components==2.0.3", "defusedxml==0.7.1", - "dulwich==1.2.5", + "dulwich==1.2.9", "google-api-python-client==2.163.0", "google-auth-httplib2==0.2.0", "jsonschema==4.23.0", @@ -98,15 +102,17 @@ dependencies = [ "stackit-iaas==1.4.0", "stackit-objectstorage==1.4.0", "stackit-resourcemanager==0.8.0", + "stackit-ske==1.12.0", "tabulate==0.9.0", + "truststore==0.10.4", "tzlocal==5.3.1", "uuid6==2024.7.10", "py-iam-expand==0.3.0", "h2==4.3.0", - "oci==2.183.0", + "oci==2.184.1", "alibabacloud_credentials==1.0.3", "alibabacloud_ram20150501==1.2.0", - "alibabacloud_tea_openapi==0.4.5", + "alibabacloud_tea_openapi==0.4.6", "alibabacloud_sts20150401==1.1.6", "alibabacloud_vpc20160428==6.13.0", "alibabacloud_ecs20140526==7.2.5", @@ -127,8 +133,10 @@ 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" + "huaweicloudsdkwaf==3.1.204", + "zstandard==0.25.0" ] description = "Prowler is an Open Source security tool to perform AWS, GCP and Azure security best practices assessments, audits, incident response, continuous monitoring, hardening and forensics readiness. It contains hundreds of controls covering CIS, NIST 800, NIST CSF, CISA, RBI, FedRAMP, PCI-DSS, GDPR, HIPAA, FFIEC, SOC2, GXP, AWS Well-Architected Framework Security Pillar, AWS Foundational Technical Review (FTR), ENS (Spanish National Security Scheme) and your custom security frameworks." license = "Apache-2.0" @@ -136,7 +144,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"} name = "prowler" readme = "README.md" requires-python = ">=3.10,<3.14" -version = "5.39.0" +version = "5.45.0" [project.scripts] prowler = "prowler.__main__:prowler" @@ -199,14 +207,14 @@ constraint-dependencies = [ "alibabacloud-sas20181203==6.1.0", "alibabacloud-sts20150401==1.1.6", "alibabacloud-tea==0.4.3", - "alibabacloud-tea-openapi==0.4.5", + "alibabacloud-tea-openapi==0.4.6", "alibabacloud-tea-util==0.3.14", "alibabacloud-tea-xml==0.0.3", "alibabacloud-vpc20160428==6.13.0", "aliyun-log-fastpb==0.3.0", "annotated-types==0.7.0", "antlr4-python3-runtime==4.13.2", - "anyio==4.13.0", + "anyio==4.14.2", "apscheduler==3.11.2", "astroid==3.3.11", "async-timeout==5.0.1", @@ -264,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", @@ -300,7 +309,7 @@ constraint-dependencies = [ "mock==5.2.0", "moto==5.1.11", "mpmath==1.3.0", - "msal==1.36.0", + "msal==1.37.0", "msal-extensions==1.3.1", "msgraph-core==1.3.8", "msrest==0.7.1", @@ -340,10 +349,10 @@ constraint-dependencies = [ "pydash==8.0.6", "pyflakes==3.2.0", "pygments==2.20.0", - "pyjwt==2.13.0", + "pyjwt==2.14.0", "pylint==3.3.4", "pynacl==1.6.2", - "pyopenssl==26.2.0", + "pyopenssl==26.4.0", "pyparsing==3.3.2", "pytest==9.0.3", "pytest-cov==6.0.0", @@ -378,7 +387,7 @@ constraint-dependencies = [ "typing-inspection==0.4.2", "tzdata==2026.2", "uritemplate==4.2.0", - "urllib3==2.7.0", + "urllib3==2.8.0", "vulture==2.14", "websocket-client==1.9.0", "werkzeug==3.1.8", @@ -387,12 +396,14 @@ constraint-dependencies = [ "xmltodict==1.0.4", "yarl==1.23.0", "zipp==3.23.1", - "zstd==1.5.7.3" + "zstd==1.5.7.2" ] override-dependencies = [ "okta==3.4.2", - # alibabacloud-tea-openapi 0.4.5 caps cryptography below 49 and is the latest release. - "cryptography==50.0.0", + # huaweicloudsdkcore pins pymongo==4.15.1 exactly (still so in 3.1.216) and only uses + # its bson types. The container installs from this lock, so the override reaches the + # image; a pip install of the wheel still resolves 4.15.1 through the huawei pin. + "pymongo==4.18.2" ] [tool.vulture] diff --git a/skills/prowler-mcp/SKILL.md b/skills/prowler-mcp/SKILL.md index c98f6a886f..f23243e3ff 100644 --- a/skills/prowler-mcp/SKILL.md +++ b/skills/prowler-mcp/SKILL.md @@ -69,7 +69,11 @@ Use `@mcp.tool()` decorator directly—no BaseTool or models required. - [ ] Tool docstrings describe LLM-relevant behavior - [ ] Models use `MinimalSerializerMixin` - [ ] API responses transformed to simplified models -- [ ] Error handling returns `{"error": str, "status": "failed"}` +- [ ] Failures are **raised**, never returned. A returned error dict is reported + as a success. Raise `InvalidArgument` for a bad argument, let + `ProwlerAPIError`/`ProwlerAPIUnreachable` propagate, and raise `ToolError` + **without a `from` clause** only for a sentence `lib/errors.py` cannot know + (a resource name, a precondition, the next tool to call) - [ ] Parameters use `Field()` with descriptions - [ ] No hardcoded secrets - [ ] Tests added under `mcp_server/tests/` 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/check/universal_compliance_models_test.py b/tests/lib/check/universal_compliance_models_test.py index c8f614488c..a6ca74c66a 100644 --- a/tests/lib/check/universal_compliance_models_test.py +++ b/tests/lib/check/universal_compliance_models_test.py @@ -26,6 +26,7 @@ from prowler.lib.check.compliance_models import ( UniversalComplianceRequirement, adapt_legacy_to_universal, get_bulk_compliance_frameworks_universal, + get_universal_compliance_entry_point_dirs, load_compliance_framework_universal, ) from tests.lib.outputs.compliance.fixtures import ( @@ -891,6 +892,60 @@ class TestSmokeLoadAllJSONs: assert len(fw.requirements) >= 0 +class TestCyberEssentialsFramework: + """Schema and content checks for the Cyber Essentials universal framework.""" + + @staticmethod + def _path(): + base = os.path.join( + os.path.dirname(__file__), + "..", + "..", + "..", + "prowler", + "compliance", + "cyber_essentials_3.3.json", + ) + return os.path.normpath(base) + + def test_loads_and_supports_azure(self): + fw = load_compliance_framework_universal(self._path()) + assert fw is not None + assert fw.framework == "Cyber-Essentials" + assert fw.version == "3.3" + assert fw.get_providers() == ["azure"] + assert fw.supports_provider("azure") + + def test_covers_all_five_themes(self): + fw = load_compliance_framework_universal(self._path()) + themes = {req.attributes["Theme"] for req in fw.requirements} + assert themes == { + "Firewalls", + "Secure Configuration", + "Security Update Management", + "User Access Control", + "Malware Protection", + } + + def test_requirement_ids_are_unique(self): + fw = load_compliance_framework_universal(self._path()) + ids = [req.id for req in fw.requirements] + assert len(ids) == len(set(ids)) + + def test_attribute_values_conform_to_enums(self): + fw = load_compliance_framework_universal(self._path()) + for req in fw.requirements: + assert req.attributes["AssessmentStatus"] in {"Automated", "Manual"} + assert req.attributes["CloudApplicability"] in { + "full", + "partial", + "non-applicable", + } + # Requirements with no checks must not claim to be Automated. + if not req.checks.get("azure"): + assert req.attributes["AssessmentStatus"] == "Manual" + + class TestBackwardCompat: """Ensure Compliance.get_bulk still returns Compliance objects.""" @@ -1237,3 +1292,91 @@ class TestGetBulkUniversalEntryPoints: assert "pkg_a_1.0" in bulk assert "pkg_b_1.0" in bulk + + +class TestGetUniversalComplianceEntryPointDirs: + """Directories external packages contribute.""" + + @staticmethod + def _entry_point(path=None, *, file_path=None, load_error=None): + ep = MagicMock() + ep.name = "external" + ep.group = "prowler.compliance.universal" + if load_error is not None: + ep.load.side_effect = load_error + return ep + module = MagicMock() + if path is not None: + module.__path__ = [path] + else: + # A module, not a package: only __file__. + del module.__path__ + module.__file__ = file_path + ep.load.return_value = module + return ep + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + def test_returns_entry_point_dirs_in_order(self, mock_ep): + with ( + tempfile.TemporaryDirectory() as dir_a, + tempfile.TemporaryDirectory() as dir_b, + ): + mock_ep.return_value = [ + self._entry_point(dir_a), + self._entry_point(dir_b), + ] + + assert get_universal_compliance_entry_point_dirs() == [dir_a, dir_b] + + mock_ep.assert_called_with(group="prowler.compliance.universal") + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + def test_dedupes_the_same_directory(self, mock_ep): + """Two entry points, one directory: loaded once.""" + with tempfile.TemporaryDirectory() as ep_dir: + mock_ep.return_value = [ + self._entry_point(ep_dir), + self._entry_point(ep_dir), + ] + + assert get_universal_compliance_entry_point_dirs() == [ep_dir] + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + def test_dedupes_a_directory_reached_through_a_symlink(self, mock_ep, tmp_path): + """Same directory behind a symlink: still loaded once.""" + real = tmp_path / "real" + real.mkdir() + link = tmp_path / "link" + link.symlink_to(real, target_is_directory=True) + mock_ep.return_value = [ + self._entry_point(str(real)), + self._entry_point(str(link)), + ] + + assert get_universal_compliance_entry_point_dirs() == [str(real)] + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + def test_skips_paths_that_are_not_directories(self, mock_ep): + mock_ep.return_value = [self._entry_point("/does/not/exist")] + + assert get_universal_compliance_entry_point_dirs() == [] + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + def test_a_broken_entry_point_does_not_hide_the_others(self, mock_ep): + with tempfile.TemporaryDirectory() as ep_dir: + mock_ep.return_value = [ + self._entry_point(load_error=ImportError("boom")), + self._entry_point(ep_dir), + ] + + assert get_universal_compliance_entry_point_dirs() == [ep_dir] + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + def test_module_without_path_falls_back_to_its_file_directory(self, mock_ep): + with tempfile.TemporaryDirectory() as ep_dir: + module_file = os.path.join(ep_dir, "compliance.py") + with open(module_file, "w"): + pass + mock_ep.return_value = [self._entry_point(file_path=module_file)] + + assert get_universal_compliance_entry_point_dirs() == [ep_dir] 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/compliance/cis/cis_kubernetes_test.py b/tests/lib/outputs/compliance/cis/cis_kubernetes_test.py index 74d85c094b..fa1cd71180 100644 --- a/tests/lib/outputs/compliance/cis/cis_kubernetes_test.py +++ b/tests/lib/outputs/compliance/cis/cis_kubernetes_test.py @@ -34,6 +34,7 @@ class TestKubernetesCIS: assert output_data.Framework == CIS_1_8_KUBERNETES.Framework assert output_data.Name == CIS_1_8_KUBERNETES.Name assert output_data.Context == KUBERNETES_CLUSTER_NAME + assert output_data.Cluster == KUBERNETES_CLUSTER_NAME assert output_data.Namespace == KUBERNETES_NAMESPACE assert output_data.Description == CIS_1_8_KUBERNETES.Description assert output_data.Requirements_Id == CIS_1_8_KUBERNETES.Requirements[0].Id @@ -101,6 +102,7 @@ class TestKubernetesCIS: assert output_data_manual.Framework == CIS_1_8_KUBERNETES.Framework assert output_data_manual.Name == CIS_1_8_KUBERNETES.Name assert output_data_manual.Context == "" + assert output_data_manual.Cluster == "" assert output_data_manual.Namespace == "" assert output_data_manual.Description == CIS_1_8_KUBERNETES.Description assert ( @@ -190,5 +192,5 @@ class TestKubernetesCIS: mock_file.seek(0) content = mock_file.read() - expected_csv = f"PROVIDER;DESCRIPTION;CONTEXT;NAMESPACE;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SUBSECTION;REQUIREMENTS_ATTRIBUTES_PROFILE;REQUIREMENTS_ATTRIBUTES_ASSESSMENTSTATUS;REQUIREMENTS_ATTRIBUTES_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_RATIONALESTATEMENT;REQUIREMENTS_ATTRIBUTES_IMPACTSTATEMENT;REQUIREMENTS_ATTRIBUTES_REMEDIATIONPROCEDURE;REQUIREMENTS_ATTRIBUTES_AUDITPROCEDURE;REQUIREMENTS_ATTRIBUTES_ADDITIONALINFORMATION;REQUIREMENTS_ATTRIBUTES_REFERENCES;REQUIREMENTS_ATTRIBUTES_DEFAULTVALUE;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED;FRAMEWORK;NAME\r\nkubernetes;This CIS Kubernetes Benchmark provides prescriptive guidance for establishing a secure configuration posture for Kubernetes v1.27.;test-cluster;test-namespace;{datetime.now()};1.1.3;Ensure that the controller manager pod specification file permissions are set to 600 or more restrictive;1. Control Plane;1.1 Control Plane Node Configuration Files;Level 1;Automated;Ensure that the controller manager pod specification file has permissions of `600` or more restrictive.;The controller manager pod specification file controls various parameters that set the behavior of the Controller Manager on the master node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.;;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod 600 /etc/kubernetes/manifests/kube-controller-manager.yaml ```;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %a /etc/kubernetes/manifests/kube-controller-manager.yaml ``` Verify that the permissions are `600` or more restrictive.;;https://kubernetes.io/docs/admin/kube-apiserver/;By default, the `kube-controller-manager.yaml` file has permissions of `640`.;PASS;;;;service_test_check_id;False;CIS;CIS Kubernetes Benchmark v1.8.0\r\nkubernetes;This CIS Kubernetes Benchmark provides prescriptive guidance for establishing a secure configuration posture for Kubernetes v1.27.;;;{datetime.now()};1.1.4;Ensure that the controller manager pod specification file permissions are set to 600 or more restrictive;1.1 Control Plane Node Configuration Files;;Level 1;Automated;Ensure that the controller manager pod specification file has permissions of `600` or more restrictive.;The controller manager pod specification file controls various parameters that set the behavior of the Controller Manager on the master node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.;;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod 600 /etc/kubernetes/manifests/kube-controller-manager.yaml ```;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %a /etc/kubernetes/manifests/kube-controller-manager.yaml ``` Verify that the permissions are `600` or more restrictive.;;https://kubernetes.io/docs/admin/kube-apiserver/;By default, the `kube-controller-manager.yaml` file has permissions of `640`.;MANUAL;Manual check;manual_check;Manual check;manual;False;CIS;CIS Kubernetes Benchmark v1.8.0\r\n" + expected_csv = f"PROVIDER;DESCRIPTION;CONTEXT;CLUSTER;NAMESPACE;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SUBSECTION;REQUIREMENTS_ATTRIBUTES_PROFILE;REQUIREMENTS_ATTRIBUTES_ASSESSMENTSTATUS;REQUIREMENTS_ATTRIBUTES_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_RATIONALESTATEMENT;REQUIREMENTS_ATTRIBUTES_IMPACTSTATEMENT;REQUIREMENTS_ATTRIBUTES_REMEDIATIONPROCEDURE;REQUIREMENTS_ATTRIBUTES_AUDITPROCEDURE;REQUIREMENTS_ATTRIBUTES_ADDITIONALINFORMATION;REQUIREMENTS_ATTRIBUTES_REFERENCES;REQUIREMENTS_ATTRIBUTES_DEFAULTVALUE;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED;FRAMEWORK;NAME\r\nkubernetes;This CIS Kubernetes Benchmark provides prescriptive guidance for establishing a secure configuration posture for Kubernetes v1.27.;test-cluster;test-cluster;test-namespace;{datetime.now()};1.1.3;Ensure that the controller manager pod specification file permissions are set to 600 or more restrictive;1. Control Plane;1.1 Control Plane Node Configuration Files;Level 1;Automated;Ensure that the controller manager pod specification file has permissions of `600` or more restrictive.;The controller manager pod specification file controls various parameters that set the behavior of the Controller Manager on the master node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.;;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod 600 /etc/kubernetes/manifests/kube-controller-manager.yaml ```;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %a /etc/kubernetes/manifests/kube-controller-manager.yaml ``` Verify that the permissions are `600` or more restrictive.;;https://kubernetes.io/docs/admin/kube-apiserver/;By default, the `kube-controller-manager.yaml` file has permissions of `640`.;PASS;;;;service_test_check_id;False;CIS;CIS Kubernetes Benchmark v1.8.0\r\nkubernetes;This CIS Kubernetes Benchmark provides prescriptive guidance for establishing a secure configuration posture for Kubernetes v1.27.;;;;{datetime.now()};1.1.4;Ensure that the controller manager pod specification file permissions are set to 600 or more restrictive;1.1 Control Plane Node Configuration Files;;Level 1;Automated;Ensure that the controller manager pod specification file has permissions of `600` or more restrictive.;The controller manager pod specification file controls various parameters that set the behavior of the Controller Manager on the master node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.;;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod 600 /etc/kubernetes/manifests/kube-controller-manager.yaml ```;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %a /etc/kubernetes/manifests/kube-controller-manager.yaml ``` Verify that the permissions are `600` or more restrictive.;;https://kubernetes.io/docs/admin/kube-apiserver/;By default, the `kube-controller-manager.yaml` file has permissions of `640`.;MANUAL;Manual check;manual_check;Manual check;manual;False;CIS;CIS Kubernetes Benchmark v1.8.0\r\n" assert content == expected_csv diff --git a/tests/lib/outputs/compliance/iso27001/iso27001_kubernetes_test.py b/tests/lib/outputs/compliance/iso27001/iso27001_kubernetes_test.py new file mode 100644 index 0000000000..dc33869b51 --- /dev/null +++ b/tests/lib/outputs/compliance/iso27001/iso27001_kubernetes_test.py @@ -0,0 +1,78 @@ +from prowler.lib.check.compliance_models import ( + Compliance, + Compliance_Requirement, + ISO27001_2013_Requirement_Attribute, +) +from prowler.lib.outputs.compliance.iso27001.iso27001_kubernetes import ( + KubernetesISO27001, +) +from prowler.lib.outputs.compliance.iso27001.models import KubernetesISO27001Model +from tests.lib.outputs.fixtures.fixtures import generate_finding_output +from tests.providers.kubernetes.kubernetes_fixtures import ( + KUBERNETES_CLUSTER_NAME, + KUBERNETES_NAMESPACE, +) + +ISO27001_2013_KUBERNETES = Compliance( + Framework="ISO27001", + Name="ISO/IEC 27001 Information Security Management Standard 2013", + Provider="Kubernetes", + Version="2013", + Description="ISO 27001 controls mapped to Kubernetes findings.", + Requirements=[ + Compliance_Requirement( + Id="A.10.1", + Description="Protect Kubernetes workload configuration", + Name="Cryptographic Controls", + Attributes=[ + ISO27001_2013_Requirement_Attribute( + Category="A.10 Cryptography", + Objetive_ID="A.10.1", + Objetive_Name="Cryptographic Controls", + Check_Summary="Protect Kubernetes workload configuration", + ) + ], + Checks=["service_test_check_id"], + ), + Compliance_Requirement( + Id="A.10.2", + Description="Manual Kubernetes control", + Name="Cryptographic Controls", + Attributes=[ + ISO27001_2013_Requirement_Attribute( + Category="A.10 Cryptography", + Objetive_ID="A.10.2", + Objetive_Name="Cryptographic Controls", + Check_Summary="Manual Kubernetes control", + ) + ], + Checks=[], + ), + ], +) + + +class TestKubernetesISO27001: + def test_output_transform_includes_cluster(self): + findings = [ + generate_finding_output( + provider="kubernetes", + compliance={"ISO27001-2013": "A.10.1"}, + account_name="context: kind-dev", + account_uid=KUBERNETES_CLUSTER_NAME, + region=KUBERNETES_NAMESPACE, + ) + ] + + output = KubernetesISO27001(findings, ISO27001_2013_KUBERNETES) + + output_data = output.data[0] + assert isinstance(output_data, KubernetesISO27001Model) + assert output_data.Context == "context: kind-dev" + assert output_data.Cluster == KUBERNETES_CLUSTER_NAME + assert output_data.Namespace == KUBERNETES_NAMESPACE + + manual = output.data[1] + assert manual.Context == "" + assert manual.Cluster == "" + assert manual.Namespace == "" diff --git a/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_kubernetes_test.py b/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_kubernetes_test.py new file mode 100644 index 0000000000..b0b5775017 --- /dev/null +++ b/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_kubernetes_test.py @@ -0,0 +1,84 @@ +from prowler.lib.check.compliance_models import ( + Compliance, + Compliance_Requirement, + Prowler_ThreatScore_Requirement_Attribute, +) +from prowler.lib.outputs.compliance.prowler_threatscore.models import ( + ProwlerThreatScoreKubernetesModel, +) +from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_kubernetes import ( + ProwlerThreatScoreKubernetes, +) +from tests.lib.outputs.fixtures.fixtures import generate_finding_output +from tests.providers.kubernetes.kubernetes_fixtures import ( + KUBERNETES_CLUSTER_NAME, + KUBERNETES_NAMESPACE, +) + +PROWLER_THREATSCORE_KUBERNETES = Compliance( + Framework="ProwlerThreatScore", + Name="Prowler ThreatScore Compliance Framework for Kubernetes", + Version="1.0", + Provider="Kubernetes", + Description="Prowler ThreatScore controls mapped to Kubernetes findings.", + Requirements=[ + Compliance_Requirement( + Id="1.1.1", + Description="Kubernetes workload hardening", + Attributes=[ + Prowler_ThreatScore_Requirement_Attribute( + Title="Workload hardening", + Section="1. Kubernetes", + SubSection="1.1 Workloads", + AttributeDescription="Kubernetes workload hardening control.", + AdditionalInformation="", + LevelOfRisk=5, + Weight=1000, + ) + ], + Checks=["service_test_check_id"], + ), + Compliance_Requirement( + Id="1.1.2", + Description="Manual Kubernetes review", + Attributes=[ + Prowler_ThreatScore_Requirement_Attribute( + Title="Manual review", + Section="1. Kubernetes", + SubSection="1.1 Workloads", + AttributeDescription="Manual Kubernetes review control.", + AdditionalInformation="", + LevelOfRisk=3, + Weight=10, + ) + ], + Checks=[], + ), + ], +) + + +class TestProwlerThreatScoreKubernetes: + def test_output_transform_includes_cluster(self): + findings = [ + generate_finding_output( + provider="kubernetes", + compliance={"ProwlerThreatScore-1.0": "1.1.1"}, + account_name="context: kind-dev", + account_uid=KUBERNETES_CLUSTER_NAME, + region=KUBERNETES_NAMESPACE, + ) + ] + + output = ProwlerThreatScoreKubernetes(findings, PROWLER_THREATSCORE_KUBERNETES) + + output_data = output.data[0] + assert isinstance(output_data, ProwlerThreatScoreKubernetesModel) + assert output_data.Context == "context: kind-dev" + assert output_data.Cluster == KUBERNETES_CLUSTER_NAME + assert output_data.Namespace == KUBERNETES_NAMESPACE + + manual = output.data[1] + assert manual.Context == "" + assert manual.Cluster == "" + assert manual.Namespace == "" diff --git a/tests/lib/outputs/compliance/universal/universal_output_test.py b/tests/lib/outputs/compliance/universal/universal_output_test.py index d0fdf0f178..dd951cf272 100644 --- a/tests/lib/outputs/compliance/universal/universal_output_test.py +++ b/tests/lib/outputs/compliance/universal/universal_output_test.py @@ -493,9 +493,11 @@ class TestProviderHeaders: ) row_dict = output.data[0].dict() assert "Context" in row_dict + assert "Cluster" in row_dict assert "Namespace" in row_dict # Kubernetes Context maps to account_name assert row_dict["Context"] == "test-account" + assert row_dict["Cluster"] == "123456789012" assert row_dict["Namespace"] == "us-east-1" def test_github_headers(self, tmp_path): @@ -562,17 +564,17 @@ class TestProviderHeaders: assert "ACCOUNTID" not in content def test_column_order_matches_legacy(self, tmp_path): - """Verify that the base column order matches the legacy per-provider models. + """Verify that the base column order matches per-provider models. - Legacy models all define: Provider, Description, , , AssessmentDate, ... - The universal output must preserve this exact order for backward compatibility. + Most models define: Provider, Description, , , AssessmentDate. + Kubernetes includes the dedicated Cluster column between Context and Namespace. """ # Expected column order per provider (positions 3 and 4 after Provider, Description) legacy_order = { "aws": ("AccountId", "Region"), "azure": ("SubscriptionId", "Location"), "gcp": ("ProjectId", "Location"), - "kubernetes": ("Context", "Namespace"), + "kubernetes": ("Context", "Cluster", "Namespace"), "m365": ("TenantId", "Location"), "github": ("Account_Name", "Account_Id"), "oraclecloud": ("TenancyId", "Region"), @@ -580,7 +582,7 @@ class TestProviderHeaders: "nhn": ("AccountId", "Region"), } - for provider_name, (expected_col3, expected_col4) in legacy_order.items(): + for provider_name, expected_columns in legacy_order.items(): fw = _simple_framework() findings = [_make_provider_finding(provider_name)] output = UniversalComplianceOutput( @@ -594,12 +596,12 @@ class TestProviderHeaders: assert ( keys[1] == "Description" ), f"{provider_name}: col 2 should be Description" - assert ( - keys[2] == expected_col3 - ), f"{provider_name}: col 3 should be {expected_col3}, got {keys[2]}" - assert ( - keys[3] == expected_col4 - ), f"{provider_name}: col 4 should be {expected_col4}, got {keys[3]}" - assert ( - keys[4] == "AssessmentDate" - ), f"{provider_name}: col 5 should be AssessmentDate" + for index, expected_column in enumerate(expected_columns, start=2): + assert keys[index] == expected_column, ( + f"{provider_name}: col {index + 1} should be " + f"{expected_column}, got {keys[index]}" + ) + assert keys[2 + len(expected_columns)] == "AssessmentDate", ( + f"{provider_name}: col {3 + len(expected_columns)} should be " + "AssessmentDate" + ) diff --git a/tests/lib/outputs/html/html_test.py b/tests/lib/outputs/html/html_test.py index 1459c34b3c..1590d29742 100644 --- a/tests/lib/outputs/html/html_test.py +++ b/tests/lib/outputs/html/html_test.py @@ -1,6 +1,8 @@ +import re import sys from io import StringIO +import pytest from mock import MagicMock, patch from prowler.config.config import prowler_version, timestamp @@ -676,6 +678,192 @@ html_footer = """ """ +def _setup_aws_xss(provider, payload): + provider.identity.account = payload + provider.identity.profile = payload + provider.identity.audited_regions = [payload] + provider.identity.user_id = payload + provider.identity.identity_arn = payload + + +def _setup_azure_xss(provider, payload): + provider.identity.tenant_ids = [payload] + provider.identity.tenant_domain = payload + provider.identity.subscriptions = {payload: payload} + provider.identity.identity_type = payload + provider.identity.identity_id = payload + + +def _setup_gcp_xss(provider, payload): + provider.project_ids = [payload] + provider.session._service_account_email = payload + + +def _setup_kubernetes_xss(provider, payload): + provider.identity.cluster = payload + provider.identity.context = payload + + +def _setup_github_xss(provider, payload): + provider.identity = MagicMock(spec=["account_name", "account_email"]) + provider.identity.account_name = payload + provider.identity.account_email = payload + provider.auth_method = payload + + +def _setup_m365_xss(provider, payload): + provider.identity.tenant_domain = payload + provider.identity.identity_type = payload + provider.identity.identity_id = payload + provider.identity.user = payload + + +def _setup_nhn_xss(provider, payload): + provider.identity.tenant_domain = payload + provider.identity.identity_type = payload + provider.identity.identity_id = payload + + +def _setup_mongodbatlas_xss(provider, payload): + provider.identity.organization_name = payload + + +def _setup_iac_xss(provider, payload): + provider.scan_repository_url = payload + provider.scan_path = None + provider.auth_method = payload + + +def _setup_image_xss(provider, payload): + provider.registry = payload + provider.images = [payload] + provider.auth_method = payload + + +def _setup_llm_xss(provider, payload): + provider.model = payload + provider.plugins = [payload] + provider.max_concurrency = payload + provider.config_path = payload + + +def _setup_oraclecloud_xss(provider, payload): + provider.session.profile = payload + provider.identity.tenancy_name = payload + provider.identity.tenancy_id = payload + + +def _setup_stackit_xss(provider, payload): + provider.identity.project_id = payload + provider.identity.project_name = payload + provider.identity.audited_regions = {payload} + + +def _setup_cloudflare_xss(provider, payload): + account = MagicMock() + account.id = payload + provider.accounts = [account] + provider.session.api_token = "token" + provider.session.api_key = None + provider.session.api_email = None + provider.identity.email = payload + + +def _setup_alibabacloud_xss(provider, payload): + provider.identity.account_id = payload + provider.identity.account_name = payload + provider.identity.audited_regions = payload + provider.identity.identity_arn = payload + provider.identity.user_name = payload + + +def _setup_openstack_xss(provider, payload): + provider.identity.project_id = payload + provider.identity.project_name = payload + provider.identity.region_name = payload + provider.identity.username = payload + provider.identity.user_id = payload + + +def _setup_googleworkspace_xss(provider, payload): + provider.identity.domain = payload + provider.identity.customer_id = payload + provider.identity.delegated_user = payload + + +def _setup_e2enetworks_xss(provider, payload): + provider.identity.project_id = payload + provider.identity.locations = [payload] + + +def _setup_vercel_xss(provider, payload): + team = MagicMock() + team.name = payload + team.id = payload + provider.identity.team = team + provider.identity.email = payload + provider.identity.username = payload + + +def _setup_okta_xss(provider, payload): + provider.identity.org_domain = payload + provider.auth_method = payload + provider.identity.client_id = payload + + +def _setup_scaleway_xss(provider, payload): + provider.identity.organization_id = payload + provider.session.access_key = payload + provider.identity.bearer_type = payload + provider.identity.bearer_email = payload + provider.identity.bearer_id = payload + provider.session.default_region = payload + + +def _setup_linode_xss(provider, payload): + provider.identity.username = payload + provider.identity.email = payload + provider.identity.account_id = payload + + +def _setup_huaweicloud_xss(provider, payload): + provider.identity.account_id = payload + provider.identity.account_name = payload + provider.identity.profile = payload + provider.identity.regions = {payload} + provider.identity.domain_id = payload + provider.identity.user_id = payload + provider.identity.user_name = payload + provider.identity.identity_type = payload + + +PROVIDER_XSS_SETUPS = [ + ("aws", _setup_aws_xss), + ("azure", _setup_azure_xss), + ("gcp", _setup_gcp_xss), + ("kubernetes", _setup_kubernetes_xss), + ("github", _setup_github_xss), + ("m365", _setup_m365_xss), + ("nhn", _setup_nhn_xss), + ("mongodbatlas", _setup_mongodbatlas_xss), + ("iac", _setup_iac_xss), + ("image", _setup_image_xss), + ("llm", _setup_llm_xss), + ("oraclecloud", _setup_oraclecloud_xss), + ("stackit", _setup_stackit_xss), + ("cloudflare", _setup_cloudflare_xss), + ("alibabacloud", _setup_alibabacloud_xss), + ("openstack", _setup_openstack_xss), + ("googleworkspace", _setup_googleworkspace_xss), + ("e2enetworks", _setup_e2enetworks_xss), + ("vercel", _setup_vercel_xss), + ("okta", _setup_okta_xss), + ("scaleway", _setup_scaleway_xss), + ("linode", _setup_linode_xss), + ("huaweicloud", _setup_huaweicloud_xss), +] + + class TestHTML: def test_transform_fail_finding(self): findings = [ @@ -1100,6 +1288,103 @@ class TestHTML: assert "" not in summary assert "Delhi"><script>alert(1)</script>" in summary + @pytest.mark.parametrize( + "provider_type,setup_fn", + PROVIDER_XSS_SETUPS, + ids=[t for t, _ in PROVIDER_XSS_SETUPS], + ) + def test_get_assessment_summary_escapes_provider_identity( + self, provider_type, setup_fn + ): + """Every provider header must HTML-escape tenant-controlled identity fields.""" + payload = "" + findings = [generate_finding_output()] + output = HTML(findings) + + provider = MagicMock() + provider.type = provider_type + setup_fn(provider, payload) + + summary = output.get_assessment_summary(provider) + + assert payload not in summary + assert "<script>alert(1)</script>" in summary + + def test_provider_xss_setups_covers_every_assessment_summary_method(self): + """Adding a new get__assessment_summary without a PROVIDER_XSS_SETUPS + entry must fail this test, so the escape guarantee cannot silently regress.""" + pattern = re.compile(r"^get_(.+)_assessment_summary$") + discovered = set() + for name in dir(HTML): + match = pattern.match(name) + if match: + discovered.add(match.group(1)) + + covered = {ptype for ptype, _ in PROVIDER_XSS_SETUPS} + + missing = discovered - covered + extra = covered - discovered + assert ( + not missing + ), f"providers without XSS coverage in PROVIDER_XSS_SETUPS: {sorted(missing)}" + assert ( + not extra + ), f"PROVIDER_XSS_SETUPS entries with no matching HTML method: {sorted(extra)}" + + def test_github_app_get_assessment_summary_escapes_app_identity(self): + """The GitHub App branch (elif hasattr app_id) must escape app_name/app_id/installations, + which the PAT setup does not exercise.""" + payload = "" + findings = [generate_finding_output()] + output = HTML(findings) + + provider = MagicMock() + provider.type = "github" + provider.identity = MagicMock(spec=["app_id", "app_name", "installations"]) + provider.identity.app_id = payload + provider.identity.app_name = payload + provider.identity.installations = [payload] + provider.auth_method = payload + + summary = output.get_assessment_summary(provider) + + assert payload not in summary + assert "<script>alert(1)</script>" in summary + + def test_iac_get_assessment_summary_escapes_scan_path(self): + """The IAC scan_path branch (else of `if scan_repository_url`) must escape it.""" + payload = "" + findings = [generate_finding_output()] + output = HTML(findings) + + provider = MagicMock() + provider.type = "iac" + provider.scan_repository_url = None + provider.scan_path = payload + provider.auth_method = payload + + summary = output.get_assessment_summary(provider) + + assert payload not in summary + assert "<script>alert(1)</script>" in summary + + def test_image_get_assessment_summary_escapes_images_list(self): + """The Image `else` branch (no registry, images list) must escape each image.""" + payload = "" + findings = [generate_finding_output()] + output = HTML(findings) + + provider = MagicMock() + provider.type = "image" + provider.registry = None + provider.images = [payload] + provider.auth_method = payload + + summary = output.get_assessment_summary(provider) + + assert payload not in summary + assert "<script>alert(1)</script>" in summary + def test_process_markdown_bold_text(self): """Test that **text** is converted to text""" test_text = "This is **bold text** and this is **also bold**" diff --git a/tests/lib/outputs/jira/jira_test.py b/tests/lib/outputs/jira/jira_test.py index 9abd2d26a1..7c5877dc50 100644 --- a/tests/lib/outputs/jira/jira_test.py +++ b/tests/lib/outputs/jira/jira_test.py @@ -1,29 +1,46 @@ 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 from urllib.parse import parse_qs, urlparse import pytest +import requests from freezegun import freeze_time from prowler.lib.outputs.jira.exceptions.exceptions import ( JiraAuthenticationError, JiraBasicAuthError, JiraCreateIssueError, + JiraGetAccessTokenError, JiraGetAvailableIssueTypesError, JiraGetCloudIDError, JiraGetProjectsError, JiraGetProjectsResponseError, JiraNoProjectsError, - JiraNoTokenError, JiraRefreshTokenError, JiraRefreshTokenResponseError, JiraRequiredCustomFieldsError, - JiraSendFindingsResponseError, JiraTestConnectionError, ) from prowler.lib.outputs.jira.jira import Jira, MarkdownToADFConverter +from prowler.lib.outputs.jira.models import ( + JiraCreationOutcome, + JiraCreationResult, + JiraIssueLookupOutcome, + JiraIssueReference, + JiraIssueSearchMatch, + JiraIssueSearchOutcome, + JiraIssueSearchResult, + JiraIssueStatusResult, +) TEST_DATETIME = "2023-01-01T12:01:01+00:00" @@ -72,6 +89,62 @@ class TestMarkdownToADFConverter: assert result[0]["content"][0]["marks"] == [{"type": "code"}] +@pytest.mark.parametrize("missing_field", ["issue_key", "issue_id", "issue_url"]) +def test_confirmed_creation_requires_complete_identity(missing_field): + identity = { + "issue_key": "SEC-1", + "issue_id": "10001", + "issue_url": "https://example.atlassian.net/browse/SEC-1", + } + identity[missing_field] = None + + with pytest.raises(ValueError, match="requires an issue key, ID, and URL"): + JiraCreationResult(JiraCreationOutcome.CONFIRMED_SUCCESS, **identity) + + +@pytest.mark.parametrize( + ("instance", "field"), + [ + (JiraCreationResult(JiraCreationOutcome.UNCERTAIN), "error_code"), + (JiraIssueReference("10001", "SEC-1"), "issue_key"), + ( + JiraIssueStatusResult( + JiraIssueReference("10001", "SEC-1"), JiraIssueLookupOutcome.OPEN + ), + "status", + ), + (JiraIssueSearchMatch("10001", "SEC-1", "https://example"), "issue_key"), + (JiraIssueSearchResult(JiraIssueSearchOutcome.SUCCESS), "matches"), + ], +) +def test_jira_result_models_are_immutable(instance, field): + with pytest.raises(FrozenInstanceError): + setattr(instance, field, None) + + +@pytest.mark.parametrize( + ("enum", "values"), + [ + ( + JiraCreationOutcome, + { + "confirmed_success", + "confirmed_rejection", + "retryable_failure", + "uncertain", + }, + ), + ( + JiraIssueLookupOutcome, + {"open", "done", "moved", "missing", "forbidden", "unknown"}, + ), + (JiraIssueSearchOutcome, {"success", "retryable_failure", "unknown"}), + ], +) +def test_jira_outcome_values_are_stable(enum, values): + assert {outcome.value for outcome in enum} == values + + class TestJiraIntegration: @pytest.fixture(autouse=True) @patch.object(Jira, "get_auth", return_value=None) @@ -108,6 +181,52 @@ class TestJiraIntegration: domain=self.domain, ) + @pytest.fixture + def jira_response(self): + def build(status_code=200, payload=None, headers=None, json_error=None): + response = MagicMock(status_code=status_code, headers=headers or {}) + if json_error: + response.json.side_effect = json_error + else: + response.json.return_value = payload + return response + + return build + + @pytest.fixture + def jira_issue(self): + def build(issue_id, key, status=None, category=None): + status_data = None + if status is not None: + status_data = {"name": status, "statusCategory": {"key": category}} + return {"id": issue_id, "key": key, "fields": {"status": status_data}} + + return build + + @pytest.fixture + def oauth_post(self): + with ( + patch.object(Jira, "get_access_token", return_value="token"), + patch.object( + Jira, "cloud_id", new_callable=PropertyMock, return_value="cloud" + ), + patch("prowler.lib.outputs.jira.jira.requests.post") as post, + ): + yield post + + @pytest.fixture + def send_finding_post(self, oauth_post): + self.jira_integration._site_url = "https://example.atlassian.net" + with ( + patch.object(Jira, "get_projects", return_value={"TEST": {}}) as projects, + patch.object( + Jira, "get_available_issue_types", return_value=["Bug"] + ) as issue_types, + ): + yield SimpleNamespace( + post=oauth_post, projects=projects, issue_types=issue_types + ) + @staticmethod def _collect_text_from_cell(cell: dict) -> str: pieces: List[str] = [] @@ -338,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") @@ -634,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") @@ -813,6 +1033,20 @@ class TestJiraIntegration: with pytest.raises(JiraRefreshTokenError): self.jira_integration.get_projects() + @pytest.mark.parametrize( + ("method_name", "args", "error"), + [ + ("get_projects", (), JiraGetProjectsError), + ("get_available_issue_types", ("TEST",), JiraGetAvailableIssueTypesError), + ], + ) + @patch.object(Jira, "get_access_token", return_value=None) + def test_catalog_methods_raise_without_access_token( + self, mock_get_access_token, method_name, args, error + ): + with pytest.raises(error): + getattr(self.jira_integration, method_name)(*args) + @patch.object(Jira, "get_access_token", return_value="valid_access_token") @patch.object( Jira, "cloud_id", new_callable=PropertyMock, return_value="test_cloud_id" @@ -869,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" @@ -956,6 +1260,7 @@ class TestJiraIntegration: finding.compliance = {"CIS": ["2.1.1", "2.1.2"], "NIST": ["AC-3", "AC-6"]} self.jira_integration.cloud_id = "valid_cloud_id" + self.jira_integration._site_url = "https://example.atlassian.net" self.jira_integration.send_findings( findings=[finding], @@ -1981,18 +2286,17 @@ class TestJiraIntegration: mock_cloud_id, mock_get_access_token, ): - """Test that send_finding returns True when the finding is sent successfully.""" + """Test that send_finding returns a confirmed typed result.""" # 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_issue_types = mock_get_issue_types - # Mock successful response - mock_response = MagicMock() - mock_response.status_code = 201 - mock_response.json.return_value = {"id": "ISSUE-123", "key": "TEST-123"} + mock_response = MagicMock(status_code=201, headers={}) + mock_response.json.return_value = {"id": "10001", "key": "TEST-123"} mock_post.return_value = mock_response + self.jira_integration._site_url = "https://example.atlassian.net" result = self.jira_integration.send_finding( check_id="test-check", @@ -2003,9 +2307,745 @@ class TestJiraIntegration: issue_type="Bug", ) - assert result is True + assert result.outcome == JiraCreationOutcome.CONFIRMED_SUCCESS + assert result.issue_key == "TEST-123" + assert result.issue_id == "10001" + assert result.issue_url == "https://example.atlassian.net/browse/TEST-123" + assert result.is_confirmed_success is True + assert bool(result) is True mock_post.assert_called_once() + @pytest.mark.parametrize( + ("payload", "json_error", "site_url", "error_code"), + [ + ( + None, + ValueError("invalid JSON"), + "https://example", + "malformed_success_response", + ), + ({"id": "10001"}, None, "https://example", "incomplete_success_response"), + ({"key": "TEST-1"}, None, "https://example", "incomplete_success_response"), + ( + {"id": "10001", "key": "invalid key"}, + None, + "https://example", + "incomplete_success_response", + ), + ( + {"id": "not-an-id", "key": "TEST-1"}, + None, + "https://example", + "incomplete_success_response", + ), + ( + {"id": "10001", "key": "TEST-1"}, + None, + None, + "incomplete_success_response", + ), + ], + ) + def test_send_finding_invalid_201_is_uncertain( + self, + send_finding_post, + jira_response, + payload, + json_error, + site_url, + error_code, + ): + self.jira_integration._site_url = site_url + send_finding_post.post.return_value = jira_response( + 201, payload, json_error=json_error + ) + result = self.jira_integration.send_finding( + project_key="TEST", issue_type="Bug", delivery_attempt_marker="attempt-123" + ) + + assert result.outcome == JiraCreationOutcome.UNCERTAIN + assert result.error_code == error_code + assert result.delivery_marker == "attempt-123" + assert result.http_status == 201 + assert bool(result) is False + if site_url is None: + assert (result.issue_id, result.issue_key, result.issue_url) == ( + "10001", + "TEST-1", + None, + ) + + @pytest.mark.parametrize( + ("status_code", "outcome", "retry_after"), + [ + (401, JiraCreationOutcome.CONFIRMED_REJECTION, None), + (403, JiraCreationOutcome.CONFIRMED_REJECTION, None), + (408, JiraCreationOutcome.UNCERTAIN, None), + (429, JiraCreationOutcome.RETRYABLE_FAILURE, "17"), + ], + ) + def test_send_finding_classifies_http_failures( + self, send_finding_post, jira_response, status_code, outcome, retry_after + ): + send_finding_post.post.return_value = jira_response( + status_code, + {"errorMessages": ["Jira error"]}, + {"Retry-After": retry_after} if retry_after else None, + ) + result = self.jira_integration.send_finding( + project_key="TEST", issue_type="Bug" + ) + + assert result.outcome == outcome + assert result.http_status == status_code + assert result.retry_after == retry_after + + @pytest.mark.parametrize( + "transport_error, expected_outcome", + [ + ( + requests.exceptions.ConnectTimeout(), + JiraCreationOutcome.RETRYABLE_FAILURE, + ), + (requests.exceptions.ReadTimeout(), JiraCreationOutcome.UNCERTAIN), + (requests.exceptions.ConnectionError(), JiraCreationOutcome.UNCERTAIN), + ], + ) + def test_send_finding_classifies_transport_failures( + self, send_finding_post, transport_error, expected_outcome + ): + send_finding_post.post.side_effect = transport_error + result = self.jira_integration.send_finding( + project_key="TEST", + issue_type="Bug", + delivery_attempt_marker="stable-marker", + ) + + assert result.outcome == expected_outcome + assert result.delivery_marker == "stable-marker" + + @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.object(Jira, "get_projects", return_value={"TEST": {"name": "Test Project"}}) + @patch.object(Jira, "get_available_issue_types", return_value=["Bug"]) + @patch("prowler.lib.outputs.jira.jira.requests.post") + def test_send_finding_returns_issue_url_with_basic_auth( + self, + mock_post, + mock_get_issue_types, + mock_get_projects, + mock_cloud_id, + mock_get_access_token, + ): + """Test that send_finding builds the browse URL from the basic auth site name.""" + # 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_issue_types = mock_get_issue_types + + mock_response = MagicMock(status_code=201, headers={}) + mock_response.json.return_value = {"id": "10001", "key": "TEST-7"} + mock_post.return_value = mock_response + + result = self.jira_integration_basic_auth.send_finding( + check_id="test-check", + check_title="Test Finding", + severity="High", + status="FAIL", + project_key="TEST", + issue_type="Bug", + ) + + assert result.outcome == JiraCreationOutcome.CONFIRMED_SUCCESS + assert result.issue_key == "TEST-7" + assert result.issue_id == "10001" + assert result.issue_url == "https://test-domain.atlassian.net/browse/TEST-7" + + @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.object(Jira, "get_projects", return_value={"TEST": {"name": "Test Project"}}) + @patch.object(Jira, "get_available_issue_types", return_value=["Bug"]) + @patch("prowler.lib.outputs.jira.jira.requests.post") + def test_send_finding_sanitizes_issue_labels( + self, + mock_post, + mock_get_issue_types, + mock_get_projects, + mock_cloud_id, + mock_get_access_token, + ): + """Test that labels are sanitized, deduplicated and empties dropped before sending.""" + # 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_issue_types = mock_get_issue_types + + mock_response = MagicMock(status_code=201, headers={}) + mock_response.json.return_value = {"id": "10001", "key": "TEST-123"} + mock_post.return_value = mock_response + + self.jira_integration.send_finding( + check_id="test-check", + check_title="Test Finding", + severity="High", + status="FAIL", + project_key="TEST", + issue_type="Bug", + issue_labels=[ + "prowler", + "prowler-finding-arn:aws:s3:::my bucket/with space", + "prowler", + "", + " ", + ], + ) + + payload = mock_post.call_args.kwargs["json"] + assert payload["fields"]["labels"] == [ + "prowler", + "prowler-finding-arn:aws:s3:::my_bucket/with_space", + ] + + def test_send_finding_reuses_marker_and_caches_valid_destinations( + self, send_finding_post, jira_response + ): + """Test retries keep one marker and catalogs are cached per destination.""" + responses = [ + jira_response( + 201, + { + "id": str(10001 + index), + "key": f"TEST-{index + 1}", + }, + ) + for index in range(3) + ] + send_finding_post.projects.return_value = {"TEST": {}, "OPS": {}} + send_finding_post.issue_types.side_effect = [["Bug"], ["Task"]] + send_finding_post.post.side_effect = responses + + for _ in range(2): + self.jira_integration.send_finding( + project_key="TEST", + issue_type="Bug", + delivery_attempt_marker="marker 123", + ) + self.jira_integration.send_finding( + project_key="OPS", + issue_type="Task", + delivery_attempt_marker="marker 123", + ) + + assert send_finding_post.projects.call_count == 2 + assert send_finding_post.issue_types.call_count == 2 + labels = [ + call.kwargs["json"]["fields"]["labels"] + for call in send_finding_post.post.call_args_list + ] + assert labels == [["prowler-attempt-marker_123"]] * 3 + + @pytest.mark.parametrize( + "projects, issue_types, expected_code", + [ + ({}, ["Bug"], "invalid_project"), + ({"TEST": {}}, [], "invalid_issue_type"), + (JiraNoProjectsError(message="No projects"), ["Bug"], "invalid_project"), + ], + ) + def test_send_finding_invalid_destination_is_confirmed_rejection( + self, send_finding_post, projects, issue_types, expected_code + ): + if isinstance(projects, Exception): + send_finding_post.projects.side_effect = projects + else: + send_finding_post.projects.return_value = projects + send_finding_post.issue_types.return_value = issue_types + result = self.jira_integration.send_finding( + project_key="TEST", issue_type="Bug" + ) + + assert result.outcome == JiraCreationOutcome.CONFIRMED_REJECTION + assert result.error_code == expected_code + send_finding_post.post.assert_not_called() + + @pytest.mark.parametrize( + ("failure_stage", "status_code", "outcome", "error_code", "retry_after"), + [ + ( + "projects", + 403, + JiraCreationOutcome.CONFIRMED_REJECTION, + "invalid_credentials", + None, + ), + ( + "issue_types", + 403, + JiraCreationOutcome.CONFIRMED_REJECTION, + "invalid_credentials", + None, + ), + ( + "projects", + 429, + JiraCreationOutcome.RETRYABLE_FAILURE, + "destination_temporarily_unavailable", + "23", + ), + ], + ) + def test_send_finding_classifies_catalog_failures( + self, + oauth_post, + jira_response, + failure_stage, + status_code, + outcome, + error_code, + retry_after, + ): + failure = jira_response( + status_code, headers={"Retry-After": retry_after} if retry_after else None + ) + failure.text = "unsafe response body" + responses = [failure] + if failure_stage == "issue_types": + projects = jira_response(200, [{"key": "TEST", "name": "Test"}]) + responses = [projects, failure] + + with patch("prowler.lib.outputs.jira.jira.requests.get", side_effect=responses): + result = self.jira_integration.send_finding( + project_key="TEST", issue_type="Bug" + ) + + assert result.outcome == outcome + assert result.http_status == status_code + assert result.error_code == error_code + assert result.retry_after == retry_after + assert "unsafe response body" not in result.error_message + oauth_post.assert_not_called() + + def test_sanitize_label(self): + """Test the deterministic Jira label sanitizer.""" + assert Jira.sanitize_label("") == "" + assert Jira.sanitize_label(None) == "" + assert Jira.sanitize_label(" ") == "" + assert Jira.sanitize_label("simple") == "simple" + assert Jira.sanitize_label("with space") == "with_space" + assert Jira.sanitize_label(" many spaces \t tabs\nnewline ") == ( + "many_spaces_tabs_newline" + ) + assert Jira.sanitize_label("ctrl\x00char\x07here") == "ctrlcharhere" + assert Jira.sanitize_label("__ Keep__CASE Here __") == "Keep_CASE_Here" + assert Jira.sanitize_label("arn:aws:iam::123456789012:role/Admin") == ( + "arn:aws:iam::123456789012:role/Admin" + ) + long_label = "x" * 300 + assert Jira.sanitize_label(long_label) == "x" * 255 + # Idempotent: sanitizing an already sanitized value is a no-op + once = Jira.sanitize_label("a b\tc") + assert Jira.sanitize_label(once) == once + + def test_prefixed_labels_are_length_safe_and_collision_resistant(self): + """Test finding and attempt labels preserve identity at Jira's limit.""" + assert Jira.build_finding_label("") == "" + assert Jira.build_delivery_attempt_label(None) == "" + finding_prefix = f"{Jira.FINDING_LABEL_PREFIX}-" + exact_uid = "A" * (Jira.LABEL_MAX_LENGTH - len(finding_prefix)) + assert Jira.build_finding_label(exact_uid) == f"{finding_prefix}{exact_uid}" + + long_prefix = "A" * 400 + first_uid = f"{long_prefix}-first" + second_uid = f"{long_prefix}-second" + first_label = Jira.build_finding_label(first_uid) + second_label = Jira.build_finding_label(second_uid) + + assert len(first_label) == Jira.LABEL_MAX_LENGTH + assert len(second_label) == Jira.LABEL_MAX_LENGTH + assert first_label.endswith(hashlib.sha256(first_uid.encode()).hexdigest()) + assert second_label.endswith(hashlib.sha256(second_uid.encode()).hexdigest()) + assert first_label != second_label + assert Jira.build_delivery_attempt_label("Attempt CASE") == ( + "prowler-attempt-Attempt_CASE" + ) + long_attempt_label = Jira.build_delivery_attempt_label(first_uid) + assert len(long_attempt_label) == Jira.LABEL_MAX_LENGTH + assert long_attempt_label.endswith( + hashlib.sha256(first_uid.encode()).hexdigest() + ) + + def test_sanitize_labels(self): + """Test list sanitization keeps order, drops empties and duplicates.""" + assert Jira.sanitize_labels(None) == [] + assert Jira.sanitize_labels([]) == [] + assert Jira.sanitize_labels(["b", "a b", "b", "", "a_b"]) == ["b", "a_b"] + + def test_site_url_and_issue_url(self): + """Test site_url derivation and browse URL building for both auth modes.""" + assert ( + self.jira_integration_basic_auth.site_url + == "https://test-domain.atlassian.net" + ) + assert ( + self.jira_integration_basic_auth.get_issue_url("TEST-1") + == "https://test-domain.atlassian.net/browse/TEST-1" + ) + # OAuth: unknown until accessible-resources is fetched + assert self.jira_integration.site_url is None + assert self.jira_integration.get_issue_url("TEST-1") is None + self.jira_integration._site_url = "https://oauth-site.atlassian.net/" + assert ( + self.jira_integration.get_issue_url("TEST-1") + == "https://oauth-site.atlassian.net/browse/TEST-1" + ) + assert self.jira_integration.get_issue_url("") is None + + @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.post") + def test_get_issues_status(self, mock_post, mock_cloud_id, mock_get_access_token): + """Test bulk status lookup returns explicit outcomes in input order.""" + mock_cloud_id = mock_cloud_id + mock_get_access_token = mock_get_access_token + mock_response = MagicMock(status_code=200, headers={}) + mock_response.json.return_value = { + "issues": [ + { + "id": "10001", + "key": "SEC-1", + "fields": { + "status": { + "name": "Resolved by policy", + "statusCategory": {"key": "done"}, + } + }, + }, + { + "id": "10002", + "key": "SEC-2", + "fields": { + "status": { + "name": "In Progress", + "statusCategory": {"key": "indeterminate"}, + } + }, + }, + ], + "issueErrors": [{"id": "10003", "statusCode": 404}], + } + mock_post.return_value = mock_response + + references = [ + JiraIssueReference("10001", "SEC-1"), + JiraIssueReference("10002", "SEC-2"), + JiraIssueReference("10003", "SEC-3"), + JiraIssueReference("10001", "SEC-1"), + ] + result = self.jira_integration.get_issues_status(references) + + assert [item.reference for item in result] == references[:3] + assert [item.outcome for item in result] == [ + JiraIssueLookupOutcome.DONE, + JiraIssueLookupOutcome.OPEN, + JiraIssueLookupOutcome.MISSING, + ] + assert result[0].status == "Resolved by policy" + assert result[1].status_category == "indeterminate" + assert result[2].http_status == 404 + mock_post.assert_called_once() + assert mock_post.call_args.args[0].endswith("/rest/api/3/issue/bulkfetch") + assert mock_post.call_args.kwargs["json"] == { + "issueIdsOrKeys": ["10001", "10002", "10003"], + "fields": ["status"], + } + + def test_get_issues_status_moved_precedes_status_and_parses_mixed_errors( + self, oauth_post, jira_response, jira_issue + ): + self.jira_integration._site_url = "https://example.atlassian.net" + oauth_post.return_value = jira_response( + 200, + { + "issues": [ + jira_issue("10001", "OPS-9", "Custom completion", "done"), + jira_issue("10004", "SEC-4"), + jira_issue("10006", "SEC-6", "Custom", "unclassified"), + ], + "issueErrors": [ + {"issueIdOrKey": "10002", "errorCode": 403}, + {"issueId": "10003", "status": 404}, + ], + }, + ) + references = [ + JiraIssueReference("10001", "SEC-1"), + JiraIssueReference("10002", "SEC-2"), + JiraIssueReference("10003", "SEC-3"), + JiraIssueReference("10004", "SEC-4"), + JiraIssueReference("10005", "SEC-5"), + JiraIssueReference("10006", "SEC-6"), + ] + + results = self.jira_integration.get_issues_status(references) + + assert [result.outcome for result in results] == [ + JiraIssueLookupOutcome.MOVED, + JiraIssueLookupOutcome.FORBIDDEN, + JiraIssueLookupOutcome.MISSING, + JiraIssueLookupOutcome.UNKNOWN, + JiraIssueLookupOutcome.UNKNOWN, + JiraIssueLookupOutcome.UNKNOWN, + ] + assert results[0].current_issue_key == "OPS-9" + assert results[0].current_issue_url.endswith("/browse/OPS-9") + assert results[0].status == "Custom completion" + assert results[3].error_code == "malformed_issue" + assert results[4].error_code == "omitted_issue" + assert results[5].error_code == "malformed_issue" + + def test_get_issues_status_malformed_batch_is_unknown( + self, oauth_post, jira_response + ): + oauth_post.return_value = jira_response(200, {"issues": "invalid"}) + reference = JiraIssueReference("10001", "SEC-1") + + result = self.jira_integration.get_issues_status([reference])[0] + + assert result.outcome == JiraIssueLookupOutcome.UNKNOWN + assert result.error_code == "malformed_response" + + @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.post") + def test_get_issues_status_batches_requests( + self, mock_post, mock_cloud_id, mock_get_access_token + ): + """Test that more than ISSUE_STATUS_BATCH_SIZE keys are fetched in batches.""" + mock_cloud_id = mock_cloud_id + mock_get_access_token = mock_get_access_token + mock_response = MagicMock(status_code=200, headers={}) + mock_response.json.return_value = {"issues": []} + mock_post.return_value = mock_response + + references = [JiraIssueReference(str(i), f"SEC-{i}") for i in range(1, 251)] + results = self.jira_integration.get_issues_status(references) + assert len(results) == 250 + assert all( + result.outcome == JiraIssueLookupOutcome.UNKNOWN for result in results + ) + assert mock_post.call_count == 3 + sizes = [ + len(call.kwargs["json"]["issueIdsOrKeys"]) + for call in mock_post.call_args_list + ] + assert sizes == [100, 100, 50] + + @patch("prowler.lib.outputs.jira.jira.requests.post") + def test_get_issues_status_without_keys(self, mock_post): + """Test that no request is made when there is nothing to look up.""" + assert self.jira_integration.get_issues_status([]) == [] + invalid_references = [ + JiraIssueReference("1", "SEC-1\n"), + JiraIssueReference("2", "SEC-١"), + JiraIssueReference("not-an-id", "SEC-3"), + ] + results = self.jira_integration.get_issues_status(invalid_references) + assert [result.outcome for result in results] == [ + JiraIssueLookupOutcome.UNKNOWN, + JiraIssueLookupOutcome.UNKNOWN, + JiraIssueLookupOutcome.UNKNOWN, + ] + assert all(result.error_code == "invalid_reference" for result in results) + mock_post.assert_not_called() + + def test_get_issues_status_preserves_success_when_later_batch_fails( + self, oauth_post, jira_response, jira_issue + ): + """Test that a failing later batch does not erase earlier results.""" + ok = jira_response( + 200, + {"issues": [jira_issue("1", "SEC-1", "Done", "done")]}, + ) + failed = jira_response(502, headers={"Retry-After": "12"}) + oauth_post.side_effect = [ok, failed] + + references = [JiraIssueReference(str(i), f"SEC-{i}") for i in range(1, 102)] + results = self.jira_integration.get_issues_status(references) + assert results[0].outcome == JiraIssueLookupOutcome.DONE + assert all( + result.outcome == JiraIssueLookupOutcome.UNKNOWN for result in results[1:] + ) + assert results[-1].http_status == 502 + assert results[-1].retry_after == "12" + assert oauth_post.call_count == 2 + + @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.post") + def test_get_issues_status_response_error( + self, mock_post, mock_cloud_id, mock_get_access_token + ): + """Test that a whole-batch error produces unknown without losing input.""" + mock_cloud_id = mock_cloud_id + mock_get_access_token = mock_get_access_token + mock_response = MagicMock(status_code=403, headers={}) + mock_response.text = "forbidden" + mock_post.return_value = mock_response + + reference = JiraIssueReference("10001", "SEC-1") + result = self.jira_integration.get_issues_status([reference])[0] + assert result.reference == reference + assert result.outcome == JiraIssueLookupOutcome.UNKNOWN + assert result.http_status == 403 + + @patch.object(Jira, "get_access_token", side_effect=JiraGetAccessTokenError()) + @patch("prowler.lib.outputs.jira.jira.requests.post") + def test_get_issues_status_access_token_error_returns_unknown( + self, mock_post, mock_get_access_token + ): + reference = JiraIssueReference("10001", "SEC-1") + result = self.jira_integration.get_issues_status([reference])[0] + + assert result.outcome == JiraIssueLookupOutcome.UNKNOWN + assert result.error_code == "authentication_failed" + mock_get_access_token.assert_called_once_with() + mock_post.assert_not_called() + + @pytest.mark.parametrize( + "issues", + [ + [], + [{"id": "10001", "key": "SEC-1"}], + [ + {"id": "10001", "key": "SEC-1"}, + {"id": "10002", "key": "SEC-2"}, + ], + ], + ) + def test_search_issues_by_delivery_attempt_returns_all_matches( + self, oauth_post, jira_response, issues + ): + self.jira_integration._site_url = "https://example.atlassian.net" + oauth_post.return_value = jira_response(200, {"issues": issues}) + result = self.jira_integration.search_issues_by_delivery_attempt("attempt 123") + + assert result.outcome == JiraIssueSearchOutcome.SUCCESS + assert [match.issue_id for match in result.matches] == [ + issue["id"] for issue in issues + ] + assert all( + match.issue_url.endswith(match.issue_key) for match in result.matches + ) + assert oauth_post.call_args.args[0].endswith("/rest/api/3/search/jql") + assert oauth_post.call_args.kwargs["json"]["jql"] == ( + 'labels = "prowler-attempt-attempt_123"' + ) + + @pytest.mark.parametrize( + "response_kwargs, side_effect, expected_outcome, expected_code, retry_after", + [ + ( + {"status_code": 429, "headers": {"Retry-After": "9"}}, + None, + JiraIssueSearchOutcome.RETRYABLE_FAILURE, + "jira_http_429", + "9", + ), + ( + {"json_error": ValueError("invalid JSON")}, + None, + JiraIssueSearchOutcome.UNKNOWN, + "malformed_response", + None, + ), + ( + {}, + requests.exceptions.ReadTimeout(), + JiraIssueSearchOutcome.RETRYABLE_FAILURE, + "transport_failure", + None, + ), + ( + {"payload": {"issues": [], "nextPageToken": "repeated"}}, + None, + JiraIssueSearchOutcome.UNKNOWN, + "pagination_stalled", + None, + ), + ], + ) + def test_search_issues_by_delivery_attempt_classifies_failures( + self, + oauth_post, + jira_response, + response_kwargs, + side_effect, + expected_outcome, + expected_code, + retry_after, + ): + oauth_post.return_value = jira_response(**response_kwargs) + oauth_post.side_effect = side_effect + result = self.jira_integration.search_issues_by_delivery_attempt("attempt-123") + + assert result.outcome == expected_outcome + assert result.error_code == expected_code + assert result.retry_after == retry_after + + @patch.object(Jira, "get_access_token", side_effect=JiraGetAccessTokenError()) + @patch("prowler.lib.outputs.jira.jira.requests.post") + def test_search_issues_by_delivery_attempt_access_token_error_returns_unknown( + self, mock_post, mock_get_access_token + ): + result = self.jira_integration.search_issues_by_delivery_attempt("attempt-123") + + assert result.outcome == JiraIssueSearchOutcome.UNKNOWN + assert result.error_code == "authentication_failed" + mock_get_access_token.assert_called_once_with() + mock_post.assert_not_called() + + @pytest.mark.parametrize( + "issue", + [ + {"id": "not-an-id", "key": "SEC-1"}, + {"id": "10001", "key": "invalid key"}, + ], + ) + def test_search_issues_by_delivery_attempt_rejects_invalid_identity( + self, oauth_post, jira_response, issue + ): + self.jira_integration._site_url = "https://example.atlassian.net" + oauth_post.return_value = jira_response(200, {"issues": [issue]}) + result = self.jira_integration.search_issues_by_delivery_attempt("attempt-123") + + assert result.outcome == JiraIssueSearchOutcome.UNKNOWN + assert result.error_code == "malformed_issue" + + @patch("prowler.lib.outputs.jira.jira.requests.get") + def test_get_cloud_id_captures_site_url(self, mock_get): + """Test that the OAuth cloud id lookup records the site URL.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = [ + {"id": "cloud-1", "url": "https://oauth-site.atlassian.net"} + ] + mock_get.return_value = mock_response + + assert self.jira_integration.get_cloud_id("token") == "cloud-1" + assert self.jira_integration.site_url == "https://oauth-site.atlassian.net" + @patch.object(Jira, "get_access_token", return_value="valid_access_token") @patch.object( Jira, "cloud_id", new_callable=PropertyMock, return_value="test_cloud_id" @@ -2022,15 +3062,12 @@ class TestJiraIntegration: mock_get_access_token, ): """Test that Jira summary is sent as one line.""" - # 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_issue_types = mock_get_issue_types - - mock_response = MagicMock() - mock_response.status_code = 201 - mock_response.json.return_value = {"id": "ISSUE-123", "key": "TEST-123"} + mock_response = MagicMock(status_code=201, headers={}) + mock_response.json.return_value = {"id": "10001", "key": "TEST-123"} mock_post.return_value = mock_response long_check_id = "check\nwith\rcontrol\tcharacters " + "x" * 260 @@ -2045,7 +3082,7 @@ class TestJiraIntegration: grouped_resources=[], ) - assert result is True + assert result.issue_key == "TEST-123" payload = mock_post.call_args.kwargs["json"] expected_summary = ( f"[Prowler] HIGH - {' '.join(long_check_id.split())} - " @@ -2069,35 +3106,31 @@ class TestJiraIntegration: mock_cloud_id, mock_get_access_token, ): - """Test that send_finding raises with Jira JSON error details.""" - # To disable vulture + """Test that a definitive Jira 400 is a confirmed rejection.""" mock_cloud_id = mock_cloud_id mock_get_access_token = mock_get_access_token mock_get_projects = mock_get_projects mock_get_issue_types = mock_get_issue_types - - # Mock failed response - mock_response = MagicMock() - mock_response.status_code = 400 + mock_response = MagicMock(status_code=400, headers={}) mock_response.json.return_value = { "errors": {"Team": "Team is required."}, "errorMessages": ["Field 'Team' cannot be set."], } mock_post.return_value = mock_response - with pytest.raises(JiraSendFindingsResponseError) as error: - self.jira_integration.send_finding( - check_id="test-check", - check_title="Test Finding", - severity="High", - status="FAIL", - project_key="TEST", - issue_type="Bug", - ) + result = self.jira_integration.send_finding( + check_id="test-check", + check_title="Test Finding", + severity="High", + status="FAIL", + project_key="TEST", + issue_type="Bug", + ) - assert "Failed to create Jira issue" in str(error.value) - assert "'Team': 'Team is required.'" in str(error.value) - assert "Field 'Team' cannot be set." in str(error.value) + assert result.outcome == JiraCreationOutcome.CONFIRMED_REJECTION + assert result.error_code == "jira_http_400" + assert "'Team': 'Team is required.'" in result.error_message + assert "Field 'Team' cannot be set." in result.error_message mock_post.assert_called_once() @patch.object(Jira, "get_access_token", return_value="valid_access_token") @@ -2115,30 +3148,27 @@ class TestJiraIntegration: mock_cloud_id, mock_get_access_token, ): - """Test send_finding raises with status-code context for non-JSON errors.""" - # To disable vulture + """Test a 5xx response is uncertain even without a JSON body.""" mock_cloud_id = mock_cloud_id mock_get_access_token = mock_get_access_token mock_get_projects = mock_get_projects mock_get_issue_types = mock_get_issue_types - - mock_response = MagicMock() - mock_response.status_code = 502 + mock_response = MagicMock(status_code=502, headers={}) mock_response.json.side_effect = ValueError("No JSON body") mock_post.return_value = mock_response - with pytest.raises(JiraSendFindingsResponseError) as error: - self.jira_integration.send_finding( - check_id="test-check", - check_title="Test Finding", - severity="High", - status="FAIL", - project_key="TEST", - issue_type="Bug", - ) + result = self.jira_integration.send_finding( + check_id="test-check", + check_title="Test Finding", + severity="High", + status="FAIL", + project_key="TEST", + issue_type="Bug", + ) - assert "Failed to create Jira issue" in str(error.value) - assert "Jira returned status code 502" in str(error.value) + assert result.outcome == JiraCreationOutcome.UNCERTAIN + assert result.error_code == "jira_http_502" + assert "Jira returned status code 502" in result.error_message mock_post.assert_called_once() @patch.object(Jira, "get_access_token", return_value="valid_access_token") @@ -2156,16 +3186,12 @@ class TestJiraIntegration: mock_cloud_id, mock_get_access_token, ): - """Test that send_finding raises when custom fields cause an error.""" - # To disable vulture + """Test that required custom fields are a confirmed rejection.""" mock_cloud_id = mock_cloud_id mock_get_access_token = mock_get_access_token mock_get_projects = mock_get_projects mock_get_issue_types = mock_get_issue_types - - # Mock response with custom fields error - mock_response = MagicMock() - mock_response.status_code = 400 + mock_response = MagicMock(status_code=400, headers={}) mock_response.json.return_value = { "errors": { "customfield_10001": "This custom field is required", @@ -2174,59 +3200,57 @@ class TestJiraIntegration: } mock_post.return_value = mock_response - with pytest.raises(JiraRequiredCustomFieldsError) as error: - self.jira_integration.send_finding( - check_id="test-check", - check_title="Test Finding", - severity="High", - status="FAIL", - project_key="TEST", - issue_type="Bug", - ) + result = self.jira_integration.send_finding( + check_id="test-check", + check_title="Test Finding", + severity="High", + status="FAIL", + project_key="TEST", + issue_type="Bug", + ) - assert "Jira project requires custom fields" in str(error.value) - assert "customfield_10001" in str(error.value) + assert result.outcome == JiraCreationOutcome.CONFIRMED_REJECTION + assert "customfield_10001" in result.error_message mock_post.assert_called_once() - @patch.object( - Jira, - "get_access_token", - side_effect=JiraRefreshTokenError(message="Failed to refresh the access token"), + @pytest.mark.parametrize( + "access_token_error", + [ + JiraRefreshTokenError(message="Failed to refresh the access token"), + JiraGetAccessTokenError(message="Failed to get the access token"), + ], ) - def test_send_finding_reraises_refresh_token_error(self, mock_get_access_token): - """Test send_finding re-raises refresh token errors for API propagation.""" - # To disable vulture - mock_get_access_token = mock_get_access_token + @patch.object(Jira, "get_access_token") + def test_send_finding_access_token_errors_are_retryable( + self, mock_get_access_token, access_token_error + ): + """Test access-token failures before sending are retryable.""" + mock_get_access_token.side_effect = access_token_error + result = self.jira_integration.send_finding( + check_id="test-check", + check_title="Test Finding", + severity="High", + status="FAIL", + project_key="TEST", + issue_type="Bug", + ) - with pytest.raises(JiraRefreshTokenError) as error: - self.jira_integration.send_finding( - check_id="test-check", - check_title="Test Finding", - severity="High", - status="FAIL", - project_key="TEST", - issue_type="Bug", - ) - - assert error.value.message == "Failed to refresh the access token" + assert result.outcome == JiraCreationOutcome.RETRYABLE_FAILURE @patch.object(Jira, "get_access_token", return_value=None) def test_send_finding_reraises_no_token_error(self, mock_get_access_token): - """Test send_finding re-raises missing token errors for API propagation.""" - # To disable vulture + """Test missing credentials are a confirmed rejection.""" mock_get_access_token = mock_get_access_token + result = self.jira_integration.send_finding( + check_id="test-check", + check_title="Test Finding", + severity="High", + status="FAIL", + project_key="TEST", + issue_type="Bug", + ) - with pytest.raises(JiraNoTokenError) as error: - self.jira_integration.send_finding( - check_id="test-check", - check_title="Test Finding", - severity="High", - status="FAIL", - project_key="TEST", - issue_type="Bug", - ) - - assert error.value.message == "No token was found" + assert result.outcome == JiraCreationOutcome.CONFIRMED_REJECTION @patch.object( Jira, @@ -2238,25 +3262,19 @@ class TestJiraIntegration: def test_send_finding_reraises_refresh_token_response_error( self, mock_get_access_token ): - """Test send_finding re-raises refresh token response errors for API propagation.""" - # To disable vulture + """Test refresh response failures before sending are retryable.""" mock_get_access_token = mock_get_access_token - - with pytest.raises(JiraRefreshTokenResponseError) as error: - self.jira_integration.send_finding( - check_id="test-check", - check_title="Test Finding", - severity="High", - status="FAIL", - project_key="TEST", - issue_type="Bug", - ) - - assert ( - error.value.message - == "Failed to refresh the access token, response code did not match 200" + result = self.jira_integration.send_finding( + check_id="test-check", + check_title="Test Finding", + severity="High", + status="FAIL", + project_key="TEST", + issue_type="Bug", ) + assert result.outcome == JiraCreationOutcome.RETRYABLE_FAILURE + def test_get_headers_oauth_with_access_token(self): """Test get_headers returns correct OAuth headers with access token.""" self.jira_integration._using_basic_auth = False diff --git a/tests/lib/outputs/ocsf/ingestion_cli_test.py b/tests/lib/outputs/ocsf/ingestion_cli_test.py new file mode 100644 index 0000000000..906344b388 --- /dev/null +++ b/tests/lib/outputs/ocsf/ingestion_cli_test.py @@ -0,0 +1,132 @@ +from unittest.mock import MagicMock + +import pytest +import requests + +from prowler import __main__ as cli +from prowler.lib.outputs.ocsf.ingestion import SystemTrustStoreError + + +def test_ssl_error_uses_actionable_tls_message_before_connection_error( + monkeypatch, capsys +): + upload = MagicMock( + side_effect=requests.exceptions.SSLError("secret transport detail") + ) + monkeypatch.setattr(cli, "send_ocsf_to_api", upload) + + response = cli._send_ocsf_to_cloud("/tmp/saved-findings.ocsf.json") + + output = capsys.readouterr().out + assert response is None + assert "TLS certificate validation failed" in output + assert "hostname, validity period, and certificate chain" in output + assert "operating system trust store" in output + assert "TLS-intercepting proxy CA" in output + assert "container system CA store" in output + assert "Scan results were saved to /tmp/saved-findings.ocsf.json" in output + assert "secret transport detail" not in output + + +def test_truststore_initialization_error_uses_safe_actionable_message( + monkeypatch, capsys +): + monkeypatch.setattr( + cli, + "send_ocsf_to_api", + MagicMock(side_effect=SystemTrustStoreError("secret initialization detail")), + ) + + response = cli._send_ocsf_to_cloud("/tmp/saved-findings.ocsf.json") + + output = capsys.readouterr().out + assert response is None + assert "operating system trust store could not be initialized" in output + assert "configured CA bundle paths" in output + assert "TLS-intercepting proxy CA" in output + assert "container system CA store" in output + assert "Scan results were saved to /tmp/saved-findings.ocsf.json" in output + assert "secret initialization detail" not in output + + +def test_json_decode_error_uses_safe_api_response_message(monkeypatch, capsys): + monkeypatch.setattr( + cli, + "send_ocsf_to_api", + MagicMock( + side_effect=requests.exceptions.JSONDecodeError( + "secret response detail", "invalid", 0 + ) + ), + ) + + response = cli._send_ocsf_to_cloud("/tmp/saved-findings.ocsf.json") + + output = capsys.readouterr().out + assert response is None + assert "the API returned an invalid JSON response" in output + assert "no API key configured" not in output + assert "secret response detail" not in output + assert "Scan results were saved to /tmp/saved-findings.ocsf.json" in output + + +def test_http_error_without_response_uses_safe_fallback(monkeypatch, capsys): + monkeypatch.setattr( + cli, + "send_ocsf_to_api", + MagicMock(side_effect=requests.HTTPError("secret transport detail")), + ) + + response = cli._send_ocsf_to_cloud("/tmp/saved-findings.ocsf.json") + + output = capsys.readouterr().out + assert response is None + assert "the API request failed without a response status" in output + assert "secret transport detail" not in output + assert "Scan results were saved to /tmp/saved-findings.ocsf.json" in output + + +@pytest.mark.parametrize( + ("error", "expected_message"), + [ + ( + ValueError("missing"), + "Push to Prowler Cloud skipped: no API key configured", + ), + ( + requests.ConnectionError("offline"), + "could not reach the Prowler Cloud API", + ), + ( + requests.HTTPError(response=MagicMock(status_code=402)), + "only available with a Prowler Cloud subscription", + ), + ( + requests.HTTPError(response=MagicMock(status_code=403)), + "the API returned HTTP 403", + ), + ], +) +def test_existing_upload_errors_keep_specific_messages( + monkeypatch, capsys, error, expected_message +): + monkeypatch.setattr(cli, "send_ocsf_to_api", MagicMock(side_effect=error)) + + response = cli._send_ocsf_to_cloud("/tmp/saved-findings.ocsf.json") + + output = capsys.readouterr().out + assert response is None + assert expected_message in output + assert "Scan results were saved to /tmp/saved-findings.ocsf.json" in output + + +def test_successful_upload_returns_response(monkeypatch, capsys): + expected_response = {"data": {"id": "job-id"}} + monkeypatch.setattr( + cli, "send_ocsf_to_api", MagicMock(return_value=expected_response) + ) + + response = cli._send_ocsf_to_cloud("/tmp/saved-findings.ocsf.json") + + assert response == expected_response + assert capsys.readouterr().out == "" diff --git a/tests/lib/outputs/ocsf/ingestion_test.py b/tests/lib/outputs/ocsf/ingestion_test.py new file mode 100644 index 0000000000..3d345ecd02 --- /dev/null +++ b/tests/lib/outputs/ocsf/ingestion_test.py @@ -0,0 +1,487 @@ +import ssl +import sys +from types import SimpleNamespace +from unittest.mock import MagicMock, call + +import pytest +import requests +from requests import PreparedRequest +from requests.adapters import HTTPAdapter + +from prowler.lib.outputs.ocsf import ingestion + + +@pytest.fixture +def system_trust_session(monkeypatch): + monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False) + monkeypatch.delenv("CURL_CA_BUNDLE", raising=False) + native_context = MagicMock() + ssl_context = MagicMock(return_value=native_context) + inject_into_ssl = MagicMock() + monkeypatch.setitem( + sys.modules, + "truststore", + SimpleNamespace( + SSLContext=ssl_context, + inject_into_ssl=inject_into_ssl, + ), + ) + + session = ingestion._SystemTrustSession() + yield session, native_context, ssl_context, inject_into_ssl + session.close() + + +def test_https_origin_pool_uses_native_trust_context(system_trust_session): + session, native_context, ssl_context, _ = system_trust_session + adapter = session.get_adapter("https://private.prowler.example") + request = PreparedRequest() + request.prepare(method="POST", url="https://private.prowler.example/api/v1") + + _, pool_kwargs = adapter.build_connection_pool_key_attributes(request, verify=True) + + ssl_context.assert_called_once_with(ssl.PROTOCOL_TLS_CLIENT) + assert pool_kwargs["ssl_context"] is native_context + assert pool_kwargs["cert_reqs"] == "CERT_REQUIRED" + assert "ca_certs" not in pool_kwargs + assert "ca_cert_dir" not in pool_kwargs + + +def test_native_context_loads_requests_default_ca_bundle(monkeypatch, tmp_path): + monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False) + monkeypatch.delenv("CURL_CA_BUNDLE", raising=False) + default_ca_bundle = tmp_path / "requests-default-ca.pem" + default_ca_bundle.write_text("default CA") + native_context = MagicMock() + monkeypatch.setattr(requests.certs, "where", lambda: str(default_ca_bundle)) + monkeypatch.setitem( + sys.modules, + "truststore", + SimpleNamespace(SSLContext=MagicMock(return_value=native_context)), + ) + + session = ingestion._SystemTrustSession() + + native_context.load_verify_locations.assert_called_once_with( + cafile=str(default_ca_bundle) + ) + session.close() + + +def test_native_context_adds_requests_ca_bundle_file(monkeypatch, tmp_path): + default_ca_bundle = tmp_path / "requests-default-ca.pem" + configured_ca_bundle = tmp_path / "configured-ca.pem" + default_ca_bundle.write_text("default CA") + configured_ca_bundle.write_text("configured CA") + native_context = MagicMock() + monkeypatch.setattr(requests.certs, "where", lambda: str(default_ca_bundle)) + monkeypatch.setenv("REQUESTS_CA_BUNDLE", str(configured_ca_bundle)) + monkeypatch.delenv("CURL_CA_BUNDLE", raising=False) + monkeypatch.setitem( + sys.modules, + "truststore", + SimpleNamespace(SSLContext=MagicMock(return_value=native_context)), + ) + + session = ingestion._SystemTrustSession() + + assert native_context.load_verify_locations.call_args_list == [ + call(cafile=str(default_ca_bundle)), + call(cafile=str(configured_ca_bundle)), + ] + session.close() + + +def test_native_context_uses_curl_ca_bundle_as_fallback(monkeypatch, tmp_path): + default_ca_bundle = tmp_path / "requests-default-ca.pem" + configured_ca_bundle = tmp_path / "curl-ca.pem" + default_ca_bundle.write_text("default CA") + configured_ca_bundle.write_text("configured CA") + native_context = MagicMock() + monkeypatch.setattr(requests.certs, "where", lambda: str(default_ca_bundle)) + monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False) + monkeypatch.setenv("CURL_CA_BUNDLE", str(configured_ca_bundle)) + monkeypatch.setitem( + sys.modules, + "truststore", + SimpleNamespace(SSLContext=MagicMock(return_value=native_context)), + ) + + session = ingestion._SystemTrustSession() + + assert native_context.load_verify_locations.call_args_list == [ + call(cafile=str(default_ca_bundle)), + call(cafile=str(configured_ca_bundle)), + ] + session.close() + + +def test_requests_ca_bundle_takes_precedence_over_curl_ca_bundle(monkeypatch, tmp_path): + default_ca_bundle = tmp_path / "requests-default-ca.pem" + requests_ca_bundle = tmp_path / "requests-ca.pem" + curl_ca_bundle = tmp_path / "curl-ca.pem" + for ca_bundle in (default_ca_bundle, requests_ca_bundle, curl_ca_bundle): + ca_bundle.write_text("CA") + native_context = MagicMock() + monkeypatch.setattr(requests.certs, "where", lambda: str(default_ca_bundle)) + monkeypatch.setenv("REQUESTS_CA_BUNDLE", str(requests_ca_bundle)) + monkeypatch.setenv("CURL_CA_BUNDLE", str(curl_ca_bundle)) + monkeypatch.setitem( + sys.modules, + "truststore", + SimpleNamespace(SSLContext=MagicMock(return_value=native_context)), + ) + + session = ingestion._SystemTrustSession() + + assert native_context.load_verify_locations.call_args_list == [ + call(cafile=str(default_ca_bundle)), + call(cafile=str(requests_ca_bundle)), + ] + session.close() + + +def test_native_context_adds_configured_ca_directory(monkeypatch, tmp_path): + default_ca_bundle = tmp_path / "requests-default-ca.pem" + configured_ca_directory = tmp_path / "configured-ca-directory" + default_ca_bundle.write_text("default CA") + configured_ca_directory.mkdir() + native_context = MagicMock() + monkeypatch.setattr(requests.certs, "where", lambda: str(default_ca_bundle)) + monkeypatch.setenv("REQUESTS_CA_BUNDLE", str(configured_ca_directory)) + monkeypatch.setitem( + sys.modules, + "truststore", + SimpleNamespace(SSLContext=MagicMock(return_value=native_context)), + ) + + session = ingestion._SystemTrustSession() + + assert native_context.load_verify_locations.call_args_list == [ + call(cafile=str(default_ca_bundle)), + call(capath=str(configured_ca_directory)), + ] + session.close() + + +@pytest.mark.parametrize("invalid_bundle_source", ["default", "configured"]) +def test_invalid_ca_bundle_raises_safe_initialization_error( + monkeypatch, tmp_path, invalid_bundle_source +): + default_ca_bundle = tmp_path / "requests-default-ca.pem" + invalid_ca_bundle = tmp_path / "secret-missing-ca.pem" + default_ca_bundle.write_text("default CA") + if invalid_bundle_source == "default": + monkeypatch.setattr(requests.certs, "where", lambda: str(invalid_ca_bundle)) + monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False) + else: + monkeypatch.setattr(requests.certs, "where", lambda: str(default_ca_bundle)) + monkeypatch.setenv("REQUESTS_CA_BUNDLE", str(invalid_ca_bundle)) + monkeypatch.delenv("CURL_CA_BUNDLE", raising=False) + monkeypatch.setitem( + sys.modules, + "truststore", + SimpleNamespace(SSLContext=MagicMock(return_value=MagicMock())), + ) + + with pytest.raises( + ingestion.SystemTrustStoreError, + match="Check the configured CA bundle paths", + ) as error: + ingestion._SystemTrustSession() + + assert str(invalid_ca_bundle) not in str(error.value) + + +def test_session_does_not_inject_global_ssl_or_change_default_adapters( + system_trust_session, +): + session, _, _, inject_into_ssl = system_trust_session + unrelated_session = requests.Session() + + assert type(session.get_adapter("http://private.prowler.example")) is HTTPAdapter + assert type(unrelated_session.get_adapter("https://example.com")) is HTTPAdapter + assert type(requests.Session().get_adapter("https://example.com")) is HTTPAdapter + inject_into_ssl.assert_not_called() + + unrelated_session.close() + + +def test_environment_proxy_is_preserved_without_replacing_prepared_context( + monkeypatch, system_trust_session +): + session, _, _, _ = system_trust_session + for variable in ( + "ALL_PROXY", + "CURL_CA_BUNDLE", + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "all_proxy", + "http_proxy", + "https_proxy", + "no_proxy", + ): + monkeypatch.delenv(variable, raising=False) + monkeypatch.setenv("HTTPS_PROXY", "https://proxy.example:8443") + + settings = session.merge_environment_settings( + "https://private.prowler.example", {}, None, None, None + ) + + assert settings["verify"] is True + assert settings["proxies"]["https"] == "https://proxy.example:8443" + + +def test_https_proxy_uses_native_trust_context(monkeypatch, system_trust_session): + session, native_context, _, _ = system_trust_session + adapter = session.get_adapter("https://private.prowler.example") + proxy_manager = MagicMock() + proxy_from_url = MagicMock(return_value=proxy_manager) + monkeypatch.setattr(requests.adapters, "proxy_from_url", proxy_from_url) + + result = adapter.proxy_manager_for("https://proxy.example:8443") + + assert result is proxy_manager + assert proxy_from_url.call_args.kwargs["proxy_ssl_context"] is native_context + + +def test_socks_proxy_does_not_receive_proxy_ssl_context( + monkeypatch, system_trust_session +): + session, _, _, _ = system_trust_session + adapter = session.get_adapter("https://private.prowler.example") + socks_proxy_manager = MagicMock() + socks_proxy_factory = MagicMock(return_value=socks_proxy_manager) + monkeypatch.setattr(requests.adapters, "SOCKSProxyManager", socks_proxy_factory) + + result = adapter.proxy_manager_for("socks5h://proxy.example:1080") + + assert result is socks_proxy_manager + assert "proxy_ssl_context" not in socks_proxy_factory.call_args.kwargs + + +def test_adapter_keeps_native_verification_without_certifi(system_trust_session): + session, _, _, _ = system_trust_session + adapter = session.get_adapter("https://private.prowler.example") + connection = SimpleNamespace( + cert_reqs=None, + ca_certs=None, + ca_cert_dir=None, + cert_file=None, + key_file=None, + ) + + adapter.cert_verify( + connection, + "https://private.prowler.example", + verify=True, + cert=None, + ) + + assert connection.cert_reqs == "CERT_REQUIRED" + assert connection.ca_certs is None + assert connection.ca_cert_dir is None + + +def test_adapter_rejects_disabled_tls_verification(system_trust_session): + session, _, _, _ = system_trust_session + adapter = session.get_adapter("https://private.prowler.example") + + with pytest.raises(ValueError, match="verification is required"): + adapter.cert_verify( + MagicMock(), + "https://private.prowler.example", + verify=False, + cert=None, + ) + + +def test_adapter_does_not_retry_posts(system_trust_session): + session, _, _, _ = system_trust_session + adapter = session.get_adapter("https://private.prowler.example") + + assert adapter.max_retries.total == 0 + + +def test_truststore_is_loaded_lazily_and_initialization_errors_are_safe( + monkeypatch, +): + assert "truststore" not in ingestion.__dict__ + monkeypatch.setitem( + sys.modules, + "truststore", + SimpleNamespace(SSLContext=MagicMock(side_effect=OSError("secret detail"))), + ) + + with pytest.raises( + ingestion.SystemTrustStoreError, + match="Could not initialize the operating system trust store", + ) as error: + ingestion._SystemTrustSession() + + assert "secret detail" not in str(error.value) + + +def test_truststore_import_failure_becomes_custom_error(monkeypatch): + monkeypatch.setitem(sys.modules, "truststore", None) + + with pytest.raises(ingestion.SystemTrustStoreError): + ingestion._SystemTrustSession() + + +def test_upload_preserves_request_and_response_behavior(tmp_path, monkeypatch): + ocsf_file = tmp_path / "findings.ocsf.json" + ocsf_file.write_text('{"finding": true}') + response = MagicMock(status_code=200, text='{"data": {"id": "job-id"}}') + response.json.return_value = {"data": {"id": "job-id"}} + session = MagicMock() + uploaded_file = {} + + def capture_upload(*args, **kwargs): + filename, file_handle, content_type = kwargs["files"]["file"] + uploaded_file.update( + filename=filename, + content=file_handle.read(), + content_type=content_type, + ) + return response + + session.post.side_effect = capture_upload + monkeypatch.setattr( + ingestion, "_SystemTrustSession", MagicMock(return_value=session) + ) + + result = ingestion.send_ocsf_to_api( + str(ocsf_file), + base_url="private.prowler.example/", + api_key="safe-api-key", + timeout=17, + ) + + assert result == {"data": {"id": "job-id"}} + assert uploaded_file == { + "filename": "findings.ocsf.json", + "content": b'{"finding": true}', + "content_type": "application/json", + } + session.post.assert_called_once_with( + f"https://private.prowler.example{ingestion.cloud_api_ingestion_path}", + headers={ + "Authorization": "Api-Key safe-api-key", + "Accept": "application/vnd.api+json", + }, + files=session.post.call_args.kwargs["files"], + timeout=17, + allow_redirects=False, + ) + response.raise_for_status.assert_called_once_with() + response.json.assert_called_once_with() + session.close.assert_called_once_with() + + +@pytest.mark.parametrize( + "error", + [requests.exceptions.SSLError("TLS failed"), requests.ConnectionError("offline")], +) +def test_upload_closes_session_when_request_fails(tmp_path, monkeypatch, error): + ocsf_file = tmp_path / "findings.ocsf.json" + ocsf_file.write_text("[]") + session = MagicMock() + session.post.side_effect = error + monkeypatch.setattr( + ingestion, "_SystemTrustSession", MagicMock(return_value=session) + ) + + with pytest.raises(type(error)): + ingestion.send_ocsf_to_api( + str(ocsf_file), + base_url="https://private.prowler.example", + api_key="safe-api-key", + ) + + session.post.assert_called_once() + session.close.assert_called_once_with() + + +def test_empty_response_returns_empty_object_and_closes_session(tmp_path, monkeypatch): + ocsf_file = tmp_path / "findings.ocsf.json" + ocsf_file.write_text("[]") + response = MagicMock(status_code=200, text="") + session = MagicMock() + session.post.return_value = response + monkeypatch.setattr( + ingestion, "_SystemTrustSession", MagicMock(return_value=session) + ) + + result = ingestion.send_ocsf_to_api(str(ocsf_file), api_key="safe-api-key") + + assert result == {} + response.json.assert_not_called() + session.close.assert_called_once_with() + + +def test_upload_rejects_redirect_response(tmp_path, monkeypatch): + ocsf_file = tmp_path / "findings.ocsf.json" + ocsf_file.write_text("[]") + response = requests.Response() + response.status_code = 307 + session = MagicMock() + session.post.return_value = response + monkeypatch.setattr( + ingestion, "_SystemTrustSession", MagicMock(return_value=session) + ) + + with pytest.raises(requests.HTTPError, match="redirect") as error: + ingestion.send_ocsf_to_api(str(ocsf_file), api_key="safe-api-key") + + assert error.value.response is response + session.close.assert_called_once_with() + + +@pytest.mark.parametrize("failure_stage", ["status", "json"]) +def test_upload_closes_session_when_response_processing_fails( + tmp_path, monkeypatch, failure_stage +): + ocsf_file = tmp_path / "findings.ocsf.json" + ocsf_file.write_text("[]") + response = MagicMock(status_code=200, text="invalid-json") + if failure_stage == "status": + response.raise_for_status.side_effect = requests.HTTPError("forbidden") + else: + response.json.side_effect = ValueError("invalid response") + session = MagicMock() + session.post.return_value = response + monkeypatch.setattr( + ingestion, "_SystemTrustSession", MagicMock(return_value=session) + ) + + with pytest.raises((requests.HTTPError, ValueError)): + ingestion.send_ocsf_to_api(str(ocsf_file), api_key="safe-api-key") + + session.close.assert_called_once_with() + + +def test_missing_api_key_fails_before_creating_session(tmp_path, monkeypatch): + ocsf_file = tmp_path / "findings.ocsf.json" + ocsf_file.write_text("[]") + session_factory = MagicMock() + monkeypatch.setattr(ingestion, "cloud_api_key", None) + monkeypatch.setattr(ingestion, "_SystemTrustSession", session_factory) + + with pytest.raises(ValueError, match="Missing API key"): + ingestion.send_ocsf_to_api(str(ocsf_file)) + + session_factory.assert_not_called() + + +def test_missing_file_fails_before_creating_session(tmp_path, monkeypatch): + session_factory = MagicMock() + monkeypatch.setattr(ingestion, "_SystemTrustSession", session_factory) + + with pytest.raises(FileNotFoundError, match="OCSF file not found"): + ingestion.send_ocsf_to_api( + str(tmp_path / "missing.ocsf.json"), api_key="safe-api-key" + ) + + session_factory.assert_not_called() diff --git a/tests/lib/outputs/slack/slack_test.py b/tests/lib/outputs/slack/slack_test.py index 2ab3d3a5ae..84b581ace2 100644 --- a/tests/lib/outputs/slack/slack_test.py +++ b/tests/lib/outputs/slack/slack_test.py @@ -205,6 +205,47 @@ class TestSlackIntegration: }, ] + def test_create_message_blocks_no_findings(self): + """A scan that produced no findings must still build a valid message. + + The pass/fail percentages divide by ``stats["findings_count"]``, which is + initialised to 0 in ``prowler/lib/outputs/outputs.py``. With no findings + that raises ``ZeroDivisionError``, the handler logs it and returns + ``None``, and ``send()`` then passes ``blocks=None`` to + ``chat_postMessage``. + """ + aws_provider = set_mocked_aws_provider() + slack = Slack(SLACK_TOKEN, SLACK_CHANNEL, aws_provider) + args = "--slack" + stats = { + "total_pass": 0, + "total_fail": 0, + "total_critical_severity_pass": 0, + "total_critical_severity_fail": 0, + "total_high_severity_fail": 0, + "total_high_severity_pass": 0, + "total_medium_severity_fail": 0, + "total_medium_severity_pass": 0, + "total_low_severity_fail": 0, + "total_low_severity_pass": 0, + "resources_count": 0, + "findings_count": 0, + } + + blocks = slack.__create_message_blocks__( + f"AWS Account *{AWS_ACCOUNT_NUMBER}*", aws_logo, stats, args + ) + + assert blocks is not None, ( + "__create_message_blocks__ returned None, so send() passes " + "blocks=None to chat_postMessage" + ) + assert isinstance(blocks, list) and blocks + # The percentages must degrade to 0% rather than raising. + rendered = str(blocks) + assert "0 Passed findings* (0%)" in rendered + assert "0 Failed findings* (0%)" in rendered + def test_create_message_blocks_azure(self): aws_provider = set_mocked_azure_provider() slack = Slack(SLACK_TOKEN, SLACK_CHANNEL, aws_provider) diff --git a/tests/providers/alibabacloud/services/oss/oss_bucket_server_side_encryption_enabled/oss_bucket_server_side_encryption_enabled_test.py b/tests/providers/alibabacloud/services/oss/oss_bucket_server_side_encryption_enabled/oss_bucket_server_side_encryption_enabled_test.py new file mode 100644 index 0000000000..58df21fbf8 --- /dev/null +++ b/tests/providers/alibabacloud/services/oss/oss_bucket_server_side_encryption_enabled/oss_bucket_server_side_encryption_enabled_test.py @@ -0,0 +1,179 @@ +from unittest import mock + +from tests.providers.alibabacloud.alibabacloud_fixtures import ( + set_mocked_alibabacloud_provider, +) + + +class TestOssBucketServerSideEncryptionEnabled: + def test_bucket_with_aes256_encryption(self): + oss_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_alibabacloud_provider(), + ), + mock.patch( + "prowler.providers.alibabacloud.services.oss.oss_bucket_server_side_encryption_enabled.oss_bucket_server_side_encryption_enabled.oss_client", + new=oss_client, + ), + ): + from prowler.providers.alibabacloud.services.oss.oss_bucket_server_side_encryption_enabled.oss_bucket_server_side_encryption_enabled import ( + oss_bucket_server_side_encryption_enabled, + ) + from prowler.providers.alibabacloud.services.oss.oss_service import Bucket + + bucket = Bucket( + arn="acs:oss::1234567890:encrypted-aes", + name="encrypted-aes", + region="cn-hangzhou", + encryption_algorithm="AES256", + ) + oss_client.buckets = {bucket.arn: bucket} + + check = oss_bucket_server_side_encryption_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "server-side encryption enabled" in result[0].status_extended + assert "AES256" in result[0].status_extended + assert result[0].resource_id == "encrypted-aes" + assert result[0].resource_arn == bucket.arn + + def test_bucket_with_kms_encryption(self): + oss_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_alibabacloud_provider(), + ), + mock.patch( + "prowler.providers.alibabacloud.services.oss.oss_bucket_server_side_encryption_enabled.oss_bucket_server_side_encryption_enabled.oss_client", + new=oss_client, + ), + ): + from prowler.providers.alibabacloud.services.oss.oss_bucket_server_side_encryption_enabled.oss_bucket_server_side_encryption_enabled import ( + oss_bucket_server_side_encryption_enabled, + ) + from prowler.providers.alibabacloud.services.oss.oss_service import Bucket + + bucket = Bucket( + arn="acs:oss::1234567890:encrypted-kms", + name="encrypted-kms", + region="cn-hangzhou", + encryption_algorithm="KMS", + encryption_kms_key_id="9468da86-3509-4f8d-a61e-6eab1eac", + ) + oss_client.buckets = {bucket.arn: bucket} + + check = oss_bucket_server_side_encryption_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "KMS" in result[0].status_extended + assert "9468da86-3509-4f8d-a61e-6eab1eac" in result[0].status_extended + assert result[0].resource_id == "encrypted-kms" + assert result[0].resource_arn == bucket.arn + + def test_bucket_with_kms_key_and_sm4_data_encryption(self): + oss_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_alibabacloud_provider(), + ), + mock.patch( + "prowler.providers.alibabacloud.services.oss.oss_bucket_server_side_encryption_enabled.oss_bucket_server_side_encryption_enabled.oss_client", + new=oss_client, + ), + ): + from prowler.providers.alibabacloud.services.oss.oss_bucket_server_side_encryption_enabled.oss_bucket_server_side_encryption_enabled import ( + oss_bucket_server_side_encryption_enabled, + ) + from prowler.providers.alibabacloud.services.oss.oss_service import Bucket + + bucket = Bucket( + arn="acs:oss::1234567890:encrypted-sm4", + name="encrypted-sm4", + region="cn-hangzhou", + encryption_algorithm="KMS", + encryption_kms_key_id="00000000-1111-2222-3333-444444444444", + encryption_kms_data_algorithm="SM4", + ) + oss_client.buckets = {bucket.arn: bucket} + + check = oss_bucket_server_side_encryption_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "server-side encryption enabled" in result[0].status_extended + assert "data encryption SM4" in result[0].status_extended + assert result[0].resource_id == "encrypted-sm4" + assert result[0].resource_arn == bucket.arn + + def test_bucket_without_encryption(self): + oss_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_alibabacloud_provider(), + ), + mock.patch( + "prowler.providers.alibabacloud.services.oss.oss_bucket_server_side_encryption_enabled.oss_bucket_server_side_encryption_enabled.oss_client", + new=oss_client, + ), + ): + from prowler.providers.alibabacloud.services.oss.oss_bucket_server_side_encryption_enabled.oss_bucket_server_side_encryption_enabled import ( + oss_bucket_server_side_encryption_enabled, + ) + from prowler.providers.alibabacloud.services.oss.oss_service import Bucket + + bucket = Bucket( + arn="acs:oss::1234567890:no-encryption", + name="no-encryption", + region="cn-hangzhou", + ) + oss_client.buckets = {bucket.arn: bucket} + + check = oss_bucket_server_side_encryption_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "does not have default server-side encryption enabled" + in result[0].status_extended + ) + assert result[0].resource_id == "no-encryption" + assert result[0].resource_arn == bucket.arn + + def test_no_buckets(self): + oss_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_alibabacloud_provider(), + ), + mock.patch( + "prowler.providers.alibabacloud.services.oss.oss_bucket_server_side_encryption_enabled.oss_bucket_server_side_encryption_enabled.oss_client", + new=oss_client, + ), + ): + from prowler.providers.alibabacloud.services.oss.oss_bucket_server_side_encryption_enabled.oss_bucket_server_side_encryption_enabled import ( + oss_bucket_server_side_encryption_enabled, + ) + + oss_client.buckets = {} + + check = oss_bucket_server_side_encryption_enabled() + result = check.execute() + + assert len(result) == 0 diff --git a/tests/providers/alibabacloud/services/oss/oss_bucket_versioning_enabled/oss_bucket_versioning_enabled_test.py b/tests/providers/alibabacloud/services/oss/oss_bucket_versioning_enabled/oss_bucket_versioning_enabled_test.py new file mode 100644 index 0000000000..fcd1c44876 --- /dev/null +++ b/tests/providers/alibabacloud/services/oss/oss_bucket_versioning_enabled/oss_bucket_versioning_enabled_test.py @@ -0,0 +1,135 @@ +from unittest import mock + +from tests.providers.alibabacloud.alibabacloud_fixtures import ( + set_mocked_alibabacloud_provider, +) + + +class TestOssBucketVersioningEnabled: + def test_bucket_with_versioning_enabled(self): + oss_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_alibabacloud_provider(), + ), + mock.patch( + "prowler.providers.alibabacloud.services.oss.oss_bucket_versioning_enabled.oss_bucket_versioning_enabled.oss_client", + new=oss_client, + ), + ): + from prowler.providers.alibabacloud.services.oss.oss_bucket_versioning_enabled.oss_bucket_versioning_enabled import ( + oss_bucket_versioning_enabled, + ) + from prowler.providers.alibabacloud.services.oss.oss_service import Bucket + + bucket = Bucket( + arn="acs:oss::1234567890:versioning-enabled", + name="versioning-enabled", + region="cn-hangzhou", + versioning_status="Enabled", + ) + oss_client.buckets = {bucket.arn: bucket} + + check = oss_bucket_versioning_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "has versioning enabled" in result[0].status_extended + assert result[0].resource_id == "versioning-enabled" + assert result[0].resource_arn == bucket.arn + + def test_bucket_with_versioning_suspended(self): + oss_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_alibabacloud_provider(), + ), + mock.patch( + "prowler.providers.alibabacloud.services.oss.oss_bucket_versioning_enabled.oss_bucket_versioning_enabled.oss_client", + new=oss_client, + ), + ): + from prowler.providers.alibabacloud.services.oss.oss_bucket_versioning_enabled.oss_bucket_versioning_enabled import ( + oss_bucket_versioning_enabled, + ) + from prowler.providers.alibabacloud.services.oss.oss_service import Bucket + + bucket = Bucket( + arn="acs:oss::1234567890:versioning-suspended", + name="versioning-suspended", + region="cn-hangzhou", + versioning_status="Suspended", + ) + oss_client.buckets = {bucket.arn: bucket} + + check = oss_bucket_versioning_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "has versioning suspended" in result[0].status_extended + assert result[0].resource_id == "versioning-suspended" + assert result[0].resource_arn == bucket.arn + + def test_bucket_without_versioning(self): + oss_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_alibabacloud_provider(), + ), + mock.patch( + "prowler.providers.alibabacloud.services.oss.oss_bucket_versioning_enabled.oss_bucket_versioning_enabled.oss_client", + new=oss_client, + ), + ): + from prowler.providers.alibabacloud.services.oss.oss_bucket_versioning_enabled.oss_bucket_versioning_enabled import ( + oss_bucket_versioning_enabled, + ) + from prowler.providers.alibabacloud.services.oss.oss_service import Bucket + + bucket = Bucket( + arn="acs:oss::1234567890:no-versioning", + name="no-versioning", + region="cn-hangzhou", + ) + oss_client.buckets = {bucket.arn: bucket} + + check = oss_bucket_versioning_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "does not have versioning enabled" in result[0].status_extended + assert result[0].resource_id == "no-versioning" + assert result[0].resource_arn == bucket.arn + + def test_no_buckets(self): + oss_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_alibabacloud_provider(), + ), + mock.patch( + "prowler.providers.alibabacloud.services.oss.oss_bucket_versioning_enabled.oss_bucket_versioning_enabled.oss_client", + new=oss_client, + ), + ): + from prowler.providers.alibabacloud.services.oss.oss_bucket_versioning_enabled.oss_bucket_versioning_enabled import ( + oss_bucket_versioning_enabled, + ) + + oss_client.buckets = {} + + check = oss_bucket_versioning_enabled() + result = check.execute() + + assert len(result) == 0 diff --git a/tests/providers/alibabacloud/services/oss/oss_service_test.py b/tests/providers/alibabacloud/services/oss/oss_service_test.py index 96c822dbfc..f7fb5eabd1 100644 --- a/tests/providers/alibabacloud/services/oss/oss_service_test.py +++ b/tests/providers/alibabacloud/services/oss/oss_service_test.py @@ -133,3 +133,273 @@ def test_list_buckets_inventory_is_loaded_once_across_regions(): oss.__threading_call__(oss._list_buckets) assert get_mock.call_count == 1 + + +def _build_bucket(name="prowler-test"): + from prowler.providers.alibabacloud.services.oss.oss_service import Bucket + + return Bucket(arn=f"acs:oss::1234567890:{name}", name=name, region="ap-southeast-1") + + +def _mock_subresource_response(root_element, content): + """Mimic the dict the OSS SDK execute path returns for XML bodies.""" + return {"headers": {}, "statusCode": 200, "body": {root_element: content}} + + +def test_get_bucket_subresource_calls_execute_and_unwraps_root(): + service = _build_oss_service() + bucket = _build_bucket() + oss_client = MagicMock() + oss_client.execute.return_value = _mock_subresource_response( + "VersioningConfiguration", {"Status": "Enabled"} + ) + service.session.client.return_value = oss_client + + result = service._get_bucket_subresource( + bucket, "GetBucketVersioning", "versioning" + ) + + assert result == {"Status": "Enabled"} + service.session.client.assert_called_once_with("oss", bucket.region) + params, request, _ = oss_client.execute.call_args.args + assert params.action == "GetBucketVersioning" + assert params.pathname == "/?versioning" + assert params.method == "GET" + assert params.style == "ROA" + assert params.body_type == "xml" + assert request.host_map == {"bucket": bucket.name} + + +def test_get_bucket_subresource_returns_empty_dict_for_empty_root(): + service = _build_oss_service() + bucket = _build_bucket() + oss_client = MagicMock() + oss_client.execute.return_value = _mock_subresource_response( + "VersioningConfiguration", None + ) + service.session.client.return_value = oss_client + + assert ( + service._get_bucket_subresource(bucket, "GetBucketVersioning", "versioning") + == {} + ) + + +def test_get_bucket_encryption_parses_aes256_rule(): + service = _build_oss_service() + bucket = _build_bucket() + oss_client = MagicMock() + oss_client.execute.return_value = _mock_subresource_response( + "ServerSideEncryptionRule", + {"ApplyServerSideEncryptionByDefault": {"SSEAlgorithm": "AES256"}}, + ) + service.session.client.return_value = oss_client + + service._get_bucket_encryption(bucket) + + assert bucket.encryption_algorithm == "AES256" + assert bucket.encryption_kms_key_id == "" + assert bucket.encryption_kms_data_algorithm == "" + + +def test_get_bucket_encryption_parses_kms_rule(): + service = _build_oss_service() + bucket = _build_bucket() + oss_client = MagicMock() + oss_client.execute.return_value = _mock_subresource_response( + "ServerSideEncryptionRule", + { + "ApplyServerSideEncryptionByDefault": { + "SSEAlgorithm": "KMS", + "KMSMasterKeyID": "00000000-1111-2222-3333-444444444444", + "KMSDataEncryption": "SM4", + } + }, + ) + service.session.client.return_value = oss_client + + service._get_bucket_encryption(bucket) + + assert bucket.encryption_algorithm == "KMS" + assert bucket.encryption_kms_key_id == "00000000-1111-2222-3333-444444444444" + assert bucket.encryption_kms_data_algorithm == "SM4" + + +def test_get_bucket_encryption_no_rule_is_not_logged_as_error(): + from Tea.exceptions import TeaException + + service = _build_oss_service() + bucket = _build_bucket() + oss_client = MagicMock() + oss_client.execute.side_effect = TeaException( + { + "code": "NoSuchServerSideEncryptionRule", + "message": "No encryption rules are configured for this bucket.", + } + ) + service.session.client.return_value = oss_client + + with patch( + "prowler.providers.alibabacloud.services.oss.oss_service.logger" + ) as mock_logger: + service._get_bucket_encryption(bucket) + + mock_logger.error.assert_not_called() + assert bucket.encryption_algorithm == "" + assert bucket.encryption_kms_key_id == "" + + +def test_get_bucket_encryption_unexpected_error_is_logged(): + service = _build_oss_service() + bucket = _build_bucket() + oss_client = MagicMock() + oss_client.execute.side_effect = RuntimeError("boom") + service.session.client.return_value = oss_client + + with patch( + "prowler.providers.alibabacloud.services.oss.oss_service.logger" + ) as mock_logger: + service._get_bucket_encryption(bucket) + + mock_logger.error.assert_called_once() + assert bucket.encryption_algorithm == "" + + +def test_get_bucket_logging_parses_target(): + service = _build_oss_service() + bucket = _build_bucket() + oss_client = MagicMock() + oss_client.execute.return_value = _mock_subresource_response( + "BucketLoggingStatus", + {"LoggingEnabled": {"TargetBucket": "log-bucket", "TargetPrefix": "logs/"}}, + ) + service.session.client.return_value = oss_client + + service._get_bucket_logging(bucket) + + assert bucket.logging_enabled is True + assert bucket.logging_target_bucket == "log-bucket" + assert bucket.logging_target_prefix == "logs/" + + +def test_get_bucket_logging_disabled_when_no_target(): + service = _build_oss_service() + bucket = _build_bucket() + oss_client = MagicMock() + oss_client.execute.return_value = _mock_subresource_response( + "BucketLoggingStatus", None + ) + service.session.client.return_value = oss_client + + service._get_bucket_logging(bucket) + + assert bucket.logging_enabled is False + assert bucket.logging_target_bucket == "" + + +def test_get_bucket_versioning_parses_suspended_status(): + service = _build_oss_service() + bucket = _build_bucket() + oss_client = MagicMock() + oss_client.execute.return_value = _mock_subresource_response( + "VersioningConfiguration", {"Status": "Suspended"} + ) + service.session.client.return_value = oss_client + + service._get_bucket_versioning(bucket) + + assert bucket.versioning_status == "Suspended" + + +def test_get_bucket_acl_parses_grant(): + service = _build_oss_service() + bucket = _build_bucket() + oss_client = MagicMock() + oss_client.execute.return_value = _mock_subresource_response( + "AccessControlPolicy", + {"Owner": {"ID": "1234567890"}, "AccessControlList": {"Grant": "public-read"}}, + ) + service.session.client.return_value = oss_client + + service._get_bucket_acl(bucket) + + assert bucket.acl == "public-read" + + +def test_get_bucket_acl_parses_private_grant_value(): + service = _build_oss_service() + bucket = _build_bucket() + oss_client = MagicMock() + oss_client.execute.return_value = _mock_subresource_response( + "AccessControlPolicy", + {"Owner": {"ID": "1234567890"}, "AccessControlList": {"Grant": "private"}}, + ) + service.session.client.return_value = oss_client + + service._get_bucket_acl(bucket) + + assert bucket.acl == "private" + + +def test_get_bucket_acl_defaults_to_private_without_grant(): + service = _build_oss_service() + bucket = _build_bucket() + oss_client = MagicMock() + oss_client.execute.return_value = _mock_subresource_response( + "AccessControlPolicy", {"Owner": {"ID": "1234567890"}} + ) + service.session.client.return_value = oss_client + + service._get_bucket_acl(bucket) + + assert bucket.acl == "private" + + +def test_get_bucket_subresource_with_real_sdk_client_unwraps_xml_root(): + """Regression test against the SDK deserialization the helper works around. + + The generated ``get_bucket_*`` methods return empty response models for + XML bodies (root element kept by the gateway, dropped by the models). Drive + the real client with only the HTTP call mocked to make sure the helper still + returns the configuration after SDK upgrades. + """ + import io + + import darabonba.core as dara_core + from alibabacloud_oss20190517.client import Client as OssClient + from alibabacloud_tea_openapi import models as open_api_models + + service = _build_oss_service() + bucket = _build_bucket() + service.session.client.return_value = OssClient( + open_api_models.Config( + access_key_id="AKID", + access_key_secret="SECRET", + endpoint="oss-ap-southeast-1.aliyuncs.com", + region_id="ap-southeast-1", + ) + ) + xml = ( + b'' + b"" + b"KMS" + b"00000000-1111-2222-3333-444444444444" + b"" + ) + + class FakeHttpResponse: + status_code = 200 + headers = {"content-type": "application/xml"} + body = io.BytesIO(xml) + + with patch.object(dara_core.DaraCore, "do_action", return_value=FakeHttpResponse()): + result = service._get_bucket_subresource( + bucket, "GetBucketEncryption", "encryption" + ) + + assert result == { + "ApplyServerSideEncryptionByDefault": { + "SSEAlgorithm": "KMS", + "KMSMasterKeyID": "00000000-1111-2222-3333-444444444444", + } + } diff --git a/tests/providers/aws/aws_provider_test.py b/tests/providers/aws/aws_provider_test.py index f874ca8812..0f21877b65 100644 --- a/tests/providers/aws/aws_provider_test.py +++ b/tests/providers/aws/aws_provider_test.py @@ -16,22 +16,39 @@ 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_RETRIES_MAX_ATTEMPTS, BOTO3_USER_AGENT_EXTRA, ROLE_SESSION_NAME, + get_boto3_retries_from_env, + get_boto3_timeout_from_env, get_default_session_config, ) from prowler.providers.aws.exceptions.exceptions import ( + AWSAccessKeyIDInvalidError, AWSArgumentTypeValidationError, + AWSAssumeRoleError, AWSIAMRoleARNInvalidResourceTypeError, + AWSInvalidBoto3RetriesError, + 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 +66,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 +74,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, @@ -469,10 +490,10 @@ class TestAWSProvider: captured_sessions = [] original_get_organizations_info = AwsProvider.get_organizations_info - def capture(self, organizations_session, aws_account_id): + def capture(self, organizations_session, aws_account_id, region=None): captured_sessions.append(organizations_session) return original_get_organizations_info( - self, organizations_session, aws_account_id + self, organizations_session, aws_account_id, region ) with patch.object(AwsProvider, "get_organizations_info", capture): @@ -492,10 +513,10 @@ class TestAWSProvider: captured_sessions = [] original_get_organizations_info = AwsProvider.get_organizations_info - def capture(self, organizations_session, aws_account_id): + def capture(self, organizations_session, aws_account_id, region=None): captured_sessions.append(organizations_session) return original_get_organizations_info( - self, organizations_session, aws_account_id + self, organizations_session, aws_account_id, region ) with patch.object(AwsProvider, "get_organizations_info", capture): @@ -1181,6 +1202,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 +1228,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 +1330,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 +1590,582 @@ 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" + # Refreshing the credentials later goes straight to the region that answered + assert assumed_role_info.sts_region == AWS_REGION_GOV_CLOUD_US_WEST_1 + + def test_assume_role_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.assume_role.side_effect = botocore.exceptions.ClientError( + {"Error": {"Code": "AccessDenied", "Message": "denied"}}, + "AssumeRole", + ) + 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, + ): + with raises(AWSAssumeRoleError): + AwsProvider.assume_role(current_session, assumed_role_info) + + assert attempted_regions == [AWS_REGION_GOV_CLOUD_US_EAST_1] + assert assumed_role_info.sts_region == AWS_REGION_GOV_CLOUD_US_EAST_1 + + def test_test_connection_role_validates_where_the_role_was_assumed( + self, monkeypatch + ): + monkeypatch.setenv("PROWLER_AWS_PARTITION", AWS_GOV_CLOUD_PARTITION) + monkeypatch.delenv("AWS_DEFAULT_REGION", raising=False) + attempted_calls = [] + + def create_sts_session(session, aws_region): + if aws_region == AWS_REGION_GOV_CLOUD_US_EAST_1: + attempted_calls.append(aws_region) + raise botocore.exceptions.ConnectTimeoutError( + endpoint_url=f"https://sts.{aws_region}.amazonaws.com" + ) + sts_client = mock.MagicMock() + + def assume_role(**_): + attempted_calls.append(("AssumeRole", aws_region)) + return { + "Credentials": { + "AccessKeyId": "AKIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "secret", + "SessionToken": "token", + "Expiration": datetime.now() + timedelta(seconds=3600), + } + } + + def get_caller_identity(): + attempted_calls.append(("GetCallerIdentity", aws_region)) + return { + "UserId": "test-user-id", + "Account": AWS_ACCOUNT_NUMBER, + "Arn": AWS_GOV_CLOUD_ACCOUNT_ARN, + } + + sts_client.assume_role.side_effect = assume_role + sts_client.get_caller_identity.side_effect = get_caller_identity + return sts_client + + with patch( + "prowler.providers.aws.aws_provider.AwsProvider.create_sts_session", + new=create_sts_session, + ): + 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 + # The unreachable region is paid for once, not again for the validation + assert attempted_calls == [ + AWS_REGION_GOV_CLOUD_US_EAST_1, + ("AssumeRole", AWS_REGION_GOV_CLOUD_US_WEST_1), + ("GetCallerIdentity", AWS_REGION_GOV_CLOUD_US_WEST_1), + ] + + @mock_aws + def test_aws_set_up_session_assumes_the_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) + 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 + + 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): + AwsSetUpSession( + role_arn=f"arn:{AWS_GOV_CLOUD_PARTITION}:iam::{AWS_ACCOUNT_NUMBER}:role/test-role", + session_duration=900, + external_id="test-external-id", + role_session_name=ROLE_SESSION_NAME, + aws_access_key_id="testing", + aws_secret_access_key="testing", + ) + + assert sts_regions == [AWS_REGION_GOV_CLOUD_US_WEST_1] + + 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_aws_provider_queries_organizations_where_validation_got_an_answer( + self, monkeypatch + ): + monkeypatch.setenv("PROWLER_AWS_PARTITION", AWS_GOV_CLOUD_PARTITION) + # No region in the session: Organizations would resolve to us-east-1 + monkeypatch.delenv("AWS_DEFAULT_REGION", raising=False) + monkeypatch.delenv("AWS_REGION", raising=False) + 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, + ) + organizations_regions = [] + + def get_organizations_metadata(aws_account_id, session, region=None): + organizations_regions.append(region) + return {}, {}, {} + + with ( + patch( + "prowler.providers.aws.aws_provider.AwsProvider.validate_credentials", + return_value=answered, + ), + patch( + "prowler.providers.aws.aws_provider.get_organizations_metadata", + side_effect=get_organizations_metadata, + ), + ): + AwsProvider() + + assert organizations_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 +2483,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 +2754,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 +2763,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 +2949,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 +3436,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 +3446,210 @@ 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_default_session_config_retries_from_env(self): + with mock.patch.dict( + os.environ, {"PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS": "1"} + ): + config = get_default_session_config() + + assert config.retries == {"max_attempts": 1, "mode": "standard"} + + def test_get_default_session_config_retries_from_env_0_disables_retries(self): + with mock.patch.dict( + os.environ, {"PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS": "0"} + ): + config = get_default_session_config() + + assert config.retries == {"max_attempts": 0, "mode": "standard"} + + def test_set_session_config_argument_overrides_env_retries(self): + with mock.patch.dict( + os.environ, {"PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS": "1"} + ): + config = AwsProvider.set_session_config(5) + + assert config.retries == {"max_attempts": 5, "mode": "standard"} + + @mock_aws + def test_aws_provider_without_retries_argument_uses_env_retries(self): + with mock.patch.dict( + os.environ, {"PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS": "0"} + ): + aws_provider = AwsProvider() + 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 + + @mock_aws + def test_aws_provider_retries_argument_overrides_env_retries(self): + with mock.patch.dict( + os.environ, {"PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS": "0"} + ): + aws_provider = AwsProvider(retries_max_attempts=7) + client = aws_provider.session.current_session.client( + "ec2", region_name=AWS_REGION_US_EAST_1 + ) + + assert client.meta.config.retries["total_max_attempts"] == 8 + + @mock_aws + def test_aws_set_up_session_without_retries_argument_uses_env_retries(self): + with mock.patch.dict( + os.environ, {"PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS": "1"} + ): + aws_session = AwsSetUpSession( + aws_access_key_id="testing", + aws_secret_access_key="testing", + ) + client = aws_session._session.current_session.client( + "ec2", region_name=AWS_REGION_US_EAST_1 + ) + + assert client.meta.config.retries["total_max_attempts"] == 2 + + def test_test_connection_session_uses_env_retries(self): + with ( + mock.patch.dict( + os.environ, {"PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS": "0"} + ), + 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, + ), + ) 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 + validated_session = mock_validate_credentials.call_args.args[0] + assert validated_session._session.get_default_client_config().retries == { + "max_attempts": 0, + "mode": "standard", + } + + @pytest.mark.parametrize("raw", ["-1", "three", "1.5"]) + def test_get_boto3_retries_from_env_rejects_anything_but_non_negative_integers( + self, raw + ): + with mock.patch.dict( + os.environ, {"PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS": raw} + ): + with raises( + AWSInvalidBoto3RetriesError, + match="PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS", + ): + get_boto3_retries_from_env("PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS", 3) + + def test_get_boto3_retries_from_env_blank_falls_back_to_default(self): + with mock.patch.dict( + os.environ, {"PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS": " "} + ): + assert ( + get_boto3_retries_from_env( + "PROWLER_AWS_BOTO3_RETRIES_MAX_ATTEMPTS", BOTO3_RETRIES_MAX_ATTEMPTS + ) + == BOTO3_RETRIES_MAX_ATTEMPTS + ) + + 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/lib/organizations/organizations_test.py b/tests/providers/aws/lib/organizations/organizations_test.py index a2d8ad0441..a49199b15b 100644 --- a/tests/providers/aws/lib/organizations/organizations_test.py +++ b/tests/providers/aws/lib/organizations/organizations_test.py @@ -238,6 +238,17 @@ class Test_AWS_Organizations: assert default_config is not None assert BOTO3_USER_AGENT_EXTRA in default_config.user_agent_extra + def test_get_organizations_metadata_uses_the_given_region(self): + # A session with no region resolves Organizations to the commercial + # global endpoint, unreachable from GovCloud + wrapper = MagicMock(wraps=boto3.Session()) + + get_organizations_metadata("123456789012", wrapper, region="us-gov-west-1") + + wrapper.client.assert_called_once_with( + "organizations", region_name="us-gov-west-1" + ) + def test_parse_organizations_metadata_with_empty_ou_metadata(self): tags = {"Tags": []} metadata = { diff --git a/tests/providers/aws/services/batch/batch_service_test.py b/tests/providers/aws/services/batch/batch_service_test.py index 7af2a64bb2..de47efd1ba 100644 --- a/tests/providers/aws/services/batch/batch_service_test.py +++ b/tests/providers/aws/services/batch/batch_service_test.py @@ -1,6 +1,7 @@ from unittest.mock import patch import botocore +from botocore.exceptions import ClientError from prowler.providers.aws.services.batch.batch_service import Batch from tests.providers.aws.utils import ( @@ -11,6 +12,16 @@ from tests.providers.aws.utils import ( make_api_call = botocore.client.BaseClient._make_api_call +COMPUTE_ENVIRONMENT_NAME = "test-compute-environment" +COMPUTE_ENVIRONMENT_ARN = f"arn:aws:batch:eu-west-1:123456789012:compute-environment/{COMPUTE_ENVIRONMENT_NAME}" + + +def default_api_call(self, operation_name, kwarg): + """Fall back to the real call, stubbing the APIs a test does not exercise.""" + if operation_name == "DescribeComputeEnvironments": + return {"computeEnvironments": []} + return make_api_call(self, operation_name, kwarg) + def mock_make_api_call(self, operation_name, kwarg): if operation_name == "DescribeJobDefinitions": @@ -31,7 +42,7 @@ def mock_make_api_call(self, operation_name, kwarg): } ] } - return make_api_call(self, operation_name, kwarg) + return default_api_call(self, operation_name, kwarg) def mock_generate_regional_clients(provider, service): @@ -114,7 +125,7 @@ class Test_Batch_Service: def mock_make_api_call_empty(self, operation_name, kwarg): if operation_name == "DescribeJobDefinitions": return {"jobDefinitions": []} - return make_api_call(self, operation_name, kwarg) + return default_api_call(self, operation_name, kwarg) with patch( "botocore.client.BaseClient._make_api_call", @@ -144,7 +155,7 @@ class Test_Batch_Service: for i in (3, 2, 1) ] } - return make_api_call(self, operation_name, kwarg) + return default_api_call(self, operation_name, kwarg) with patch( "botocore.client.BaseClient._make_api_call", new=counting_make_api_call @@ -176,7 +187,7 @@ class Test_Batch_Service: for i in (3, 2, 1) ] } - return make_api_call(self, operation_name, kwarg) + return default_api_call(self, operation_name, kwarg) with patch( "botocore.client.BaseClient._make_api_call", new=counting_make_api_call @@ -190,6 +201,134 @@ class Test_Batch_Service: assert [jd.revision for jd in batch.job_definitions.values()] == [3, 2] assert len(describe_calls) == 1 + def test_describe_compute_environments(self): + def compute_environments_api_call(self, operation_name, kwarg): + if operation_name == "DescribeComputeEnvironments": + return { + "computeEnvironments": [ + { + "computeEnvironmentName": COMPUTE_ENVIRONMENT_NAME, + "computeEnvironmentArn": COMPUTE_ENVIRONMENT_ARN, + "computeResources": { + "securityGroupIds": ["sg-1", "sg-2"], + "subnets": ["subnet-1"], + }, + } + ] + } + return mock_make_api_call(self, operation_name, kwarg) + + with patch( + "botocore.client.BaseClient._make_api_call", + new=compute_environments_api_call, + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + batch = Batch(aws_provider) + + assert len(batch.compute_environments) == 1 + compute_environment = batch.compute_environments[COMPUTE_ENVIRONMENT_ARN] + assert compute_environment.name == COMPUTE_ENVIRONMENT_NAME + assert compute_environment.arn == COMPUTE_ENVIRONMENT_ARN + assert compute_environment.region == AWS_REGION_EU_WEST_1 + assert compute_environment.security_groups == ["sg-1", "sg-2"] + assert compute_environment.subnets == ["subnet-1"] + assert batch.security_groups_in_use == {"sg-1", "sg-2"} + + @patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call) + def test_no_compute_environments(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + batch = Batch(aws_provider) + + assert len(batch.compute_environments) == 0 + assert batch.security_groups_in_use == set() + assert batch.compute_environment_lookup_failed_regions == set() + + def test_compute_environments_access_denied(self): + def access_denied_api_call(self, operation_name, kwarg): + if operation_name == "DescribeComputeEnvironments": + raise ClientError( + { + "Error": { + "Code": "AccessDeniedException", + "Message": "User is not authorized to perform: batch:DescribeComputeEnvironments", + } + }, + operation_name, + ) + return mock_make_api_call(self, operation_name, kwarg) + + with patch( + "botocore.client.BaseClient._make_api_call", new=access_denied_api_call + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + batch = Batch(aws_provider) + + # The associations are unknown, not absent + assert batch.compute_environments == {} + assert batch.security_groups_in_use == set() + assert batch.compute_environment_lookup_failed_regions == { + AWS_REGION_EU_WEST_1 + } + # Unrelated discovery is unaffected + assert len(batch.job_definitions) == 1 + + def test_compute_environment_without_compute_resources(self): + def unmanaged_api_call(self, operation_name, kwarg): + # UNMANAGED compute environments carry no `computeResources` block + if operation_name == "DescribeComputeEnvironments": + return { + "computeEnvironments": [ + { + "computeEnvironmentName": COMPUTE_ENVIRONMENT_NAME, + "computeEnvironmentArn": COMPUTE_ENVIRONMENT_ARN, + } + ] + } + return mock_make_api_call(self, operation_name, kwarg) + + with patch("botocore.client.BaseClient._make_api_call", new=unmanaged_api_call): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + batch = Batch(aws_provider) + + assert len(batch.compute_environments) == 1 + compute_environment = batch.compute_environments[COMPUTE_ENVIRONMENT_ARN] + assert compute_environment.security_groups == [] + assert compute_environment.subnets == [] + assert batch.security_groups_in_use == set() + + def test_audit_resources_filters_compute_environments(self): + def compute_environments_api_call(self, operation_name, kwarg): + if operation_name == "DescribeComputeEnvironments": + return { + "computeEnvironments": [ + { + "computeEnvironmentName": f"compute-environment-{i}", + "computeEnvironmentArn": f"arn:aws:batch:eu-west-1:123456789012:compute-environment/compute-environment-{i}", + "computeResources": { + "securityGroupIds": [f"sg-{i}"], + "subnets": ["subnet-1"], + }, + } + for i in (1, 2) + ] + } + return mock_make_api_call(self, operation_name, kwarg) + + with patch( + "botocore.client.BaseClient._make_api_call", + new=compute_environments_api_call, + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + aws_provider._audit_resources = [ + "arn:aws:batch:eu-west-1:123456789012:compute-environment/compute-environment-1" + ] + batch = Batch(aws_provider) + + assert list(batch.compute_environments) == [ + "arn:aws:batch:eu-west-1:123456789012:compute-environment/compute-environment-1" + ] + assert batch.security_groups_in_use == {"sg-1"} + def test_audit_resources_filters_job_definitions(self): def counting_make_api_call(self, operation_name, kwarg): if operation_name == "DescribeJobDefinitions": @@ -207,7 +346,7 @@ class Test_Batch_Service: for i in (1, 2) ] } - return make_api_call(self, operation_name, kwarg) + return default_api_call(self, operation_name, kwarg) with patch( "botocore.client.BaseClient._make_api_call", new=counting_make_api_call 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/bedrock/bedrock_agent_role_not_shared_across_agents/bedrock_agent_role_not_shared_across_agents_test.py b/tests/providers/aws/services/bedrock/bedrock_agent_role_not_shared_across_agents/bedrock_agent_role_not_shared_across_agents_test.py new file mode 100644 index 0000000000..60cc40079c --- /dev/null +++ b/tests/providers/aws/services/bedrock/bedrock_agent_role_not_shared_across_agents/bedrock_agent_role_not_shared_across_agents_test.py @@ -0,0 +1,633 @@ +from unittest import mock + +import botocore +from botocore.exceptions import ClientError +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + AWS_REGION_US_WEST_2, + set_mocked_aws_provider, +) + +make_api_call = botocore.client.BaseClient._make_api_call + +AGENT_A_ID = "test-agent-a" +AGENT_A_NAME = "agent-alpha" +AGENT_A_ARN = ( + f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:agent/{AGENT_A_ID}" +) +AGENT_B_ID = "test-agent-b" +AGENT_B_NAME = "agent-bravo" +AGENT_B_ARN = ( + f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:agent/{AGENT_B_ID}" +) +AGENT_C_ID = "test-agent-c" +AGENT_C_NAME = "agent-charlie" + +SHARED_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/SharedAgentRole" +ROLE_A_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/AgentAlphaRole" +ROLE_B_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/AgentBravoRole" + +# Operations the BedrockAgent constructor calls that these tests do not exercise. +_UNUSED_OPERATIONS = ( + "ListPrompts", + "GetPrompt", + "ListTagsForResource", + "ListKnowledgeBases", + "ListDataSources", + "GetDataSource", +) + +# These scenarios describe agents with no alias, so no version is deployed and +# the draft role is the only one in play. ListAgentAliases is stubbed empty +# rather than left to moto, which does not implement it: an unstubbed call would +# leave the version inventory unread and correctly downgrade every PASS to +# MANUAL, masking what these tests are actually asserting. +_NO_ALIASES = {"agentAliasSummaries": []} + + +def _agent_mock(agents, fail_get_for=()): + """Build a _make_api_call replacement returning the given agents. + + Args: + agents: list of (agentId, agentName, roleArn) tuples. + fail_get_for: agent ids whose GetAgent call must raise. + """ + + def _mock(self, operation_name, kwarg): + if operation_name in _UNUSED_OPERATIONS: + return {} + if operation_name == "ListAgentAliases": + return _NO_ALIASES + if operation_name == "ListAgents": + return { + "agentSummaries": [ + { + "agentId": agent_id, + "agentName": agent_name, + "agentStatus": "PREPARED", + } + for agent_id, agent_name, _ in agents + ] + } + if operation_name == "GetAgent": + agent_id = kwarg["agentId"] + if agent_id in fail_get_for: + raise ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "denied"}}, + operation_name, + ) + # GetAgent nests its payload under a top-level agent key. + for candidate_id, agent_name, role_arn in agents: + if candidate_id == agent_id: + agent = { + "agentId": candidate_id, + "agentName": agent_name, + "agentStatus": "PREPARED", + } + if role_arn is not None: + agent["agentResourceRoleArn"] = role_arn + return {"agent": agent} + return {"agent": {}} + return make_api_call(self, operation_name, kwarg) + + return _mock + + +_mock_dedicated_roles = _agent_mock( + [ + (AGENT_A_ID, AGENT_A_NAME, ROLE_A_ARN), + (AGENT_B_ID, AGENT_B_NAME, ROLE_B_ARN), + ] +) +_mock_shared_role = _agent_mock( + [ + (AGENT_A_ID, AGENT_A_NAME, SHARED_ROLE_ARN), + (AGENT_B_ID, AGENT_B_NAME, SHARED_ROLE_ARN), + ] +) +_mock_shared_role_three_agents = _agent_mock( + [ + (AGENT_A_ID, AGENT_A_NAME, SHARED_ROLE_ARN), + (AGENT_B_ID, AGENT_B_NAME, SHARED_ROLE_ARN), + (AGENT_C_ID, AGENT_C_NAME, SHARED_ROLE_ARN), + ] +) +_mock_single_agent = _agent_mock([(AGENT_A_ID, AGENT_A_NAME, ROLE_A_ARN)]) +_mock_role_missing = _agent_mock([(AGENT_A_ID, AGENT_A_NAME, None)]) +_mock_get_agent_fails = _agent_mock( + [(AGENT_A_ID, AGENT_A_NAME, ROLE_A_ARN)], fail_get_for=(AGENT_A_ID,) +) +# One agent's role is unreadable, so it must not count toward the other's share. +_mock_one_unreadable_one_readable = _agent_mock( + [ + (AGENT_A_ID, AGENT_A_NAME, SHARED_ROLE_ARN), + (AGENT_B_ID, AGENT_B_NAME, SHARED_ROLE_ARN), + ], + fail_get_for=(AGENT_B_ID,), +) + + +def _mock_empty(self, operation_name, kwarg): + """No agents at all.""" + if operation_name in _UNUSED_OPERATIONS: + return {} + if operation_name == "ListAgentAliases": + return _NO_ALIASES + if operation_name == "ListAgents": + return {"agentSummaries": []} + return make_api_call(self, operation_name, kwarg) + + +def _mock_unsupported_region(self, operation_name, kwarg): + """The API is not available in the audited region.""" + if operation_name in _UNUSED_OPERATIONS: + return {} + if operation_name == "ListAgentAliases": + return _NO_ALIASES + if operation_name == "ListAgents": + raise ClientError( + { + "Error": { + "Code": "ValidationException", + "Message": "Bedrock Agent is not supported in this region.", + } + }, + operation_name, + ) + return make_api_call(self, operation_name, kwarg) + + +def _inventory_mock(agents): + """Build a stub for one exhaustive-matrix inventory. + + Args: + agents: list of (name, roleArn, retrieved) tuples. retrieved=False makes + GetAgent raise for that agent. + """ + rows = [(name, name, role) for name, role, _ in agents] + fail = tuple(name for name, _, retrieved in agents if not retrieved) + return _agent_mock(rows, fail_get_for=fail) + + +def _mock_list_agents_denied(self, operation_name, kwarg): + """ListAgents is denied, so the region's agents are unknown.""" + if operation_name in _UNUSED_OPERATIONS: + return {} + if operation_name == "ListAgentAliases": + return _NO_ALIASES + if operation_name == "ListAgents": + raise ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "denied"}}, + operation_name, + ) + return make_api_call(self, operation_name, kwarg) + + +def _mock_dedicated_role_with_partial_inventory(self, operation_name, kwarg): + """us-east-1 lists one agent; us-west-2's ListAgents is denied. + + The listed agent's role is used by no other KNOWN agent, but an unlisted + Region could hold one sharing it, so PASS must not be asserted. + """ + if operation_name in _UNUSED_OPERATIONS: + return {} + if operation_name == "ListAgentAliases": + return _NO_ALIASES + region = self.meta.region_name + if operation_name == "ListAgents": + if region == AWS_REGION_US_EAST_1: + return { + "agentSummaries": [ + { + "agentId": AGENT_A_ID, + "agentName": AGENT_A_NAME, + "agentStatus": "PREPARED", + } + ] + } + raise ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "denied"}}, + operation_name, + ) + if operation_name == "GetAgent": + return { + "agent": { + "agentId": AGENT_A_ID, + "agentName": AGENT_A_NAME, + "agentStatus": "PREPARED", + "agentResourceRoleArn": ROLE_A_ARN, + } + } + return make_api_call(self, operation_name, kwarg) + + +class Test_bedrock_agent_role_not_shared_across_agents: + """Unit tests for the bedrock_agent_role_not_shared_across_agents check.""" + + def _run(self): + """Import the service + check under the active mocks and execute.""" + from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent + + 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( + "prowler.providers.aws.services.bedrock.bedrock_agent_role_not_shared_across_agents.bedrock_agent_role_not_shared_across_agents.bedrock_agent_client", + new=BedrockAgent(aws_provider), + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_agent_role_not_shared_across_agents.bedrock_agent_role_not_shared_across_agents import ( + bedrock_agent_role_not_shared_across_agents, + ) + + return bedrock_agent_role_not_shared_across_agents().execute() + + def _run_multi_region(self): + """Same as _run but with two Regions in scope.""" + from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_US_WEST_2] + ) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_agent_role_not_shared_across_agents.bedrock_agent_role_not_shared_across_agents.bedrock_agent_client", + new=BedrockAgent(aws_provider), + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_agent_role_not_shared_across_agents.bedrock_agent_role_not_shared_across_agents import ( + bedrock_agent_role_not_shared_across_agents, + ) + + return bedrock_agent_role_not_shared_across_agents().execute() + + @mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_empty) + @mock_aws + def test_no_resources(self): + """No resources means no findings, not a spurious FAIL.""" + assert self._run() == [] + + @mock.patch( + "botocore.client.BaseClient._make_api_call", new=_mock_unsupported_region + ) + @mock_aws + def test_region_not_supported(self): + """A ValidationException from the region must not raise; it yields no findings.""" + assert self._run() == [] + + @mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_single_agent) + @mock_aws + def test_single_agent_passes(self): + """The only agent in the account cannot be sharing its role.""" + result = self._run() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == AGENT_A_ID + assert result[0].resource_arn == AGENT_A_ARN + assert result[0].region == AWS_REGION_US_EAST_1 + assert ( + result[0].status_extended + == f"Bedrock Agent {AGENT_A_NAME} has a dedicated execution role in region {AWS_REGION_US_EAST_1}." + ) + + @mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_dedicated_roles) + @mock_aws + def test_distinct_roles_pass(self): + """Two agents with distinct roles are both compliant.""" + result = self._run() + assert len(result) == 2 + assert {report.status for report in result} == {"PASS"} + + @mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_shared_role) + @mock_aws + def test_shared_role_fails_for_both_agents(self): + """A role used by two agents fails for each of them, naming the other.""" + result = self._run() + assert len(result) == 2 + assert {report.status for report in result} == {"FAIL"} + by_id = {report.resource_id: report for report in result} + assert SHARED_ROLE_ARN in by_id[AGENT_A_ID].status_extended + assert AGENT_B_NAME in by_id[AGENT_A_ID].status_extended + assert AGENT_A_NAME in by_id[AGENT_B_ID].status_extended + assert "cannot attribute an action" in by_id[AGENT_A_ID].status_extended + + @mock.patch( + "botocore.client.BaseClient._make_api_call", new=_mock_shared_role_three_agents + ) + @mock_aws + def test_shared_role_names_others_sorted(self): + """The other sharing agents are listed in sorted order for determinism.""" + result = self._run() + assert len(result) == 3 + assert {report.status for report in result} == {"FAIL"} + by_id = {report.resource_id: report for report in result} + assert ( + f"with {AGENT_B_NAME}, {AGENT_C_NAME} in region" + in by_id[AGENT_A_ID].status_extended + ) + assert ( + f"with {AGENT_A_NAME}, {AGENT_C_NAME} in region" + in by_id[AGENT_B_ID].status_extended + ) + + @mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_role_missing) + @mock_aws + def test_role_absent_is_manual_not_pass(self): + """An agent whose GetAgent returned no role must not be reported as compliant.""" + result = self._run() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "could not be retrieved" in result[0].status_extended + + @mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_get_agent_fails) + @mock_aws + def test_get_agent_failure_is_manual_not_pass(self): + """A failed GetAgent must not be reported as compliant.""" + result = self._run() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "could not be retrieved" in result[0].status_extended + + @mock.patch( + "botocore.client.BaseClient._make_api_call", + new=_mock_one_unreadable_one_readable, + ) + @mock_aws + def test_unreadable_agent_does_not_inflate_share_count(self): + """An excluded agent must neither manufacture a FAIL nor allow a PASS. + + The unreadable agent is kept out of the share index, so the readable agent + is not reported as sharing. But its role is unknown and could be the same + one, so the readable agent cannot be asserted dedicated either: both are + MANUAL, and neither is FAIL. + """ + result = self._run() + assert len(result) == 2 + by_id = {report.resource_id: report for report in result} + assert by_id[AGENT_B_ID].status == "MANUAL" + assert by_id[AGENT_A_ID].status == "MANUAL" + assert "could not be read" in by_id[AGENT_A_ID].status_extended + # The entry names what could not be read, so the sentence stays + # grammatical for a role, a version set, or a Region alike. + assert ( + f"the execution role of {AGENT_B_NAME}" in by_id[AGENT_A_ID].status_extended + ) + assert {report.status for report in result} == {"MANUAL"} + + def test_every_inventory_shape_resolves_correctly(self): + """Exhaust the decision space instead of sampling it. + + Bedrock Agents cannot be created in every account (the service refuses new + agents for accounts without prior usage), so this check's behaviour is + pinned by enumerating every inventory of up to three agents over the cross + product of {role A, role B, no role} x {readable, unreadable}, and + asserting the verdict for each agent. A role counts as shared only when two + or more READABLE agents hold it. + + Each case is driven through the real BedrockAgent service so a renamed + service attribute breaks the test rather than passing silently. + """ + from itertools import product + + states = [ + (ROLE_A_ARN, True), + (ROLE_B_ARN, True), + (None, True), + (ROLE_A_ARN, False), + ] + + checked = 0 + for size in (1, 2, 3): + for combo in product(states, repeat=size): + agents = [ + (f"agent-{index}", role, retrieved) + for index, (role, retrieved) in enumerate(combo) + ] + + readable_per_role = {} + for _, role, retrieved in agents: + if retrieved and role: + readable_per_role[role] = readable_per_role.get(role, 0) + 1 + # Any agent whose own role could not be read leaves the picture + # incomplete, so no other agent can be asserted dedicated. A role + # already seen twice is shared regardless. + any_unresolved = any( + not retrieved or not role for _, role, retrieved in agents + ) + expected = sorted( + ( + "MANUAL" + if not retrieved or not role + else ( + "FAIL" + if readable_per_role[role] >= 2 + else ("MANUAL" if any_unresolved else "PASS") + ) + ) + for _, role, retrieved in agents + ) + + with mock.patch( + "botocore.client.BaseClient._make_api_call", + new=_inventory_mock(agents), + ): + with mock_aws(): + result = self._run() + + assert len(result) == size, combo + assert sorted(report.status for report in result) == expected, combo + assert all( + report.status_extended.endswith(".") for report in result + ), combo + checked += 1 + + # 4 + 16 + 64 inventories. + assert checked == 84 + + @mock.patch( + "botocore.client.BaseClient._make_api_call", new=_mock_list_agents_denied + ) + @mock_aws + def test_list_agents_denied_is_manual_not_silence(self): + """A denied ListAgents must report MANUAL for the region, not vanish.""" + result = self._run() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].region == AWS_REGION_US_EAST_1 + assert result[0].resource_id == "agent/unknown" + assert "could not be listed" in result[0].status_extended + assert "AccessDeniedException" in result[0].status_extended + assert result[0].status_extended.endswith(".") + + @mock.patch( + "botocore.client.BaseClient._make_api_call", + new=_mock_dedicated_role_with_partial_inventory, + ) + @mock_aws + def test_dedicated_role_is_manual_when_inventory_incomplete(self): + """A seemingly dedicated role cannot be asserted from a partial inventory. + + One Region lists an agent whose role no other known agent uses, while + another Region's ListAgents fails. An unlisted Region could hold an agent + sharing that role, so the verdict is MANUAL rather than PASS. + """ + result = self._run_multi_region() + by_status = {} + for report in result: + by_status.setdefault(report.status, []).append(report) + assert "PASS" not in by_status, [r.status_extended for r in result] + assert len(by_status["MANUAL"]) == 2 + agent_report = [ + r for r in by_status["MANUAL"] if r.resource_id != "agent/unknown" + ] + assert len(agent_report) == 1 + assert "agents in region" in agent_report[0].status_extended + assert all(r.status_extended.endswith(".") for r in result) + + @mock.patch( + "botocore.client.BaseClient._make_api_call", + new=_agent_mock( + [ + (AGENT_A_ID, AGENT_A_NAME, ROLE_A_ARN), + (AGENT_B_ID, AGENT_B_NAME, ROLE_B_ARN), + ], + fail_get_for=(AGENT_B_ID,), + ), + ) + @mock_aws + def test_unresolved_role_blocks_pass_for_a_distinct_role(self): + """One agent's unreadable role prevents asserting another's dedication. + + Agent A holds a role no other *readable* agent uses, so the old logic + returned PASS. Agent B's role could not be retrieved and may be the same + one, so PASS would be an assertion the data does not support. + """ + result = self._run() + assert len(result) == 2 + by_id = {report.resource_id: report for report in result} + assert by_id[AGENT_B_ID].status == "MANUAL" + assert by_id[AGENT_A_ID].status == "MANUAL" + assert AGENT_B_NAME in by_id[AGENT_A_ID].status_extended + assert "PASS" not in {report.status for report in result} + + +class Test_scoped_scan_still_sees_the_sharing: + """A --resource-arn scoped scan must not turn a shared role into a PASS. + + Collectors apply is_resource_filtered at COLLECTION time, so + bedrock_agent_client.agents holds only the agents the operator selected. + Whether a role is shared is a property of every agent that holds it, so an + index built from the filtered set cannot see the agent that proves the + violation -- and an operator filter is not a scan error, so nothing marks the + inventory incomplete either. The check therefore aggregates over all_agents + and filters only when emitting findings. + """ + + def _run_scoped(self, audit_resources): + """Execute the check with a scan scoped to the given resource ARNs.""" + from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + aws_provider._audit_resources = audit_resources + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + service = BedrockAgent(aws_provider) + with mock.patch( + "prowler.providers.aws.services.bedrock." + "bedrock_agent_role_not_shared_across_agents." + "bedrock_agent_role_not_shared_across_agents.bedrock_agent_client", + new=service, + ): + from prowler.providers.aws.services.bedrock.bedrock_agent_role_not_shared_across_agents.bedrock_agent_role_not_shared_across_agents import ( + bedrock_agent_role_not_shared_across_agents, + ) + + return service, bedrock_agent_role_not_shared_across_agents().execute() + + @mock_aws + @mock.patch( + "botocore.client.BaseClient._make_api_call", + new=_agent_mock( + [ + (AGENT_A_ID, AGENT_A_NAME, SHARED_ROLE_ARN), + (AGENT_B_ID, AGENT_B_NAME, SHARED_ROLE_ARN), + ] + ), + ) + def test_selecting_one_of_two_sharing_agents_still_fails(self): + """The unselected agent is what proves the role is shared. + + Only agent A is in scope, so exactly one finding is emitted -- but agent + B, filtered out of the report, still holds the same role, so agent A's + role is not dedicated. Reporting PASS here was the reproducible false + PASS a scoped scan produced. + """ + service, result = self._run_scoped([AGENT_A_ARN]) + + # The report set is narrowed; the role index is not. + assert list(service.agents) == [AGENT_A_ARN] + assert sorted(service.all_agents) == sorted([AGENT_A_ARN, AGENT_B_ARN]) + + assert len(result) == 1 + assert result[0].resource_id == AGENT_A_ID + assert result[0].resource_arn == AGENT_A_ARN + assert result[0].region == AWS_REGION_US_EAST_1 + assert result[0].status == "FAIL" + assert SHARED_ROLE_ARN in result[0].status_extended + # The out-of-scope agent is still named, because it is the evidence. + assert AGENT_B_NAME in result[0].status_extended + + @mock_aws + @mock.patch( + "botocore.client.BaseClient._make_api_call", + new=_agent_mock( + [ + (AGENT_A_ID, AGENT_A_NAME, ROLE_A_ARN), + (AGENT_B_ID, AGENT_B_NAME, ROLE_B_ARN), + ] + ), + ) + def test_scoped_scan_on_a_genuinely_dedicated_role_passes(self): + """Completeness must not manufacture a FAIL either. + + Aggregating over the whole account is only correct if a genuinely + dedicated role still passes when the scan is scoped. + """ + service, result = self._run_scoped([AGENT_A_ARN]) + + assert list(service.agents) == [AGENT_A_ARN] + assert len(service.all_agents) == 2 + assert len(result) == 1 + assert result[0].resource_id == AGENT_A_ID + assert result[0].status == "PASS" + assert "has a dedicated execution role" in result[0].status_extended + + @mock_aws + @mock.patch( + "botocore.client.BaseClient._make_api_call", + new=_agent_mock( + [ + (AGENT_A_ID, AGENT_A_NAME, SHARED_ROLE_ARN), + (AGENT_B_ID, AGENT_B_NAME, SHARED_ROLE_ARN), + ] + ), + ) + def test_unscoped_scan_is_unchanged(self): + """With no filter, every agent is both aggregated and reported.""" + service, result = self._run_scoped(None) + + assert sorted(service.agents) == sorted(service.all_agents) + assert len(result) == 2 + assert {report.status for report in result} == {"FAIL"} diff --git a/tests/providers/aws/services/bedrock/bedrock_agent_role_not_shared_across_agents/bedrock_agent_role_not_shared_across_agents_versions_test.py b/tests/providers/aws/services/bedrock/bedrock_agent_role_not_shared_across_agents/bedrock_agent_role_not_shared_across_agents_versions_test.py new file mode 100644 index 0000000000..37035b95b7 --- /dev/null +++ b/tests/providers/aws/services/bedrock/bedrock_agent_role_not_shared_across_agents/bedrock_agent_role_not_shared_across_agents_versions_test.py @@ -0,0 +1,556 @@ +"""Tests that a role shared through a deployed agent version is still reported. + +GetAgent returns only the working draft. An agent version is an immutable +snapshot that keeps the role it was cut with, and an alias routes invocations at +a specific version, so two agents whose drafts hold distinct roles can still be +invoking one shared role in production. Judging the draft alone reports that as +compliant. +""" + +from unittest import mock + +import botocore +import pytest +from botocore.exceptions import ClientError +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +make_api_call = botocore.client.BaseClient._make_api_call + +AGENT_A_ID = "test-agent-a" +AGENT_A_NAME = "agent-alpha" +AGENT_B_ID = "test-agent-b" +AGENT_B_NAME = "agent-bravo" + +DRAFT_A_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/AgentAlphaDraftRole" +DRAFT_B_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/AgentBravoDraftRole" +SHARED_VERSION_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/SharedVersionRole" +DEDICATED_VERSION_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/AlphaVersionRole" + +# Collectors the BedrockAgent constructor runs that these tests do not exercise. +_UNUSED_OPERATIONS = ( + "ListPrompts", + "GetPrompt", + "ListTagsForResource", + "ListKnowledgeBases", + "ListDataSources", + "GetDataSource", +) + +DRAFT_ROLES = {AGENT_A_ID: DRAFT_A_ARN, AGENT_B_ID: DRAFT_B_ARN} + + +def _mock( + version_roles, + routed_version="3", + fail_list_aliases=(), + fail_get_version=(), + route_draft=False, + no_aliases=(), + alias_status="PREPARED", + alias_invocation_state=None, + aliases=None, + draft_roles=DRAFT_ROLES, +): + """Build a _make_api_call replacement for a two-agent account. + + Args: + version_roles: agent id -> role ARN its deployed version was cut with. + routed_version: the version each alias routes to. + fail_list_aliases: agent ids whose ListAgentAliases must raise. + fail_get_version: agent ids whose GetAgentVersion must raise. + route_draft: route the alias at DRAFT instead of a numbered version. + no_aliases: agent ids that have no alias at all. + alias_status: agentAliasStatus each alias reports. + alias_invocation_state: aliasInvocationState each alias reports; omitted + from the response entirely when None, which is what the API does for + an alias never set to reject. + aliases: agentAliasSummaries to return instead of building one alias. + draft_roles: agent id -> role ARN returned by GetAgent. + """ + + def _call(self, operation_name, kwarg): + if operation_name in _UNUSED_OPERATIONS: + return {} + if operation_name == "ListAgents": + return { + "agentSummaries": [ + { + "agentId": AGENT_A_ID, + "agentName": AGENT_A_NAME, + "agentStatus": "PREPARED", + }, + { + "agentId": AGENT_B_ID, + "agentName": AGENT_B_NAME, + "agentStatus": "PREPARED", + }, + ] + } + if operation_name == "GetAgent": + agent_id = kwarg["agentId"] + return { + "agent": { + "agentId": agent_id, + "agentName": agent_id, + "agentStatus": "PREPARED", + "agentResourceRoleArn": draft_roles[agent_id], + } + } + if operation_name == "ListAgentAliases": + agent_id = kwarg["agentId"] + if agent_id in fail_list_aliases: + raise ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "denied"}}, + operation_name, + ) + if aliases is not None: + return {"agentAliasSummaries": aliases} + if agent_id in no_aliases: + return {"agentAliasSummaries": []} + alias = { + "agentAliasId": "alias-1", + "agentAliasName": "production", + "agentAliasStatus": alias_status, + "routingConfiguration": [ + {"agentVersion": "DRAFT" if route_draft else routed_version} + ], + } + if alias_invocation_state is not None: + alias["aliasInvocationState"] = alias_invocation_state + return {"agentAliasSummaries": [alias]} + if operation_name == "GetAgentVersion": + agent_id = kwarg["agentId"] + if agent_id in fail_get_version: + raise ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "denied"}}, + operation_name, + ) + return { + "agentVersion": { + "agentId": agent_id, + "agentName": agent_id, + "version": kwarg["agentVersion"], + "agentStatus": "PREPARED", + "agentResourceRoleArn": version_roles[agent_id], + } + } + return make_api_call(self, operation_name, kwarg) + + return _call + + +def _run(stub): + """Import the service and check under the stub and execute.""" + from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent + + check_name = "bedrock_agent_role_not_shared_across_agents" + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + with ( + mock.patch("botocore.client.BaseClient._make_api_call", new=stub), + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + ): + service = BedrockAgent(aws_provider) + with mock.patch( + f"prowler.providers.aws.services.bedrock.{check_name}.{check_name}.bedrock_agent_client", + new=service, + ): + module = __import__( + f"prowler.providers.aws.services.bedrock.{check_name}.{check_name}", + fromlist=[check_name], + ) + return service, getattr(module, check_name)().execute() + + +class Test_agent_version_roles: + """Tests for sharing judged across the draft and every routed version.""" + + @mock_aws + def test_shared_version_role_fails_despite_distinct_drafts(self): + """Distinct drafts do not make two agents dedicated. + + Both agents' aliases route at a version cut with one shared role, so + both are invoking it in production. Judging the draft alone would call + this compliant. + """ + service, results = _run( + _mock({AGENT_A_ID: SHARED_VERSION_ARN, AGENT_B_ID: SHARED_VERSION_ARN}) + ) + + for agent in service.agents.values(): + assert agent.versions_listed is True + assert agent.version_role_arns == {"3": SHARED_VERSION_ARN} + + assert len(results) == 2 + assert {report.status for report in results} == {"FAIL"} + for report in results: + assert SHARED_VERSION_ARN in report.status_extended + # The message names the version, so the reader knows the draft is + # not where the sharing lives. + assert "through deployed version 3" in report.status_extended + assert report.status_extended.endswith(".") + + @mock_aws + def test_distinct_version_roles_pass(self): + """Distinct roles on both the draft and the deployed version comply.""" + _, results = _run( + _mock( + { + AGENT_A_ID: DEDICATED_VERSION_ARN, + AGENT_B_ID: f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/BravoVersionRole", + } + ) + ) + + assert len(results) == 2 + assert {report.status for report in results} == {"PASS"} + + @mock_aws + def test_draft_sharing_still_names_no_version(self): + """Sharing on the draft is reported without a version attribution.""" + stub = _mock( + { + AGENT_A_ID: DEDICATED_VERSION_ARN, + AGENT_B_ID: f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/BravoVersionRole", + } + ) + + def _shared_draft(self, operation_name, kwarg): + if operation_name == "GetAgent": + return { + "agent": { + "agentId": kwarg["agentId"], + "agentName": kwarg["agentId"], + "agentStatus": "PREPARED", + "agentResourceRoleArn": DRAFT_A_ARN, + } + } + return stub(self, operation_name, kwarg) + + _, results = _run(_shared_draft) + + assert len(results) == 2 + assert {report.status for report in results} == {"FAIL"} + for report in results: + assert DRAFT_A_ARN in report.status_extended + assert "through deployed version" not in report.status_extended + + @mock_aws + def test_alias_routing_at_draft_needs_no_version_call(self): + """DRAFT resolves to the role GetAgent already returned.""" + service, results = _run( + _mock( + {AGENT_A_ID: SHARED_VERSION_ARN, AGENT_B_ID: SHARED_VERSION_ARN}, + route_draft=True, + ) + ) + + for agent in service.agents.values(): + assert agent.versions_listed is True + assert agent.version_role_arns == {} + + assert len(results) == 2 + assert {report.status for report in results} == {"PASS"} + + @mock_aws + def test_agent_without_an_alias_deploys_no_version(self): + """No alias means no version is reachable, so the draft is the verdict.""" + service, results = _run( + _mock( + {AGENT_A_ID: SHARED_VERSION_ARN, AGENT_B_ID: SHARED_VERSION_ARN}, + no_aliases=(AGENT_A_ID, AGENT_B_ID), + ) + ) + + for agent in service.agents.values(): + assert agent.versions_listed is True + assert agent.version_role_arns == {} + + assert {report.status for report in results} == {"PASS"} + + @mock_aws + def test_unlistable_aliases_block_pass(self): + """An unread version inventory may hold the same role, so PASS is unsafe.""" + service, results = _run( + _mock( + {AGENT_A_ID: DEDICATED_VERSION_ARN, AGENT_B_ID: DEDICATED_VERSION_ARN}, + fail_list_aliases=(AGENT_B_ID,), + ) + ) + + agent_b = next(a for a in service.agents.values() if a.id == AGENT_B_ID) + assert agent_b.versions_listed is False + assert agent_b.versions_error == "AccessDeniedException" + + assert "PASS" not in {report.status for report in results} + assert {report.status for report in results} == {"MANUAL"} + assert any( + "deployed versions of" in report.status_extended for report in results + ) + + @mock_aws + def test_unreadable_version_blocks_pass(self): + """A failed GetAgentVersion leaves that version's role unknown.""" + service, results = _run( + _mock( + {AGENT_A_ID: DEDICATED_VERSION_ARN, AGENT_B_ID: DEDICATED_VERSION_ARN}, + fail_get_version=(AGENT_A_ID,), + ) + ) + + agent_a = next(a for a in service.agents.values() if a.id == AGENT_A_ID) + assert agent_a.versions_listed is False + + assert "PASS" not in {report.status for report in results} + + @mock_aws + def test_shared_version_role_outranks_an_incomplete_inventory(self): + """A role seen on two agents is shared whatever else is missing. + + Both agents route at two versions and the second version is unreadable, + so the inventory is incomplete and the read half already shows the role + on both. A partial answer must not downgrade a definite finding, so FAIL + stands rather than MANUAL. + """ + + def _two_versions_one_unreadable(self, operation_name, kwarg): + if operation_name == "ListAgentAliases": + return { + "agentAliasSummaries": [ + { + "agentAliasId": "alias-1", + "agentAliasName": "production", + "agentAliasStatus": "PREPARED", + "routingConfiguration": [ + {"agentVersion": "2"}, + {"agentVersion": "3"}, + ], + } + ] + } + if operation_name == "GetAgentVersion" and kwarg["agentVersion"] == "3": + raise ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "denied"}}, + operation_name, + ) + return _mock( + {AGENT_A_ID: SHARED_VERSION_ARN, AGENT_B_ID: SHARED_VERSION_ARN} + )(self, operation_name, kwarg) + + service, results = _run(_two_versions_one_unreadable) + + for agent in service.agents.values(): + assert agent.versions_listed is False, "the inventory must be incomplete" + assert agent.version_role_arns == {"2": SHARED_VERSION_ARN} + + assert {report.status for report in results} == {"FAIL"} + for report in results: + assert "through deployed version 2" in report.status_extended + + +class Test_alias_must_be_invocable: + """A version only an unreachable alias routes to is not live exposure. + + Widening the audit from the working draft to every routed version closes a + false PASS and opens the symmetric false FAIL: an alias that cannot invoke + the version it points at contributes no exposure, so the role on that + version must not count as shared. Both state fields come from + ListAgentAliases -- `aliasInvocationState` is ACCEPT_INVOCATIONS | + REJECT_INVOCATIONS, `agentAliasStatus` is CREATING | PREPARED | FAILED | + UPDATING | DELETING | DISSOCIATED. + """ + + @mock_aws + def test_reject_invocations_alias_does_not_share_its_version_role(self): + """An alias set to REJECT_INVOCATIONS cannot invoke the routed version. + + Both agents' aliases route at a version cut with one shared role, so + judging routing alone reports FAIL -- but neither alias will accept an + invocation, so nothing is running under that role. + """ + service, results = _run( + _mock( + {AGENT_A_ID: SHARED_VERSION_ARN, AGENT_B_ID: SHARED_VERSION_ARN}, + alias_invocation_state="REJECT_INVOCATIONS", + ) + ) + + for agent in service.all_agents.values(): + assert agent.versions_listed is True, "the aliases WERE listed" + assert agent.version_role_arns == {}, "a rejecting alias routes nothing" + + assert len(results) == 2 + assert {report.status for report in results} == {"PASS"} + for report in results: + assert "has a dedicated execution role" in report.status_extended + + @pytest.mark.parametrize("alias_status", ["FAILED", "DELETING", "DISSOCIATED"]) + @mock_aws + def test_terminal_alias_status_does_not_share_its_version_role(self, alias_status): + """A failed, deleting or dissociated alias routes no live invocation.""" + service, results = _run( + _mock( + {AGENT_A_ID: SHARED_VERSION_ARN, AGENT_B_ID: SHARED_VERSION_ARN}, + alias_status=alias_status, + ) + ) + + for agent in service.all_agents.values(): + assert agent.version_role_arns == {} + + assert len(results) == 2 + assert {report.status for report in results} == {"PASS"} + + @pytest.mark.parametrize("alias_status", ["CREATING", "UPDATING"]) + @mock_aws + def test_in_flight_alias_status_makes_version_inventory_incomplete( + self, alias_status + ): + """CREATING and UPDATING do not prove that their version is invocable.""" + service, results = _run( + _mock( + {AGENT_A_ID: SHARED_VERSION_ARN, AGENT_B_ID: SHARED_VERSION_ARN}, + alias_status=alias_status, + ) + ) + + for agent in service.all_agents.values(): + assert agent.versions_listed is False + assert agent.version_role_arns == {} + + assert len(results) == 2 + assert {report.status for report in results} == {"MANUAL"} + for report in results: + assert "deployed versions of" in report.status_extended + + @pytest.mark.parametrize("alias_status", ["CREATING", "UPDATING"]) + @mock_aws + def test_shared_draft_role_fails_despite_in_flight_alias(self, alias_status): + """Definite draft sharing outranks an incomplete alias inventory.""" + service, results = _run( + _mock( + {AGENT_A_ID: DEDICATED_VERSION_ARN, AGENT_B_ID: DEDICATED_VERSION_ARN}, + alias_status=alias_status, + draft_roles={AGENT_A_ID: DRAFT_A_ARN, AGENT_B_ID: DRAFT_A_ARN}, + ) + ) + + assert all( + agent.versions_listed is False for agent in service.all_agents.values() + ) + assert {report.status for report in results} == {"FAIL"} + assert all(DRAFT_A_ARN in report.status_extended for report in results) + + @mock_aws + def test_prepared_accepting_alias_shares_its_version_role(self): + """A PREPARED alias accepting invocations is active.""" + service, results = _run( + _mock( + {AGENT_A_ID: SHARED_VERSION_ARN, AGENT_B_ID: SHARED_VERSION_ARN}, + alias_status="PREPARED", + alias_invocation_state="ACCEPT_INVOCATIONS", + ) + ) + + for agent in service.all_agents.values(): + assert agent.versions_listed is True + assert agent.version_role_arns == {"3": SHARED_VERSION_ARN} + + assert {report.status for report in results} == {"FAIL"} + + @mock_aws + def test_prepared_alias_sharing_fails_despite_in_flight_alias(self): + """Definite prepared-version sharing outranks incomplete inventory.""" + service, results = _run( + _mock( + {AGENT_A_ID: SHARED_VERSION_ARN, AGENT_B_ID: SHARED_VERSION_ARN}, + aliases=[ + { + "agentAliasId": "alias-prepared", + "agentAliasName": "production", + "agentAliasStatus": "PREPARED", + "routingConfiguration": [{"agentVersion": "3"}], + }, + { + "agentAliasId": "alias-updating", + "agentAliasName": "next", + "agentAliasStatus": "UPDATING", + "routingConfiguration": [{"agentVersion": "4"}], + }, + ], + ) + ) + + for agent in service.all_agents.values(): + assert agent.versions_listed is False + assert agent.version_role_arns == {"3": SHARED_VERSION_ARN} + + assert {report.status for report in results} == {"FAIL"} + assert all("through deployed version 3" in r.status_extended for r in results) + + @mock_aws + def test_absent_invocation_state_still_shares_its_version_role(self): + """aliasInvocationState is optional; absent means never set to reject. + + Reading absence as "not accepting" would report every alias that was + never explicitly enabled as dead, and a genuinely shared role as PASS. + """ + service, results = _run( + _mock( + {AGENT_A_ID: SHARED_VERSION_ARN, AGENT_B_ID: SHARED_VERSION_ARN}, + alias_invocation_state=None, + ) + ) + + for agent in service.all_agents.values(): + assert agent.version_role_arns == {"3": SHARED_VERSION_ARN} + + assert {report.status for report in results} == {"FAIL"} + + @mock_aws + def test_rejecting_alias_does_not_mask_a_shared_draft_role(self): + """The predicate must gate ROUTING only, never the draft. + + The draft role comes from GetAgent, not from an alias, so an unreachable + alias has no bearing on it. Without this, a predicate applied one level + too high would silence a genuinely shared draft role. + """ + service, results = _run( + _mock( + {AGENT_A_ID: DEDICATED_VERSION_ARN, AGENT_B_ID: DEDICATED_VERSION_ARN}, + alias_invocation_state="REJECT_INVOCATIONS", + ) + ) + # No routed version survives the predicate... + for agent in service.all_agents.values(): + assert agent.version_role_arns == {} + # ...so make both DRAFTS share one role and re-run the verdict. + for agent in service.all_agents.values(): + agent.role_arn = DRAFT_A_ARN + + check_name = "bedrock_agent_role_not_shared_across_agents" + with mock.patch( + f"prowler.providers.aws.services.bedrock.{check_name}.{check_name}" + ".bedrock_agent_client", + new=service, + ): + module = __import__( + f"prowler.providers.aws.services.bedrock.{check_name}.{check_name}", + fromlist=[check_name], + ) + results = getattr(module, check_name)().execute() + + assert len(results) == 2 + assert {report.status for report in results} == {"FAIL"} + for report in results: + assert DRAFT_A_ARN in report.status_extended + # The sharing is on the draft, so no version is named. + assert "through deployed version" not in report.status_extended diff --git a/tests/providers/aws/services/bedrock/bedrock_custom_model_encrypted_with_cmk/bedrock_custom_model_encrypted_with_cmk_test.py b/tests/providers/aws/services/bedrock/bedrock_custom_model_encrypted_with_cmk/bedrock_custom_model_encrypted_with_cmk_test.py new file mode 100644 index 0000000000..ddace1cd14 --- /dev/null +++ b/tests/providers/aws/services/bedrock/bedrock_custom_model_encrypted_with_cmk/bedrock_custom_model_encrypted_with_cmk_test.py @@ -0,0 +1,287 @@ +from unittest import mock + +import botocore +from botocore.exceptions import ClientError +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +make_api_call = botocore.client.BaseClient._make_api_call + +MODEL_NAME = "test-custom-model" +MODEL_ARN = f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:custom-model/example.base-v1/{MODEL_NAME}" +KMS_KEY_ARN = f"arn:aws:kms:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:key/test-key-id" +FOREIGN_ACCOUNT = "999999999999" +FOREIGN_MODEL_ARN = f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{FOREIGN_ACCOUNT}:custom-model/example.base-v1/foreign-model" + +# Operations the Bedrock constructor calls that these tests do not exercise. +_UNUSED_OPERATIONS = ( + "GetModelInvocationLoggingConfiguration", + "ListGuardrails", + "GetGuardrail", + "ListTagsForResource", +) + + +def _custom_model_mock(kms_key_arn=None, fail_get=False): + """Build a _make_api_call replacement returning one custom model.""" + + def _mock(self, operation_name, kwarg): + if operation_name in _UNUSED_OPERATIONS: + return {} + if operation_name == "ListCustomModels": + return { + "modelSummaries": [ + {"modelArn": MODEL_ARN, "modelName": MODEL_NAME}, + ] + } + if operation_name == "GetCustomModel": + if fail_get: + raise ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "denied"}}, + operation_name, + ) + response = {"modelArn": MODEL_ARN, "modelName": MODEL_NAME} + if kms_key_arn is not None: + response["modelKmsKeyArn"] = kms_key_arn + return response + return make_api_call(self, operation_name, kwarg) + + return _mock + + +_mock_with_cmk = _custom_model_mock(KMS_KEY_ARN) +_mock_without_cmk = _custom_model_mock(None) +_mock_empty_cmk = _custom_model_mock("") +_mock_unreadable = _custom_model_mock(fail_get=True) + + +def _mock_empty(self, operation_name, kwarg): + """No custom models at all.""" + if operation_name in _UNUSED_OPERATIONS: + return {} + if operation_name == "ListCustomModels": + return {"modelSummaries": []} + return make_api_call(self, operation_name, kwarg) + + +def _mock_unsupported_region(self, operation_name, kwarg): + """The API is not available in the audited region.""" + if operation_name in _UNUSED_OPERATIONS: + return {} + if operation_name == "ListCustomModels": + raise ClientError( + { + "Error": { + "Code": "ValidationException", + "Message": "Bedrock is not supported in this region.", + } + }, + operation_name, + ) + return make_api_call(self, operation_name, kwarg) + + +def _mock_list_denied(self, operation_name, kwarg): + """ListCustomModels is denied, so the region's models are unknown.""" + if operation_name in _UNUSED_OPERATIONS: + return {} + if operation_name == "ListCustomModels": + raise ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "denied"}}, + operation_name, + ) + return make_api_call(self, operation_name, kwarg) + + +def _mock_shared_in_model(self, operation_name, kwarg): + """Assert isOwned=True is sent, and return a model only when it is. + + A caller that omits isOwned would also receive models shared into this + account through RAM, whose KMS key this account cannot set. + """ + if operation_name in _UNUSED_OPERATIONS: + return {} + if operation_name == "ListCustomModels": + if kwarg.get("isOwned") is not True: + # What the API would return without the filter: a foreign-owned + # model this account cannot remediate. + return { + "modelSummaries": [ + { + "modelArn": FOREIGN_MODEL_ARN, + "modelName": "foreign-model", + "ownerAccountId": FOREIGN_ACCOUNT, + } + ] + } + return {"modelSummaries": []} + return make_api_call(self, operation_name, kwarg) + + +class Test_bedrock_custom_model_encrypted_with_cmk: + """Unit tests for the bedrock_custom_model_encrypted_with_cmk check.""" + + def _run(self): + """Import the service + check under the active mocks and execute.""" + from prowler.providers.aws.services.bedrock.bedrock_service import Bedrock + + 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( + "prowler.providers.aws.services.bedrock.bedrock_custom_model_encrypted_with_cmk.bedrock_custom_model_encrypted_with_cmk.bedrock_client", + new=Bedrock(aws_provider), + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_custom_model_encrypted_with_cmk.bedrock_custom_model_encrypted_with_cmk import ( + bedrock_custom_model_encrypted_with_cmk, + ) + + return bedrock_custom_model_encrypted_with_cmk().execute() + + @mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_empty) + @mock_aws + def test_no_resources(self): + """No resources means no findings, not a spurious FAIL.""" + assert self._run() == [] + + @mock.patch( + "botocore.client.BaseClient._make_api_call", new=_mock_unsupported_region + ) + @mock_aws + def test_region_not_supported(self): + """A ValidationException from the region must not raise; it yields no findings.""" + assert self._run() == [] + + @mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_with_cmk) + @mock_aws + def test_cmk_present_passes(self): + """A model with modelKmsKeyArn set is compliant.""" + result = self._run() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == MODEL_NAME + assert result[0].resource_arn == MODEL_ARN + assert result[0].region == AWS_REGION_US_EAST_1 + assert ( + result[0].status_extended + == f"Bedrock custom model {MODEL_NAME} is encrypted with a customer-managed KMS key in region {AWS_REGION_US_EAST_1}." + ) + + @mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_without_cmk) + @mock_aws + def test_no_cmk_fails(self): + """An absent modelKmsKeyArn means an AWS-owned key is in use.""" + result = self._run() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "is not encrypted with a customer-managed KMS key" in ( + result[0].status_extended + ) + + @mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_empty_cmk) + @mock_aws + def test_empty_cmk_fails(self): + """An empty modelKmsKeyArn string is not a key.""" + result = self._run() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "is not encrypted with a customer-managed KMS key" in ( + result[0].status_extended + ) + + @mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_unreadable) + @mock_aws + def test_detail_unreadable_is_manual_not_pass(self): + """A failed GetCustomModel must not be reported as compliant.""" + result = self._run() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "could not be retrieved" in result[0].status_extended + + @mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_list_denied) + @mock_aws + def test_list_denied_is_manual_not_silence(self): + """A denied ListCustomModels must report MANUAL, not vanish. + + Without a region-level report the region is indistinguishable from one + that genuinely holds no custom models. + """ + result = self._run() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].region == AWS_REGION_US_EAST_1 + assert result[0].resource_id == "custom-model/unknown" + assert ( + result[0].resource_arn + == f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:custom-model/unknown" + ) + assert "could not be listed" in result[0].status_extended + assert "AccessDeniedException" in result[0].status_extended + assert result[0].status_extended.endswith(".") + + @mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_shared_in_model) + @mock_aws + def test_shared_in_models_are_not_audited(self): + """ListCustomModels must be called with isOwned=True. + + The mock returns a foreign-owned model only when the filter is absent, so + a regression that drops isOwned produces a FAIL naming another account's + model — a finding this account cannot remediate. + """ + result = self._run() + assert result == [] + + def test_every_model_state_resolves_correctly(self): + """Exhaust the decision space instead of sampling it. + + A Bedrock custom model cannot be created without a model customization + job, so this check's behaviour is pinned by enumerating every combination + of retrieval outcome, key value (absent, empty, set) and regional listing + outcome, and asserting the per-model verdict plus the presence of the + region-level report. + + Each case is driven through the real Bedrock service so a renamed service + attribute breaks the test rather than passing silently. + """ + from itertools import product + + for key, fail_get, list_denied in product( + [None, "", KMS_KEY_ARN], [False, True], [False, True] + ): + case = (key, fail_get, list_denied) + stub = ( + _mock_list_denied + if list_denied + else _custom_model_mock(key, fail_get=fail_get) + ) + with mock.patch("botocore.client.BaseClient._make_api_call", new=stub): + with mock_aws(): + result = self._run() + + per_model = [ + r for r in result if "custom-model/unknown" not in r.resource_arn + ] + region_level = [ + r for r in result if "custom-model/unknown" in r.resource_arn + ] + + if list_denied: + # No model reaches the inventory; only the region-level report. + assert per_model == [], case + assert len(region_level) == 1, case + assert region_level[0].status == "MANUAL", case + else: + assert len(per_model) == 1, case + expected = "MANUAL" if fail_get else ("PASS" if key else "FAIL") + assert per_model[0].status == expected, case + assert region_level == [], case + assert all(r.status_extended.endswith(".") for r in result), case diff --git a/tests/providers/aws/services/bedrock/bedrock_guardrail_contextual_grounding_filter_enabled/bedrock_guardrail_contextual_grounding_filter_enabled_test.py b/tests/providers/aws/services/bedrock/bedrock_guardrail_contextual_grounding_filter_enabled/bedrock_guardrail_contextual_grounding_filter_enabled_test.py new file mode 100644 index 0000000000..ae19e43e00 --- /dev/null +++ b/tests/providers/aws/services/bedrock/bedrock_guardrail_contextual_grounding_filter_enabled/bedrock_guardrail_contextual_grounding_filter_enabled_test.py @@ -0,0 +1,466 @@ +from unittest import mock + +import botocore +from botocore.exceptions import ClientError +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +make_api_call = botocore.client.BaseClient._make_api_call + +GUARDRAIL_ID = "test-guardrail-id" +GUARDRAIL_NAME = "test-guardrail" +GUARDRAIL_ARN = f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:guardrail/{GUARDRAIL_ID}" + +# Operations the Bedrock constructor calls that these tests do not exercise. +_UNUSED_OPERATIONS = ( + "GetModelInvocationLoggingConfiguration", + "ListTagsForResource", + "ListCustomModels", +) + + +def _guardrail_mock(grounding_policy=None, fail_get=False): + """Build a _make_api_call replacement returning one guardrail.""" + + def _mock(self, operation_name, kwarg): + if operation_name in _UNUSED_OPERATIONS: + return {} + if operation_name == "ListGuardrails": + return { + "guardrails": [ + { + "id": GUARDRAIL_ID, + "name": GUARDRAIL_NAME, + "arn": GUARDRAIL_ARN, + "status": "READY", + } + ] + } + if operation_name == "GetGuardrail": + if fail_get: + raise ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "denied"}}, + operation_name, + ) + response = { + "guardrailId": GUARDRAIL_ID, + "guardrailArn": GUARDRAIL_ARN, + "name": GUARDRAIL_NAME, + "status": "READY", + } + if grounding_policy is not None: + response["contextualGroundingPolicy"] = grounding_policy + return response + return make_api_call(self, operation_name, kwarg) + + return _mock + + +def _filter(filter_type, threshold=0.75, action="BLOCK", enabled=True): + """Build one contextual grounding filter; a None value omits that key. + + Both action and enabled are optional members of the API shape, so omitting + either has to be expressible here to test the unknown paths. + """ + filter = {"type": filter_type, "threshold": threshold} + if action is not None: + filter["action"] = action + if enabled is not None: + filter["enabled"] = enabled + return filter + + +_mock_action_absent = _guardrail_mock( + { + "filters": [ + _filter("GROUNDING", action=None), + _filter("RELEVANCE", action=None), + ] + } +) +_mock_action_absent_one_filter = _guardrail_mock( + {"filters": [_filter("GROUNDING", action=None), _filter("RELEVANCE")]} +) +_mock_action_absent_with_zero_threshold = _guardrail_mock( + { + "filters": [ + _filter("GROUNDING", action=None, threshold=0.0), + _filter("RELEVANCE"), + ] + } +) + + +_mock_both_blocking = _guardrail_mock( + {"filters": [_filter("GROUNDING"), _filter("RELEVANCE")]} +) +_mock_no_policy = _guardrail_mock(None) +_mock_empty_filters = _guardrail_mock({"filters": []}) +_mock_missing_relevance = _guardrail_mock({"filters": [_filter("GROUNDING")]}) +_mock_missing_grounding = _guardrail_mock({"filters": [_filter("RELEVANCE")]}) +# A policy carrying only an unrecognised filter type: the policy exists, so it is +# not the "no policy" case, yet both required types are absent at once. +_mock_missing_both = _guardrail_mock({"filters": [_filter("UNKNOWN_TYPE")]}) +_mock_action_none = _guardrail_mock( + {"filters": [_filter("GROUNDING", action="NONE"), _filter("RELEVANCE")]} +) +_mock_zero_threshold = _guardrail_mock( + {"filters": [_filter("GROUNDING"), _filter("RELEVANCE", threshold=0.0)]} +) +_mock_disabled = _guardrail_mock( + {"filters": [_filter("GROUNDING", enabled=False), _filter("RELEVANCE")]} +) +_mock_both_disabled = _guardrail_mock( + { + "filters": [ + _filter("GROUNDING", enabled=False), + _filter("RELEVANCE", enabled=False), + ] + } +) +_mock_disabled_and_action_none = _guardrail_mock( + { + "filters": [ + _filter("GROUNDING", enabled=False, action="NONE"), + _filter("RELEVANCE"), + ] + } +) +_mock_enabled_absent = _guardrail_mock( + { + "filters": [ + _filter("GROUNDING", enabled=None), + _filter("RELEVANCE", enabled=None), + ] + } +) +_mock_enabled_absent_one_filter = _guardrail_mock( + {"filters": [_filter("GROUNDING", enabled=None), _filter("RELEVANCE")]} +) +_mock_enabled_absent_with_action_none = _guardrail_mock( + { + "filters": [ + _filter("GROUNDING", enabled=None, action="NONE"), + _filter("RELEVANCE", enabled=None), + ] + } +) +_mock_unreadable = _guardrail_mock(fail_get=True) + + +def _mock_empty(self, operation_name, kwarg): + """No guardrails at all.""" + if operation_name in _UNUSED_OPERATIONS: + return {} + if operation_name == "ListGuardrails": + return {"guardrails": []} + return make_api_call(self, operation_name, kwarg) + + +def _mock_list_guardrails_denied(self, operation_name, kwarg): + """ListGuardrails is denied, so the region's guardrails are unknown.""" + if operation_name in _UNUSED_OPERATIONS: + return {} + if operation_name == "ListGuardrails": + raise ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "denied"}}, + operation_name, + ) + return make_api_call(self, operation_name, kwarg) + + +def _mock_unsupported_region(self, operation_name, kwarg): + """The API is not available in the audited region.""" + if operation_name in _UNUSED_OPERATIONS: + return {} + if operation_name == "ListGuardrails": + raise ClientError( + { + "Error": { + "Code": "ValidationException", + "Message": "Bedrock is not supported in this region.", + } + }, + operation_name, + ) + return make_api_call(self, operation_name, kwarg) + + +class Test_bedrock_guardrail_contextual_grounding_filter_enabled: + """Unit tests for the bedrock_guardrail_contextual_grounding_filter_enabled check.""" + + def _run(self): + """Import the service + check under the active mocks and execute.""" + from prowler.providers.aws.services.bedrock.bedrock_service import Bedrock + + 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( + "prowler.providers.aws.services.bedrock.bedrock_guardrail_contextual_grounding_filter_enabled.bedrock_guardrail_contextual_grounding_filter_enabled.bedrock_client", + new=Bedrock(aws_provider), + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_guardrail_contextual_grounding_filter_enabled.bedrock_guardrail_contextual_grounding_filter_enabled import ( + bedrock_guardrail_contextual_grounding_filter_enabled, + ) + + return bedrock_guardrail_contextual_grounding_filter_enabled().execute() + + @mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_empty) + @mock_aws + def test_no_resources(self): + """No resources means no findings, not a spurious FAIL.""" + assert self._run() == [] + + @mock.patch( + "botocore.client.BaseClient._make_api_call", new=_mock_unsupported_region + ) + @mock_aws + def test_region_not_supported(self): + """A ValidationException from the region must not raise; it yields no findings.""" + assert self._run() == [] + + @mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_both_blocking) + @mock_aws + def test_both_filters_blocking_passes(self): + """Both filter types enabled and blocking above a zero threshold is compliant.""" + result = self._run() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == GUARDRAIL_ID + assert result[0].resource_arn == GUARDRAIL_ARN + assert result[0].region == AWS_REGION_US_EAST_1 + assert "blocks ungrounded and irrelevant responses" in result[0].status_extended + + @mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_no_policy) + @mock_aws + def test_no_grounding_policy_fails(self): + """No contextual grounding policy at all means nothing is ever detected.""" + result = self._run() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "no contextual grounding policy" in result[0].status_extended + + @mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_empty_filters) + @mock_aws + def test_empty_filter_list_fails(self): + """A policy present but carrying no filters is equivalent to no policy.""" + result = self._run() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "no contextual grounding policy" in result[0].status_extended + + @mock.patch( + "botocore.client.BaseClient._make_api_call", new=_mock_missing_relevance + ) + @mock_aws + def test_missing_relevance_filter_fails(self): + """A GROUNDING filter alone leaves irrelevant answers unchecked.""" + result = self._run() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "missing the RELEVANCE filter" in result[0].status_extended + + @mock.patch( + "botocore.client.BaseClient._make_api_call", new=_mock_missing_grounding + ) + @mock_aws + def test_missing_grounding_filter_fails(self): + """A RELEVANCE filter alone leaves unsupported answers unchecked.""" + result = self._run() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "missing the GROUNDING filter" in result[0].status_extended + + @mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_missing_both) + @mock_aws + def test_missing_both_filters_fails_with_plural_wording(self): + """Both required types can be absent at once, so the nouns must agree. + + The message lists the missing types, so hard-coding "filter" and "that + class" would read "missing the GROUNDING, RELEVANCE filter ... leaving + that class of ungrounded response unchecked". + """ + result = self._run() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "missing the GROUNDING, RELEVANCE filters" in result[0].status_extended + assert "leaving those classes of ungrounded response" in ( + result[0].status_extended + ) + assert result[0].status_extended.endswith(".") + + @mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_action_none) + @mock_aws + def test_action_none_fails(self): + """Action NONE scores and reports without blocking, so it must FAIL.""" + result = self._run() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "GROUNDING filter uses action NONE" in result[0].status_extended + assert "without blocking" in result[0].status_extended + + @mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_zero_threshold) + @mock_aws + def test_zero_threshold_fails(self): + """A threshold of 0 can never be tripped, so the filter blocks nothing.""" + result = self._run() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "RELEVANCE filter has a threshold of 0" in result[0].status_extended + + @mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_disabled) + @mock_aws + def test_disabled_filter_fails(self): + """enabled: false runs no evaluation, so BLOCK and a real threshold are inert.""" + result = self._run() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "GROUNDING filter is disabled" in result[0].status_extended + assert "its evaluation never runs" in result[0].status_extended + + @mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_both_disabled) + @mock_aws + def test_both_filters_disabled_fails(self): + """Both filters disabled reports both, not just the first.""" + result = self._run() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "GROUNDING filter is disabled" in result[0].status_extended + assert "RELEVANCE filter is disabled" in result[0].status_extended + + @mock.patch( + "botocore.client.BaseClient._make_api_call", new=_mock_disabled_and_action_none + ) + @mock_aws + def test_disabled_reported_ahead_of_action(self): + """Disabled is the operative defect: the action is never reached.""" + result = self._run() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "GROUNDING filter is disabled" in result[0].status_extended + assert "action NONE" not in result[0].status_extended + + @mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_enabled_absent) + @mock_aws + def test_absent_enabled_is_manual_not_pass(self): + """enabled is optional with no documented default, so absent is unknown.""" + result = self._run() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + "GROUNDING filter omits enabled, RELEVANCE filter omits enabled" + in result[0].status_extended + ) + # Two unknown filters take the plural subject. + assert "so whether they block is unknown" in result[0].status_extended + + @mock.patch( + "botocore.client.BaseClient._make_api_call", new=_mock_enabled_absent_one_filter + ) + @mock_aws + def test_absent_enabled_on_one_filter_is_manual(self): + """One filter omitting enabled is enough to make the answer unknown.""" + result = self._run() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "GROUNDING filter omits enabled" in result[0].status_extended + # action was present, so it must not be reported as missing. + assert "omits action" not in result[0].status_extended + assert "RELEVANCE" not in result[0].status_extended + assert "so whether it blocks is unknown" in result[0].status_extended + + @mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_action_absent) + @mock_aws + def test_absent_action_is_manual_not_fail(self): + """action is optional with no documented default, so omitting it is unknown. + + Treating an absent action as NONE would assert a misconfiguration the + response never stated, and would print the literal None as if it were an + AWS enum value. It is reported the same way as an absent enabled. + """ + result = self._run() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "None" not in result[0].status_extended + + @mock.patch( + "botocore.client.BaseClient._make_api_call", new=_mock_action_absent_one_filter + ) + @mock_aws + def test_absent_action_on_one_filter_is_manual(self): + """One filter omitting action is enough to make the answer unknown.""" + result = self._run() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "GROUNDING filter omits action" in result[0].status_extended + # enabled was present, so it must not be reported as missing. + assert "omits enabled" not in result[0].status_extended + assert "RELEVANCE" not in result[0].status_extended + assert "so whether it blocks is unknown" in result[0].status_extended + + @mock.patch( + "botocore.client.BaseClient._make_api_call", + new=_mock_action_absent_with_zero_threshold, + ) + @mock_aws + def test_absent_action_does_not_mask_a_definite_defect(self): + """An unknown action must not downgrade a real threshold defect to MANUAL.""" + result = self._run() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "threshold" in result[0].status_extended + + @mock.patch( + "botocore.client.BaseClient._make_api_call", + new=_mock_enabled_absent_with_action_none, + ) + @mock_aws + def test_absent_enabled_does_not_mask_a_definite_defect(self): + """An unknown enabled must not downgrade a real action defect to MANUAL.""" + result = self._run() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "GROUNDING filter uses action NONE" in result[0].status_extended + + @mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_unreadable) + @mock_aws + def test_detail_unreadable_is_manual_not_pass(self): + """A failed GetGuardrail must not be reported as compliant.""" + result = self._run() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "could not be retrieved" in result[0].status_extended + + @mock.patch( + "botocore.client.BaseClient._make_api_call", new=_mock_list_guardrails_denied + ) + @mock_aws + def test_list_guardrails_denied_is_manual_not_silence(self): + """A denied ListGuardrails must report MANUAL for the region, not vanish. + + Without this the Region is indistinguishable from one holding no + guardrails, which is the same silent-inventory gap the sibling checks + report against custom-model/unknown and knowledge-base/unknown. + """ + result = self._run() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].region == AWS_REGION_US_EAST_1 + assert result[0].resource_id == "guardrail/unknown" + assert ( + result[0].resource_arn + == f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:guardrail/unknown" + ) + assert "could not be listed" in result[0].status_extended + assert "AccessDeniedException" in result[0].status_extended + assert result[0].status_extended.endswith(".") diff --git a/tests/providers/aws/services/bedrock/bedrock_knowledge_base_encrypted_with_cmk/bedrock_knowledge_base_encrypted_with_cmk_test.py b/tests/providers/aws/services/bedrock/bedrock_knowledge_base_encrypted_with_cmk/bedrock_knowledge_base_encrypted_with_cmk_test.py new file mode 100644 index 0000000000..f15916959b --- /dev/null +++ b/tests/providers/aws/services/bedrock/bedrock_knowledge_base_encrypted_with_cmk/bedrock_knowledge_base_encrypted_with_cmk_test.py @@ -0,0 +1,338 @@ +from unittest import mock + +import botocore +from botocore.exceptions import ClientError +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +make_api_call = botocore.client.BaseClient._make_api_call + +KB_ID = "test-kb-id" +KB_NAME = "test-knowledge-base" +KB_ARN = f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:knowledge-base/{KB_ID}" +DS_ID = "test-ds-id" +DS_NAME = "test-data-source" +DS_ARN = f"{KB_ARN}/data-source/{DS_ID}" +KMS_KEY_ARN = f"arn:aws:kms:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:key/test-key-id" + +# Operations the BedrockAgent constructor calls that these tests do not exercise. +_UNUSED_OPERATIONS = ( + "ListAgents", + "GetAgent", + "ListPrompts", + "GetPrompt", + "ListTagsForResource", +) + + +def _knowledge_base_mock(kms_key_arn=None, fail_get=False): + """Build a _make_api_call replacement returning one KB with one data source.""" + + def _mock(self, operation_name, kwarg): + if operation_name in _UNUSED_OPERATIONS: + return {} + if operation_name == "ListKnowledgeBases": + return { + "knowledgeBaseSummaries": [ + { + "knowledgeBaseId": KB_ID, + "name": KB_NAME, + "status": "ACTIVE", + } + ] + } + if operation_name == "ListDataSources": + return { + "dataSourceSummaries": [ + { + "knowledgeBaseId": KB_ID, + "dataSourceId": DS_ID, + "name": DS_NAME, + "status": "AVAILABLE", + } + ] + } + if operation_name == "GetDataSource": + if fail_get: + raise ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "denied"}}, + operation_name, + ) + # GetDataSource nests its payload under a top-level dataSource key. + data_source = { + "knowledgeBaseId": KB_ID, + "dataSourceId": DS_ID, + "name": DS_NAME, + "status": "AVAILABLE", + } + if kms_key_arn is not None: + data_source["serverSideEncryptionConfiguration"] = { + "kmsKeyArn": kms_key_arn + } + return {"dataSource": data_source} + return make_api_call(self, operation_name, kwarg) + + return _mock + + +_mock_with_cmk = _knowledge_base_mock(KMS_KEY_ARN) +_mock_without_cmk = _knowledge_base_mock(None) +_mock_empty_cmk = _knowledge_base_mock("") +_mock_unreadable = _knowledge_base_mock(fail_get=True) + + +def _mock_empty(self, operation_name, kwarg): + """No knowledge bases at all.""" + if operation_name in _UNUSED_OPERATIONS: + return {} + if operation_name == "ListKnowledgeBases": + return {"knowledgeBaseSummaries": []} + return make_api_call(self, operation_name, kwarg) + + +def _mock_no_data_sources(self, operation_name, kwarg): + """A knowledge base with no data sources produces no findings.""" + if operation_name in _UNUSED_OPERATIONS: + return {} + if operation_name == "ListKnowledgeBases": + return { + "knowledgeBaseSummaries": [ + {"knowledgeBaseId": KB_ID, "name": KB_NAME, "status": "ACTIVE"} + ] + } + if operation_name == "ListDataSources": + return {"dataSourceSummaries": []} + return make_api_call(self, operation_name, kwarg) + + +def _mock_unsupported_region(self, operation_name, kwarg): + """The API is not available in the audited region.""" + if operation_name in _UNUSED_OPERATIONS: + return {} + if operation_name == "ListKnowledgeBases": + raise ClientError( + { + "Error": { + "Code": "ValidationException", + "Message": "Bedrock Agent is not supported in this region.", + } + }, + operation_name, + ) + return make_api_call(self, operation_name, kwarg) + + +def _mock_list_kb_denied(self, operation_name, kwarg): + """ListKnowledgeBases is denied, so the region's knowledge bases are unknown.""" + if operation_name in _UNUSED_OPERATIONS: + return {} + if operation_name == "ListKnowledgeBases": + raise ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "denied"}}, + operation_name, + ) + return make_api_call(self, operation_name, kwarg) + + +def _mock_list_ds_denied(self, operation_name, kwarg): + """The knowledge base is visible but its data sources cannot be listed.""" + if operation_name in _UNUSED_OPERATIONS: + return {} + if operation_name == "ListKnowledgeBases": + return { + "knowledgeBaseSummaries": [ + {"knowledgeBaseId": KB_ID, "name": KB_NAME, "status": "ACTIVE"} + ] + } + if operation_name == "ListDataSources": + raise ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "denied"}}, + operation_name, + ) + return make_api_call(self, operation_name, kwarg) + + +class Test_bedrock_knowledge_base_encrypted_with_cmk: + """Unit tests for the bedrock_knowledge_base_encrypted_with_cmk check.""" + + def _run(self): + """Import the service + check under the active mocks and execute.""" + from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent + + 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( + "prowler.providers.aws.services.bedrock.bedrock_knowledge_base_encrypted_with_cmk.bedrock_knowledge_base_encrypted_with_cmk.bedrock_agent_client", + new=BedrockAgent(aws_provider), + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_knowledge_base_encrypted_with_cmk.bedrock_knowledge_base_encrypted_with_cmk import ( + bedrock_knowledge_base_encrypted_with_cmk, + ) + + return bedrock_knowledge_base_encrypted_with_cmk().execute() + + @mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_empty) + @mock_aws + def test_no_resources(self): + """No resources means no findings, not a spurious FAIL.""" + assert self._run() == [] + + @mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_no_data_sources) + @mock_aws + def test_knowledge_base_without_data_sources(self): + """Findings are per data source, so a KB with none produces nothing.""" + assert self._run() == [] + + @mock.patch( + "botocore.client.BaseClient._make_api_call", new=_mock_unsupported_region + ) + @mock_aws + def test_region_not_supported(self): + """A ValidationException from the region must not raise; it yields no findings.""" + assert self._run() == [] + + @mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_with_cmk) + @mock_aws + def test_cmk_present_passes(self): + """A data source with kmsKeyArn set is compliant.""" + result = self._run() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == DS_ID + assert result[0].resource_arn == DS_ARN + assert result[0].region == AWS_REGION_US_EAST_1 + assert ( + result[0].status_extended + == f"Bedrock knowledge base {KB_NAME} data source {DS_NAME} is encrypted with a customer-managed KMS key in region {AWS_REGION_US_EAST_1}." + ) + + @mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_without_cmk) + @mock_aws + def test_no_cmk_fails(self): + """An absent kmsKeyArn means an AWS-owned key is in use.""" + result = self._run() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert KB_NAME in result[0].status_extended + assert "is not encrypted with a customer-managed KMS key" in ( + result[0].status_extended + ) + + @mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_empty_cmk) + @mock_aws + def test_empty_cmk_fails(self): + """An empty kmsKeyArn string is not a key.""" + result = self._run() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "is not encrypted with a customer-managed KMS key" in ( + result[0].status_extended + ) + + @mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_unreadable) + @mock_aws + def test_detail_unreadable_is_manual_not_pass(self): + """A failed GetDataSource must not be reported as compliant.""" + result = self._run() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "could not be retrieved" in result[0].status_extended + + @mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_list_kb_denied) + @mock_aws + def test_list_knowledge_bases_denied_is_manual_not_silence(self): + """A denied ListKnowledgeBases must report MANUAL for the region.""" + result = self._run() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].region == AWS_REGION_US_EAST_1 + assert result[0].resource_id == "knowledge-base/unknown" + assert ( + result[0].resource_arn + == f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:knowledge-base/unknown" + ) + assert "could not be listed" in result[0].status_extended + assert "AccessDeniedException" in result[0].status_extended + assert result[0].status_extended.endswith(".") + + @mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_list_ds_denied) + @mock_aws + def test_list_data_sources_denied_is_manual_not_silence(self): + """A knowledge base whose data sources cannot be listed must still report. + + Reporting nothing would drop the knowledge base from the output, which is + indistinguishable from one that genuinely has no data sources. + """ + result = self._run() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].resource_arn == KB_ARN + assert KB_NAME in result[0].status_extended + assert "data sources could not be listed" in result[0].status_extended + # The message names why, like the region-level and detail-level ones do. + assert "AccessDeniedException" in result[0].status_extended + assert result[0].status_extended.endswith(".") + + @mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_no_data_sources) + @mock_aws + def test_listed_but_empty_marks_the_knowledge_base_as_listed(self): + """A successful but empty ListDataSources is "none", not "unknown". + + Distinct from test_knowledge_base_without_data_sources, which only asserts + the empty result: this asserts the service state that produces it, so the + over-correction of reporting MANUAL for a genuinely empty knowledge base + cannot regress silently. + """ + from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent + + 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, + ): + service = BedrockAgent(aws_provider) + + assert service.knowledge_bases, "the knowledge base must be discovered" + assert all( + knowledge_base.data_sources_listed + for knowledge_base in service.knowledge_bases.values() + ) + assert service.data_sources == {} + assert service.knowledge_bases_scan_errors == {} + assert self._run() == [] + + @mock.patch("botocore.client.BaseClient._make_api_call", new=_mock_without_cmk) + @mock_aws + def test_scoping_by_knowledge_base_arn_keeps_its_data_sources(self): + """A scan scoped to the knowledge base ARN must still see its data sources. + + AWS exposes no ARN for a Bedrock data source, so the one built here is + synthetic and can never equal a user-supplied --resource-arn. Filtering on + it would keep the knowledge base, silently drop every data source, and + leave the check reporting nothing for an in-scope knowledge base. + """ + from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + aws_provider._audit_resources = [KB_ARN] + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + service = BedrockAgent(aws_provider) + + assert service.knowledge_bases, "the scoped knowledge base must be kept" + assert service.data_sources, "its data sources must not be filtered out" + assert all( + ds.knowledge_base_id == KB_ID for ds in service.data_sources.values() + ) diff --git a/tests/providers/aws/services/bedrock/bedrock_service_test.py b/tests/providers/aws/services/bedrock/bedrock_service_test.py index f8a1fc8996..276dc44a4f 100644 --- a/tests/providers/aws/services/bedrock/bedrock_service_test.py +++ b/tests/providers/aws/services/bedrock/bedrock_service_test.py @@ -2,6 +2,7 @@ from unittest import mock from unittest.mock import MagicMock import botocore +import pytest from boto3 import client from moto import mock_aws @@ -283,11 +284,20 @@ class TestBedrockPagination: class TestBedrockAgentPagination: """Test suite for Bedrock Agent pagination logic.""" - def test_list_agents_pagination(self): - """Test that list_agents iterates through all pages.""" - # Mock the audit_info + @pytest.mark.parametrize("partition", ["aws", "aws-us-gov", "aws-cn"]) + def test_list_agents_pagination(self, partition): + """Test that list_agents iterates through all pages, in every partition. + + The ARN is built from the audited partition, so GovCloud and China must + produce aws-us-gov/aws-cn ARNs. A hardcoded `arn:aws:` here yielded an ARN + that does not exist in those partitions, and exact --resource-arn matching + against it could never succeed. + """ + # Mock the audit_info. AWSService reads the partition off provider.identity, + # so setting audited_partition alone leaves a MagicMock in the ARN. audit_info = MagicMock() - audit_info.audited_partition = "aws" + audit_info.identity.partition = partition + audit_info.audited_partition = partition audit_info.audited_account = "123456789012" audit_info.audit_resources = None @@ -322,6 +332,7 @@ class TestBedrockAgentPagination: bedrock_agent_service = BedrockAgent(audit_info) bedrock_agent_service.regional_clients = {"us-east-1": regional_client} bedrock_agent_service.agents = {} # Clear init side effects + bedrock_agent_service.all_agents = {} bedrock_agent_service.audited_account = "123456789012" # Run method @@ -329,14 +340,16 @@ class TestBedrockAgentPagination: # Assertions assert len(bedrock_agent_service.agents) == 2 - assert ( - "arn:aws:bedrock:us-east-1:123456789012:agent/agent-1" - in bedrock_agent_service.agents - ) - assert ( - "arn:aws:bedrock:us-east-1:123456789012:agent/agent-2" - in bedrock_agent_service.agents - ) + for agent_id in ("agent-1", "agent-2"): + expected_arn = ( + f"arn:{partition}:bedrock:us-east-1:123456789012:agent/{agent_id}" + ) + assert expected_arn in bedrock_agent_service.agents + # With no --resource-arn, the complete inventory and the reported set + # hold the very same objects. + assert bedrock_agent_service.all_agents[expected_arn] is ( + bedrock_agent_service.agents[expected_arn] + ) # Verify paginator was used regional_client.get_paginator.assert_called_once_with("list_agents") 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/ec2/ec2_securitygroup_not_used/ec2_securitygroup_not_used_test.py b/tests/providers/aws/services/ec2/ec2_securitygroup_not_used/ec2_securitygroup_not_used_test.py index b959f4b257..f3dac7fc3e 100644 --- a/tests/providers/aws/services/ec2/ec2_securitygroup_not_used/ec2_securitygroup_not_used_test.py +++ b/tests/providers/aws/services/ec2/ec2_securitygroup_not_used/ec2_securitygroup_not_used_test.py @@ -36,6 +36,10 @@ class Test_ec2_securitygroup_not_used: awslambda_client = mock.MagicMock() awslambda_client.functions = {} awslambda_client.security_groups_in_use = {sg_id} + batch_client = mock.MagicMock() + batch_client.compute_environments = {} + batch_client.security_groups_in_use = set() + batch_client.compute_environment_lookup_failed_regions = set() aws_provider = set_mocked_aws_provider() with ( @@ -51,6 +55,10 @@ class Test_ec2_securitygroup_not_used: "prowler.providers.aws.services.ec2.ec2_securitygroup_not_used.ec2_securitygroup_not_used.awslambda_client", new=awslambda_client, ), + mock.patch( + "prowler.providers.aws.services.ec2.ec2_securitygroup_not_used.ec2_securitygroup_not_used.batch_client", + new=batch_client, + ), ): from prowler.providers.aws.services.ec2.ec2_securitygroup_not_used.ec2_securitygroup_not_used import ( ec2_securitygroup_not_used, @@ -72,6 +80,7 @@ class Test_ec2_securitygroup_not_used: ec2_client.create_vpc(CidrBlock="10.0.0.0/16") from prowler.providers.aws.services.awslambda.awslambda_service import Lambda + from prowler.providers.aws.services.batch.batch_service import Batch from prowler.providers.aws.services.ec2.ec2_service import EC2 aws_provider = set_mocked_aws_provider( @@ -91,6 +100,10 @@ class Test_ec2_securitygroup_not_used: "prowler.providers.aws.services.ec2.ec2_securitygroup_not_used.ec2_securitygroup_not_used.awslambda_client", new=Lambda(aws_provider), ), + mock.patch( + "prowler.providers.aws.services.ec2.ec2_securitygroup_not_used.ec2_securitygroup_not_used.batch_client", + new=Batch(aws_provider), + ), ): # Test Check from prowler.providers.aws.services.ec2.ec2_securitygroup_not_used.ec2_securitygroup_not_used import ( @@ -115,6 +128,7 @@ class Test_ec2_securitygroup_not_used: ) from prowler.providers.aws.services.awslambda.awslambda_service import Lambda + from prowler.providers.aws.services.batch.batch_service import Batch from prowler.providers.aws.services.ec2.ec2_service import EC2 aws_provider = set_mocked_aws_provider( @@ -134,6 +148,10 @@ class Test_ec2_securitygroup_not_used: "prowler.providers.aws.services.ec2.ec2_securitygroup_not_used.ec2_securitygroup_not_used.awslambda_client", new=Lambda(aws_provider), ), + mock.patch( + "prowler.providers.aws.services.ec2.ec2_securitygroup_not_used.ec2_securitygroup_not_used.batch_client", + new=Batch(aws_provider), + ), ): # Test Check from prowler.providers.aws.services.ec2.ec2_securitygroup_not_used.ec2_securitygroup_not_used import ( @@ -173,6 +191,7 @@ class Test_ec2_securitygroup_not_used: subnet.create_network_interface(Groups=[sg.id]) from prowler.providers.aws.services.awslambda.awslambda_service import Lambda + from prowler.providers.aws.services.batch.batch_service import Batch from prowler.providers.aws.services.ec2.ec2_service import EC2 aws_provider = set_mocked_aws_provider( @@ -192,6 +211,10 @@ class Test_ec2_securitygroup_not_used: "prowler.providers.aws.services.ec2.ec2_securitygroup_not_used.ec2_securitygroup_not_used.awslambda_client", new=Lambda(aws_provider), ), + mock.patch( + "prowler.providers.aws.services.ec2.ec2_securitygroup_not_used.ec2_securitygroup_not_used.batch_client", + new=Batch(aws_provider), + ), ): # Test Check from prowler.providers.aws.services.ec2.ec2_securitygroup_not_used.ec2_securitygroup_not_used import ( @@ -259,6 +282,7 @@ class Test_ec2_securitygroup_not_used: ) from prowler.providers.aws.services.awslambda.awslambda_service import Lambda + from prowler.providers.aws.services.batch.batch_service import Batch from prowler.providers.aws.services.ec2.ec2_service import EC2 aws_provider = set_mocked_aws_provider( @@ -278,6 +302,10 @@ class Test_ec2_securitygroup_not_used: "prowler.providers.aws.services.ec2.ec2_securitygroup_not_used.ec2_securitygroup_not_used.awslambda_client", new=Lambda(aws_provider), ), + mock.patch( + "prowler.providers.aws.services.ec2.ec2_securitygroup_not_used.ec2_securitygroup_not_used.batch_client", + new=Batch(aws_provider), + ), ): # Test Check from prowler.providers.aws.services.ec2.ec2_securitygroup_not_used.ec2_securitygroup_not_used import ( @@ -338,6 +366,7 @@ class Test_ec2_securitygroup_not_used: ) from prowler.providers.aws.services.awslambda.awslambda_service import Lambda + from prowler.providers.aws.services.batch.batch_service import Batch from prowler.providers.aws.services.ec2.ec2_service import EC2 aws_provider = set_mocked_aws_provider( @@ -357,6 +386,10 @@ class Test_ec2_securitygroup_not_used: "prowler.providers.aws.services.ec2.ec2_securitygroup_not_used.ec2_securitygroup_not_used.awslambda_client", new=Lambda(aws_provider), ), + mock.patch( + "prowler.providers.aws.services.ec2.ec2_securitygroup_not_used.ec2_securitygroup_not_used.batch_client", + new=Batch(aws_provider), + ), ): # Test Check from prowler.providers.aws.services.ec2.ec2_securitygroup_not_used.ec2_securitygroup_not_used import ( @@ -394,3 +427,271 @@ class Test_ec2_securitygroup_not_used: assert result[1].resource_id == sg1.id assert result[1].resource_details == sg_name1 assert result[1].resource_tags == [] + + def test_ec2_sg_used_by_batch_compute_environment_without_enis(self): + from prowler.providers.aws.services.ec2.ec2_service import SecurityGroup + + sg_id = "sg-batch" + sg_name = "batch-sg" + security_group = SecurityGroup( + name=sg_name, + region=AWS_REGION_US_EAST_1, + arn=f"arn:aws:ec2:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:security-group/{sg_id}", + id=sg_id, + vpc_id="vpc-test", + associated_sgs=[], + network_interfaces=[], + ingress_rules=[], + egress_rules=[], + tags=[], + ) + ec2_client = mock.MagicMock() + ec2_client.security_groups = {security_group.arn: security_group} + awslambda_client = mock.MagicMock() + awslambda_client.functions = {} + awslambda_client.security_groups_in_use = set() + batch_client = mock.MagicMock() + batch_client.security_groups_in_use = {sg_id} + batch_client.compute_environment_lookup_failed_regions = set() + aws_provider = set_mocked_aws_provider() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.ec2.ec2_securitygroup_not_used.ec2_securitygroup_not_used.ec2_client", + new=ec2_client, + ), + mock.patch( + "prowler.providers.aws.services.ec2.ec2_securitygroup_not_used.ec2_securitygroup_not_used.awslambda_client", + new=awslambda_client, + ), + mock.patch( + "prowler.providers.aws.services.ec2.ec2_securitygroup_not_used.ec2_securitygroup_not_used.batch_client", + new=batch_client, + ), + ): + from prowler.providers.aws.services.ec2.ec2_securitygroup_not_used.ec2_securitygroup_not_used import ( + ec2_securitygroup_not_used, + ) + + result = ec2_securitygroup_not_used().execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Security group {sg_name} ({sg_id}) it is being used." + ) + + def test_ec2_sg_not_failed_when_batch_lookup_failed(self): + from prowler.providers.aws.services.ec2.ec2_service import SecurityGroup + + sg_id = "sg-unknown" + sg_name = "unknown-sg" + security_group = SecurityGroup( + name=sg_name, + region=AWS_REGION_US_EAST_1, + arn=f"arn:aws:ec2:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:security-group/{sg_id}", + id=sg_id, + vpc_id="vpc-test", + associated_sgs=[], + network_interfaces=[], + ingress_rules=[], + egress_rules=[], + tags=[], + ) + ec2_client = mock.MagicMock() + ec2_client.security_groups = {security_group.arn: security_group} + awslambda_client = mock.MagicMock() + awslambda_client.functions = {} + awslambda_client.security_groups_in_use = set() + # Compute environments could not be listed in this region, e.g. because + # batch:DescribeComputeEnvironments was denied + batch_client = mock.MagicMock() + batch_client.security_groups_in_use = set() + batch_client.compute_environment_lookup_failed_regions = {AWS_REGION_US_EAST_1} + aws_provider = set_mocked_aws_provider() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.ec2.ec2_securitygroup_not_used.ec2_securitygroup_not_used.ec2_client", + new=ec2_client, + ), + mock.patch( + "prowler.providers.aws.services.ec2.ec2_securitygroup_not_used.ec2_securitygroup_not_used.awslambda_client", + new=awslambda_client, + ), + mock.patch( + "prowler.providers.aws.services.ec2.ec2_securitygroup_not_used.ec2_securitygroup_not_used.batch_client", + new=batch_client, + ), + ): + from prowler.providers.aws.services.ec2.ec2_securitygroup_not_used.ec2_securitygroup_not_used import ( + ec2_securitygroup_not_used, + ) + + result = ec2_securitygroup_not_used().execute() + + # Unknown associations must not be reported as unused; a lack of + # visibility is reported as MANUAL rather than asserted either way + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].region == AWS_REGION_US_EAST_1 + assert result[0].resource_id == sg_id + assert ( + result[0].status_extended + == f"Security group {sg_name} ({sg_id}) usage could not be verified because AWS Batch compute environments could not be listed in region {AWS_REGION_US_EAST_1}; grant batch:DescribeComputeEnvironments and run the check again." + ) + + def test_ec2_sg_still_reported_used_when_batch_lookup_failed(self): + from prowler.providers.aws.services.ec2.ec2_service import SecurityGroup + + sg_id = "sg-lambda" + sg_name = "lambda-sg" + security_group = SecurityGroup( + name=sg_name, + region=AWS_REGION_US_EAST_1, + arn=f"arn:aws:ec2:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:security-group/{sg_id}", + id=sg_id, + vpc_id="vpc-test", + associated_sgs=[], + network_interfaces=[], + ingress_rules=[], + egress_rules=[], + tags=[], + ) + ec2_client = mock.MagicMock() + ec2_client.security_groups = {security_group.arn: security_group} + awslambda_client = mock.MagicMock() + awslambda_client.functions = {} + awslambda_client.security_groups_in_use = {sg_id} + batch_client = mock.MagicMock() + batch_client.security_groups_in_use = set() + batch_client.compute_environment_lookup_failed_regions = {AWS_REGION_US_EAST_1} + aws_provider = set_mocked_aws_provider() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.ec2.ec2_securitygroup_not_used.ec2_securitygroup_not_used.ec2_client", + new=ec2_client, + ), + mock.patch( + "prowler.providers.aws.services.ec2.ec2_securitygroup_not_used.ec2_securitygroup_not_used.awslambda_client", + new=awslambda_client, + ), + mock.patch( + "prowler.providers.aws.services.ec2.ec2_securitygroup_not_used.ec2_securitygroup_not_used.batch_client", + new=batch_client, + ), + ): + from prowler.providers.aws.services.ec2.ec2_securitygroup_not_used.ec2_securitygroup_not_used import ( + ec2_securitygroup_not_used, + ) + + result = ec2_securitygroup_not_used().execute() + + # A known association is still reported, the failed lookup is irrelevant + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Security group {sg_name} ({sg_id}) it is being used." + ) + + @mock_aws + def test_ec2_sg_used_by_batch_compute_environment(self): + # Create EC2 Mocked Resources + ec2 = resource("ec2", AWS_REGION_US_EAST_1) + ec2_client = client("ec2", region_name=AWS_REGION_US_EAST_1) + vpc_id = ec2_client.create_vpc(CidrBlock="10.0.0.0/16")["Vpc"]["VpcId"] + sg_name = "test-sg" + sg = ec2.create_security_group( + GroupName=sg_name, Description="test", VpcId=vpc_id + ) + subnet = ec2.create_subnet(VpcId=vpc_id, CidrBlock="10.0.0.0/18") + iam_client = client("iam", region_name=AWS_REGION_US_EAST_1) + service_role = iam_client.create_role( + RoleName="batch-service-role", + AssumeRolePolicyDocument="some policy", + Path="/my-path/", + )["Role"]["Arn"] + instance_profile = iam_client.create_instance_profile( + InstanceProfileName="batch-instance-profile" + )["InstanceProfile"]["Arn"] + batch = client("batch", region_name=AWS_REGION_US_EAST_1) + batch.create_compute_environment( + computeEnvironmentName="test-compute-environment", + type="MANAGED", + state="ENABLED", + computeResources={ + "type": "EC2", + "minvCpus": 0, + "maxvCpus": 4, + "instanceTypes": ["optimal"], + "subnets": [subnet.id], + "securityGroupIds": [sg.id], + "instanceRole": instance_profile, + }, + serviceRole=service_role, + ) + + from prowler.providers.aws.services.awslambda.awslambda_service import Lambda + from prowler.providers.aws.services.batch.batch_service import Batch + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider( + audited_regions=["us-east-1", "eu-west-1"] + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.ec2.ec2_securitygroup_not_used.ec2_securitygroup_not_used.ec2_client", + new=EC2(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.ec2.ec2_securitygroup_not_used.ec2_securitygroup_not_used.awslambda_client", + new=Lambda(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.ec2.ec2_securitygroup_not_used.ec2_securitygroup_not_used.batch_client", + new=Batch(aws_provider), + ), + ): + # Test Check + from prowler.providers.aws.services.ec2.ec2_securitygroup_not_used.ec2_securitygroup_not_used import ( + ec2_securitygroup_not_used, + ) + + check = ec2_securitygroup_not_used() + result = check.execute() + + # One custom sg, attached to a Batch compute environment with no ENIs + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].region == AWS_REGION_US_EAST_1 + assert ( + result[0].status_extended + == f"Security group {sg_name} ({sg.id}) it is being used." + ) + assert ( + result[0].resource_arn + == f"arn:{aws_provider.identity.partition}:ec2:{AWS_REGION_US_EAST_1}:{aws_provider.identity.account}:security-group/{sg.id}" + ) + assert result[0].resource_id == sg.id + assert result[0].resource_details == sg_name + assert result[0].resource_tags == [] 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_repository_image_no_secrets/ecr_repository_image_no_secrets_test.py b/tests/providers/aws/services/ecr/ecr_repository_image_no_secrets/ecr_repository_image_no_secrets_test.py new file mode 100644 index 0000000000..91936be911 --- /dev/null +++ b/tests/providers/aws/services/ecr/ecr_repository_image_no_secrets/ecr_repository_image_no_secrets_test.py @@ -0,0 +1,837 @@ +from datetime import datetime +from unittest import mock + +from prowler.providers.aws.services.ecr.ecr_service import ( + ImageDetails, + Registry, + Repository, +) +from prowler.providers.aws.services.ecr.image_inspection import ( + ImageScanData, + ImageScanFile, +) +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +# A real JWT: Kingfisher detects this regardless of the surrounding key name +# or format (env-style KEY=value, Dockerfile RUN step, or source file). +SECRET_VALUE = ( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + ".eyJzdWIiOiIxMjM0NTY3ODkwIn0" + ".dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U" +) + + +def create_repository(name="test-repo", region=AWS_REGION_US_EAST_1) -> Repository: + """Build a minimal ECR Repository fixture.""" + return Repository( + name=name, + arn=f"arn:aws:ecr:{region}:{AWS_ACCOUNT_NUMBER}:repository/{name}", + region=region, + scan_on_push=True, + images_details=[], + ) + + +def create_image(tag="latest", digest=None) -> ImageDetails: + """Build a minimal ImageDetails fixture.""" + return ImageDetails( + latest_tag=tag, + latest_digest=digest or f"sha256:{'0' * 64}", + image_pushed_at=datetime.now(), + scan_findings_status=None, + scan_findings_severity_count=None, + artifact_media_type="application/vnd.docker.container.image.v1+json", + type="Docker", + ) + + +def mock_image_scan_data(pairs): + """Build a fake _get_image_scan_data generator yielding the given pairs.""" + + def _generator(): + """Yield each (repository, image, scan_data) pair once.""" + for entry in pairs: + yield entry + + return _generator + + +class Test_ecr_repository_image_no_secrets: + """Tests for the ecr_repository_image_no_secrets check.""" + + def test_no_repositories(self): + """No repositories yields no findings.""" + ecr_client = mock.MagicMock() + ecr_client.registries = {} + ecr_client.audit_config = { + "secrets_ignore_patterns": [], + "secrets_validate": False, + } + ecr_client._get_image_scan_data = mock_image_scan_data([]) + + 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_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client", + new=ecr_client, + ), + ): + from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import ( + ecr_repository_image_no_secrets, + ) + + check = ecr_repository_image_no_secrets() + result = check.execute() + + assert len(result) == 0 + + def test_clean_image(self): + """An image with no secrets passes.""" + repository = create_repository() + image = create_image() + scan_data = ImageScanData( + env=["PATH=/usr/bin"], + history=["RUN echo hello"], + files=[], + truncated=False, + ) + + ecr_client = mock.MagicMock() + ecr_client.registries = {} + ecr_client.audit_config = { + "secrets_ignore_patterns": [], + "secrets_validate": False, + } + ecr_client._get_image_scan_data = mock_image_scan_data( + [(repository, image, scan_data)] + ) + + 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_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client", + new=ecr_client, + ), + ): + from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import ( + ecr_repository_image_no_secrets, + ) + + check = ecr_repository_image_no_secrets() + result = check.execute() + + digest_short = image.latest_digest.split(":")[-1][:12] + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + f"No secrets found in the image '{image.latest_tag}' " + f"({image.latest_digest}) of ECR repository {repository.name}." + ) + assert result[0].region == AWS_REGION_US_EAST_1 + assert ( + result[0].resource_id + == f"{repository.name}:{image.latest_tag}@{digest_short}" + ) + assert result[0].resource_arn == f"{repository.arn}/image/{digest_short}" + + def test_truncated_image_reports_manual(self): + """A clean but truncated image is MANUAL, since part was not scanned.""" + repository = create_repository() + image = create_image() + scan_data = ImageScanData(env=[], history=[], files=[], truncated=True) + + ecr_client = mock.MagicMock() + ecr_client.registries = {} + ecr_client.audit_config = { + "secrets_ignore_patterns": [], + "secrets_validate": False, + } + ecr_client._get_image_scan_data = mock_image_scan_data( + [(repository, image, scan_data)] + ) + + 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_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client", + new=ecr_client, + ), + ): + from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import ( + ecr_repository_image_no_secrets, + ) + + check = ecr_repository_image_no_secrets() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + "part of it could not be retrieved or exceeded configured size " + "limits and was not scanned" in result[0].status_extended + ) + + def test_secret_in_environment_variable(self): + """A secret in an environment variable fails, naming the variable.""" + from prowler.lib.check.models import Severity + + repository = create_repository() + image = create_image() + scan_data = ImageScanData( + env=["PATH=/usr/bin", f"DB_PASSWORD={SECRET_VALUE}"], + history=[], + files=[], + truncated=False, + ) + + ecr_client = mock.MagicMock() + ecr_client.registries = {} + ecr_client.audit_config = { + "secrets_ignore_patterns": [], + "secrets_validate": False, + } + ecr_client._get_image_scan_data = mock_image_scan_data( + [(repository, image, scan_data)] + ) + + 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_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client", + new=ecr_client, + ), + ): + from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import ( + ecr_repository_image_no_secrets, + ) + + check = ecr_repository_image_no_secrets() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "environment variable DB_PASSWORD" in result[0].status_extended + assert SECRET_VALUE not in result[0].status_extended + assert result[0].check_metadata.Severity == Severity.high + + def test_secret_in_malformed_env_entry_is_redacted(self): + """An env entry without '=' is reported generically, never echoed.""" + repository = create_repository() + image = create_image() + # The entry has no "=" so no variable name can be split out; the entry + # itself is the secret and must not appear in the finding. + scan_data = ImageScanData( + env=[SECRET_VALUE], + history=[], + files=[], + truncated=False, + ) + + ecr_client = mock.MagicMock() + ecr_client.registries = {} + ecr_client.audit_config = { + "secrets_ignore_patterns": [], + "secrets_validate": False, + } + ecr_client._get_image_scan_data = mock_image_scan_data( + [(repository, image, scan_data)] + ) + + 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_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client", + new=ecr_client, + ), + ): + from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import ( + ecr_repository_image_no_secrets, + ) + + check = ecr_repository_image_no_secrets() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "in image environment variables" in result[0].status_extended + assert SECRET_VALUE not in result[0].status_extended + + def test_secret_in_unsafe_environment_name_is_redacted(self): + """An unsafe name before '=' is never included in report text.""" + repository = create_repository() + image = create_image() + scan_data = ImageScanData( + env=[f"{SECRET_VALUE}=safe-value"], + history=[], + files=[], + truncated=False, + ) + + ecr_client = mock.MagicMock() + ecr_client.registries = {} + ecr_client.audit_config = { + "secrets_ignore_patterns": [], + "secrets_validate": False, + } + ecr_client._get_image_scan_data = mock_image_scan_data( + [(repository, image, scan_data)] + ) + + 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_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client", + new=ecr_client, + ), + ): + from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import ( + ecr_repository_image_no_secrets, + ) + + result = ecr_repository_image_no_secrets().execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "in image environment variables" in result[0].status_extended + assert SECRET_VALUE not in str(vars(result[0])) + + def test_scanned_file_content_is_freed_after_execute(self): + """File contents are released after scanning so memory stays flat.""" + repository = create_repository() + image = create_image() + scanned_file = ImageScanFile( + path="app/config.py", + layer_digest=f"sha256:{'a' * 64}", + content="nothing secret here", + ) + scan_data = ImageScanData( + env=[], history=[], files=[scanned_file], truncated=False + ) + + ecr_client = mock.MagicMock() + ecr_client.registries = {} + ecr_client.audit_config = { + "secrets_ignore_patterns": [], + "secrets_validate": False, + } + ecr_client._get_image_scan_data = mock_image_scan_data( + [(repository, image, scan_data)] + ) + + 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_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client", + new=ecr_client, + ), + ): + from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import ( + ecr_repository_image_no_secrets, + ) + + check = ecr_repository_image_no_secrets() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + # The check empties each file's content once it is handed to the + # scanner; only path and layer digest are needed thereafter. + assert scanned_file.content == "" + + def test_secrets_ignore_patterns_suppresses_finding(self): + """A secret matching an ignore pattern is suppressed.""" + repository = create_repository() + image = create_image() + scan_data = ImageScanData( + env=["PATH=/usr/bin", f"DB_PASSWORD={SECRET_VALUE}"], + history=[], + files=[], + truncated=False, + ) + + ecr_client = mock.MagicMock() + ecr_client.registries = {} + ecr_client.audit_config = { + "secrets_ignore_patterns": [SECRET_VALUE], + "secrets_validate": False, + } + ecr_client._get_image_scan_data = mock_image_scan_data( + [(repository, image, scan_data)] + ) + + 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_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client", + new=ecr_client, + ), + ): + from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import ( + ecr_repository_image_no_secrets, + ) + + check = ecr_repository_image_no_secrets() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_secret_in_build_history(self): + """A secret in a build history step fails, naming the step.""" + repository = create_repository() + image = create_image() + scan_data = ImageScanData( + env=[], + history=["RUN apt-get update", f'RUN export TOKEN="{SECRET_VALUE}"'], + files=[], + truncated=False, + ) + + ecr_client = mock.MagicMock() + ecr_client.registries = {} + ecr_client.audit_config = { + "secrets_ignore_patterns": [], + "secrets_validate": False, + } + ecr_client._get_image_scan_data = mock_image_scan_data( + [(repository, image, scan_data)] + ) + + 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_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client", + new=ecr_client, + ), + ): + from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import ( + ecr_repository_image_no_secrets, + ) + + check = ecr_repository_image_no_secrets() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "image history step 2" in result[0].status_extended + assert SECRET_VALUE not in result[0].status_extended + + def test_multiline_environment_secret_keeps_entry_attribution(self): + """Embedded newlines do not shift an env finding to another entry.""" + repository = create_repository() + image = create_image() + scan_data = ImageScanData( + env=[f"MULTILINE=prefix\r\n{SECRET_VALUE}", "WRONG=value"], + history=[], + files=[], + truncated=False, + ) + ecr_client = mock.MagicMock() + ecr_client.registries = {} + ecr_client.audit_config = { + "secrets_ignore_patterns": [], + "secrets_validate": False, + } + ecr_client._get_image_scan_data = mock_image_scan_data( + [(repository, image, scan_data)] + ) + + 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_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client", + new=ecr_client, + ), + ): + from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import ( + ecr_repository_image_no_secrets, + ) + + result = ecr_repository_image_no_secrets().execute() + + assert "environment variable MULTILINE" in result[0].status_extended + assert "environment variable WRONG" not in result[0].status_extended + + def test_multiline_history_secret_keeps_step_attribution(self): + """Embedded newlines do not shift a history finding to another step.""" + repository = create_repository() + image = create_image() + scan_data = ImageScanData( + env=[], + history=[f"RUN first\nexport TOKEN={SECRET_VALUE}", "RUN second"], + files=[], + truncated=False, + ) + ecr_client = mock.MagicMock() + ecr_client.registries = {} + ecr_client.audit_config = { + "secrets_ignore_patterns": [], + "secrets_validate": False, + } + ecr_client._get_image_scan_data = mock_image_scan_data( + [(repository, image, scan_data)] + ) + + 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_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client", + new=ecr_client, + ), + ): + from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import ( + ecr_repository_image_no_secrets, + ) + + result = ecr_repository_image_no_secrets().execute() + + assert "image history step 1" in result[0].status_extended + assert "image history step 2" not in result[0].status_extended + + def test_secret_in_layer_file(self): + """A secret in a layer file fails, naming the file and layer.""" + repository = create_repository() + image = create_image() + layer_digest = f"sha256:{'a' * 64}" + scan_data = ImageScanData( + env=[], + history=[], + files=[ + ImageScanFile( + path="app/config.py", + layer_digest=layer_digest, + content=f'TOKEN = "{SECRET_VALUE}"', + ) + ], + truncated=False, + ) + + ecr_client = mock.MagicMock() + ecr_client.registries = {} + ecr_client.audit_config = { + "secrets_ignore_patterns": [], + "secrets_validate": False, + } + ecr_client._get_image_scan_data = mock_image_scan_data( + [(repository, image, scan_data)] + ) + + 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_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client", + new=ecr_client, + ), + ): + from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import ( + ecr_repository_image_no_secrets, + ) + + check = ecr_repository_image_no_secrets() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "file app/config.py" in result[0].status_extended + assert layer_digest in result[0].status_extended + assert SECRET_VALUE not in result[0].status_extended + + def test_manifest_unresolvable(self): + """An unresolvable manifest is reported as MANUAL.""" + repository = create_repository() + image = create_image() + + ecr_client = mock.MagicMock() + ecr_client.registries = {} + ecr_client.audit_config = { + "secrets_ignore_patterns": [], + "secrets_validate": False, + } + ecr_client._get_image_scan_data = mock_image_scan_data( + [(repository, image, 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_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client", + new=ecr_client, + ), + ): + from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import ( + ecr_repository_image_no_secrets, + ) + + check = ecr_repository_image_no_secrets() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + "Could not resolve or retrieve the manifest" + in result[0].status_extended + ) + + def test_latest_image_lookup_error_reports_repository_manual(self): + """A failed authoritative image lookup is reported for the repository.""" + repository = create_repository() + lookup_error = RuntimeError("authoritative lookup failed") + ecr_client = mock.MagicMock() + ecr_client.audit_config = { + "secrets_ignore_patterns": [], + "secrets_validate": False, + } + ecr_client._get_image_scan_data = mock_image_scan_data( + [(repository, None, lookup_error)] + ) + + 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_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client", + new=ecr_client, + ), + ): + from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import ( + ecr_repository_image_no_secrets, + ) + + result = ecr_repository_image_no_secrets().execute() + + assert result[0].status == "MANUAL" + assert "Could not determine the latest image" in result[0].status_extended + + def test_scan_error_reports_manual_for_latest_image_per_repository(self): + """A scanner failure reports MANUAL once per repository's latest image.""" + from prowler.lib.utils.utils import SecretsScanError + + # Each repository has multiple images; the scan-error fallback must + # scope to the latest image per repository only, mirroring the + # success-path scope, not emit one MANUAL per image. + repo1 = create_repository(name="repo-1") + repo1.images_details = [ + create_image(tag="v1", digest=f"sha256:{'1' * 64}"), + create_image(tag="v2", digest=f"sha256:{'2' * 64}"), + ] + repo2 = create_repository(name="repo-2") + repo2.images_details = [ + create_image(tag="v1", digest=f"sha256:{'3' * 64}"), + create_image(tag="v2", digest=f"sha256:{'4' * 64}"), + ] + registry = Registry( + id=AWS_ACCOUNT_NUMBER, + arn=f"arn:aws:ecr:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:registry/{AWS_ACCOUNT_NUMBER}", + region=AWS_REGION_US_EAST_1, + repositories=[repo1, repo2], + ) + + ecr_client = mock.MagicMock() + ecr_client.registries = {AWS_REGION_US_EAST_1: registry} + ecr_client.audit_config = { + "secrets_ignore_patterns": [], + "secrets_validate": False, + } + # Not consumed on this path, but must be a real generator to iterate. + ecr_client._get_image_scan_data = mock_image_scan_data([]) + # The error fallback resolves each repository's scan target via + # _get_scan_target_image; mirror the real method's latest-image scope. + ecr_client._get_scan_target_image.side_effect = lambda repository: ( + repository.images_details[-1] if repository.images_details else 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_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client", + new=ecr_client, + ), + mock.patch( + "prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.detect_secrets_scan_batch", + side_effect=SecretsScanError("Scanner failure"), + ), + ): + from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import ( + ecr_repository_image_no_secrets, + ) + + check = ecr_repository_image_no_secrets() + result = check.execute() + + # One MANUAL per repository (its latest image), not one per image. + assert len(result) == 2 + for report in result: + assert report.status == "MANUAL" + assert "Could not scan image" in report.status_extended + assert "Scanner failure" in report.status_extended + + digests_reported = {report.resource_id.split("@")[-1] for report in result} + latest_digest_repo1 = repo1.images_details[-1].latest_digest.split(":")[-1][ + :12 + ] + latest_digest_repo2 = repo2.images_details[-1].latest_digest.split(":")[-1][ + :12 + ] + assert digests_reported == {latest_digest_repo1, latest_digest_repo2} + assert "Scanner failure" in result[0].status_extended + + def test_verified_secret_escalates_to_critical(self): + """A verified secret escalates severity to critical.""" + from prowler.lib.check.models import Severity + + repository = create_repository() + image = create_image() + scan_data = ImageScanData( + env=[f"TOKEN={SECRET_VALUE}"], history=[], files=[], truncated=False + ) + + ecr_client = mock.MagicMock() + ecr_client.registries = {} + ecr_client.audit_config = { + "secrets_ignore_patterns": [], + "secrets_validate": True, + } + ecr_client._get_image_scan_data = mock_image_scan_data( + [(repository, image, scan_data)] + ) + + def fake_scan_batch(payloads, **kwargs): + # The real detect_secrets_scan_batch consumes the lazily-yielded + # payloads generator as a side effect (that's what populates the + # check's `scanned` list); replicate that here while returning + # a controlled, pre-verified finding. + """Drain the payload generator like the real scanner, then return canned findings.""" + list(payloads) + return { + (0, "environment:0"): [ + { + "type": "JSON Web Token (base64url-encoded)", + "line_number": 1, + "is_verified": True, + } + ] + } + + 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_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client", + new=ecr_client, + ), + mock.patch( + "prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets.detect_secrets_scan_batch", + side_effect=fake_scan_batch, + ) as mock_scan, + ): + from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import ( + ecr_repository_image_no_secrets, + ) + + check = ecr_repository_image_no_secrets() + result = check.execute() + + assert mock_scan.call_args.kwargs.get("validate") is True + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.critical + assert "confirmed to be live" in result[0].status_extended + + def test_multiple_repositories_and_images(self): + """Mixed pass/fail results are reported across multiple repositories.""" + repo1 = create_repository(name="repo-1") + repo2 = create_repository(name="repo-2") + image1 = create_image(tag="v1", digest=f"sha256:{'1' * 64}") + image2 = create_image(tag="v2", digest=f"sha256:{'2' * 64}") + + clean_scan = ImageScanData(env=[], history=[], files=[], truncated=False) + fail_scan = ImageScanData( + env=[f"DB_PASSWORD={SECRET_VALUE}"], + history=[], + files=[], + truncated=False, + ) + + ecr_client = mock.MagicMock() + ecr_client.registries = {} + ecr_client.audit_config = { + "secrets_ignore_patterns": [], + "secrets_validate": False, + } + ecr_client._get_image_scan_data = mock_image_scan_data( + [(repo1, image1, clean_scan), (repo2, image2, fail_scan)] + ) + + 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_repository_image_no_secrets.ecr_repository_image_no_secrets.ecr_client", + new=ecr_client, + ), + ): + from prowler.providers.aws.services.ecr.ecr_repository_image_no_secrets.ecr_repository_image_no_secrets import ( + ecr_repository_image_no_secrets, + ) + + check = ecr_repository_image_no_secrets() + result = check.execute() + + assert len(result) == 2 + statuses_by_repo = {r.resource_id.split(":")[0]: r.status for r in result} + assert statuses_by_repo["repo-1"] == "PASS" + assert statuses_by_repo["repo-2"] == "FAIL" + report_by_repo = {r.resource_id.split(":")[0]: r for r in result} + assert SECRET_VALUE not in report_by_repo["repo-2"].status_extended diff --git a/tests/providers/aws/services/ecr/ecr_service_test.py b/tests/providers/aws/services/ecr/ecr_service_test.py index 60f2e0e7ec..a93badad2f 100644 --- a/tests/providers/aws/services/ecr/ecr_service_test.py +++ b/tests/providers/aws/services/ecr/ecr_service_test.py @@ -1,11 +1,22 @@ +import json +from concurrent.futures import Future from datetime import datetime -from unittest.mock import patch +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 ECR, ScanningRule +from prowler.providers.aws.services.ecr.ecr_service import ( + ECR, + ScanningRule, +) +from tests.providers.aws.services.ecr.image_scan_fixtures import ( + MANIFESTS_BY_DIGEST, + reset_image_fixtures, +) from tests.providers.aws.utils import ( AWS_ACCOUNT_NUMBER, AWS_REGION_EU_WEST_1, @@ -18,8 +29,20 @@ repo_name = "test-repo" # Mocking Access Analyzer Calls make_api_call = botocore.client.BaseClient._make_api_call +# BatchGetImage / GetDownloadUrlForLayer fixtures (which moto does not +# implement) live in image_scan_fixtures and are served by mock_make_api_call. + + +@pytest.fixture(autouse=True) +def _reset_image_fixtures(): + """Isolate the BatchGetImage/GetDownloadUrlForLayer fixtures per test.""" + reset_image_fixtures() + yield + reset_image_fixtures() + def mock_make_api_call(self, operation_name, kwarg): + """Fake botocore responses for the ECR operations this suite exercises.""" if operation_name == "DescribeImages": return { "imageDetails": [ @@ -150,10 +173,52 @@ def mock_make_api_call(self, operation_name, kwarg): }, } + if operation_name == "BatchGetImage": + digest = kwarg["imageIds"][0]["imageDigest"] + manifest = MANIFESTS_BY_DIGEST.get(digest) + if manifest is None: + return { + "images": [], + "failures": [ + { + "imageId": {"imageDigest": digest}, + "failureCode": "ImageNotFound", + } + ], + } + return { + "images": [ + { + "imageManifest": json.dumps(manifest), + "imageManifestMediaType": manifest.get("mediaType", ""), + } + ] + } + + if operation_name == "GetDownloadUrlForLayer": + digest = kwarg["layerDigest"] + return {"downloadUrl": f"https://layers.example.com/{digest}"} + 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( service, region_name=AWS_REGION_EU_WEST_1 ) @@ -169,13 +234,17 @@ def mock_generate_regional_clients(provider, service): ) class Test_ECR_Service: # Test ECR Service + """Tests for the ECR service.""" + def test_service(self): + """The service name is set correctly.""" aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) ecr = ECR(aws_provider) assert ecr.service == "ecr" # Test ECR client def test_client(self): + """Each regional client is an ECR client.""" aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) ecr = ECR(aws_provider) for regional_client in ecr.regional_clients.values(): @@ -183,6 +252,7 @@ class Test_ECR_Service: # Test ECR session def test_get_session(self): + """The session is set correctly.""" aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) ecr = ECR(aws_provider) assert ecr.session.__class__.__name__ == "Session" @@ -190,6 +260,7 @@ class Test_ECR_Service: # Test describe ECR repositories @mock_aws def test_describe_registries_and_repositories(self): + """Registries and repositories are discovered.""" ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1) ecr_client.create_repository( repositoryName=repo_name, @@ -220,6 +291,7 @@ class Test_ECR_Service: # Test describe ECR repository policies @mock_aws def test_describe_repository_policies(self): + """Repository policies are fetched and parsed.""" ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1) ecr_client.create_repository( repositoryName=repo_name, @@ -249,6 +321,7 @@ class Test_ECR_Service: # Test describe ECR repository lifecycle policies @mock_aws def test_get_lifecycle_policies(self): + """Repository lifecycle policies are fetched.""" ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1) ecr_client.create_repository( repositoryName=repo_name, @@ -268,6 +341,7 @@ class Test_ECR_Service: # Test get image details @mock_aws def test_get_image_details(self): + """Scannable, tagged images are collected and sorted by push date.""" ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1) ecr_client.create_repository( repositoryName=repo_name, @@ -366,6 +440,7 @@ class Test_ECR_Service: # Test get ECR Registries Scanning Configuration @mock_aws def test_get_registry_scanning_configuration(self): + """The registry's scanning configuration is fetched.""" aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) ecr = ECR(aws_provider) assert len(ecr.registries) == 1 @@ -378,40 +453,210 @@ 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( "application/vnd.docker.container.image.v1+json" ) def test_is_artifact_scannable_layer_tar(self): + """An uncompressed Docker layer is scannable.""" assert ECR._is_artifact_scannable( "application/vnd.docker.image.rootfs.diff.tar" ) def test_is_artifact_scannable_layer_gzip(self): + """A gzip-compressed Docker layer is scannable.""" assert ECR._is_artifact_scannable( "application/vnd.docker.image.rootfs.diff.tar.gzip" ) def test_is_artifact_scannable_oci(self): + """An OCI image config is scannable.""" assert ECR._is_artifact_scannable("application/vnd.oci.image.config.v1+json") def test_is_artifact_scannable_oci_tar(self): + """An uncompressed OCI layer is scannable.""" assert ECR._is_artifact_scannable("application/vnd.oci.image.layer.v1.tar") def test_is_artifact_scannable_oci_compressed(self): + """A gzip-compressed OCI layer is scannable.""" assert ECR._is_artifact_scannable("application/vnd.oci.image.layer.v1.tar+gzip") def test_is_artifact_scannable_none(self): + """A missing media type is not scannable.""" assert not ECR._is_artifact_scannable(None) def test_is_artifact_scannable_empty(self): + """An empty media type is not scannable.""" assert not ECR._is_artifact_scannable("") def test_is_artifact_scannable_non_scannable_tags(self): + """A signature-tagged artifact is not scannable.""" assert not ECR._is_artifact_scannable("", ["sha256-abcdefg123456.sig"]) def test_is_artifact_scannable_scannable_tags(self): + """A normally-tagged artifact is scannable.""" assert ECR._is_artifact_scannable( "application/vnd.docker.container.image.v1+json", ["abcdefg123456"] ) + + @mock_aws + def test_get_image_scan_data_selects_only_latest_image_per_repository(self): + """Only the latest image per repository is selected for scanning.""" + ecr_client_boto = client("ecr", region_name=AWS_REGION_EU_WEST_1) + ecr_client_boto.create_repository( + repositoryName=repo_name, + imageScanningConfiguration={"scanOnPush": True}, + ) + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + ecr = ECR(aws_provider) + + repository = ecr.registries[AWS_REGION_EU_WEST_1].repositories[0] + # Sanity check: this repository has several scannable tagged images. + assert len(repository.images_details) == 4 + + results = list(ecr._get_image_scan_data()) + + # Only the most recently pushed image is selected, not all four. + assert len(results) == 1 + fetched_repository, fetched_image, _ = results[0] + assert fetched_repository.name == repo_name + assert fetched_image.latest_tag == "test-tag4" + assert ( + fetched_image.latest_digest + == "sha256:43251ac64627fc331584f6c498b3aba5badc01574e2c70b2499af3af16630eed" + ) + + @mock_aws + def test_get_image_scan_data_covers_scan_on_push_disabled_repository(self): + """A scan-on-push-disabled repo (empty images_details) is still scanned.""" + ecr_client_boto = client("ecr", region_name=AWS_REGION_EU_WEST_1) + ecr_client_boto.create_repository( + repositoryName=repo_name, + imageScanningConfiguration={"scanOnPush": False}, + ) + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + ecr = ECR(aws_provider) + + repository = ecr.registries[AWS_REGION_EU_WEST_1].repositories[0] + # Scan-on-push disabled: the metadata pass leaves images_details empty... + assert repository.scan_on_push is False + assert repository.images_details == [] + + # ...yet the secret-scan path resolves the latest image via a dedicated + # describe_images lookup, so the repository is not silently skipped. + results = list(ecr._get_image_scan_data()) + + assert len(results) == 1 + fetched_repository, fetched_image, _ = results[0] + assert fetched_repository.name == repo_name + assert fetched_image.latest_tag == "test-tag4" + # The dedicated lookup must NOT mutate the shared images_details, or + # other checks would treat this repo as having a scanned image. + assert repository.images_details == [] + + @mock_aws + def test_get_image_scan_data_bounds_submitted_futures(self): + """Image fetches are submitted only as earlier results are consumed.""" + ecr_client_boto = client("ecr", region_name=AWS_REGION_EU_WEST_1) + for index in range(10): + ecr_client_boto.create_repository( + repositoryName=f"{repo_name}-{index}", + imageScanningConfiguration={"scanOnPush": True}, + ) + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + ecr = ECR(aws_provider) + repositories = ecr.registries[AWS_REGION_EU_WEST_1].repositories + + executor = MagicMock() + executor.__enter__.return_value = executor + futures = [] + + def submit(*_args): + future = Future() + futures.append(future) + if len(futures) == 1: + future.set_result(None) + return future + + executor.submit.side_effect = submit + with patch( + "prowler.providers.aws.services.ecr.ecr_service.ThreadPoolExecutor", + return_value=executor, + ): + results = ecr._get_image_scan_data() + first_result = next(results) + + assert first_result[0] == repositories[0] + assert ( + first_result[1].latest_digest + == repositories[0].images_details[-1].latest_digest + ) + assert executor.submit.call_count == 4 + + @mock_aws + def test_get_scan_target_image_ignores_stale_scanned_image(self): + """Secret scanning selects a newer image absent from scan findings.""" + ecr_client_boto = client("ecr", region_name=AWS_REGION_EU_WEST_1) + ecr_client_boto.create_repository( + repositoryName=repo_name, + imageScanningConfiguration={"scanOnPush": True}, + ) + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + ecr = ECR(aws_provider) + repository = ecr.registries[AWS_REGION_EU_WEST_1].repositories[0] + older_scanned_image = repository.images_details[0] + repository.images_details = [older_scanned_image] + + target = ecr._get_scan_target_image(repository) + + assert target.latest_tag == "test-tag4" + assert target.image_pushed_at > older_scanned_image.image_pushed_at + + @mock_aws + def test_get_scan_target_image_lookup_failure_rejects_stale_image(self): + """A failed authoritative lookup does not select cached scan metadata.""" + ecr_client_boto = client("ecr", region_name=AWS_REGION_EU_WEST_1) + ecr_client_boto.create_repository( + repositoryName=repo_name, + imageScanningConfiguration={"scanOnPush": True}, + ) + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + ecr = ECR(aws_provider) + repository = ecr.registries[AWS_REGION_EU_WEST_1].repositories[0] + repository.images_details = [repository.images_details[0]] + + with patch.object( + ecr.regional_clients[AWS_REGION_EU_WEST_1], + "get_paginator", + side_effect=RuntimeError("authoritative lookup failed"), + ): + target = ecr._get_scan_target_image(repository) + scan_results = list(ecr._get_image_scan_data()) + + assert isinstance(target, RuntimeError) + assert len(scan_results) == 1 + _, result_image, result_error = scan_results[0] + assert result_image is None and isinstance(result_error, RuntimeError) diff --git a/tests/providers/aws/services/ecr/image_inspection_test.py b/tests/providers/aws/services/ecr/image_inspection_test.py new file mode 100644 index 0000000000..8d681bf354 --- /dev/null +++ b/tests/providers/aws/services/ecr/image_inspection_test.py @@ -0,0 +1,512 @@ +import gzip +import json +import random +import tarfile +from io import BytesIO +from unittest.mock import patch + +import botocore +import pytest +import zstandard +from boto3 import client +from moto import mock_aws + +from prowler.providers.aws.services.ecr.image_inspection import ( + MAX_FILE_BYTES, + MAX_LAYER_DOWNLOAD_BYTES, + ImageInspector, + _CappedLayerReader, + _LayerTooLargeError, +) +from tests.providers.aws.services.ecr.image_scan_fixtures import ( + BLOBS_BY_DIGEST, + CHILD_AMD64_DIGEST, + CHILD_ARM64_DIGEST, + CONFIG_DIGEST, + CONFIG_JSON, + IMAGE_DIGEST, + LAYER_DIGEST, + MANIFESTS_BY_DIGEST, + MULTI_ARCH_INDEX_DIGEST, + MULTI_ARCH_MANIFEST_LIST, + SIMPLE_MANIFEST, + build_gzip_tar, + build_tar, + mock_requests_get, + reset_image_fixtures, +) +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, +) + +REPO_NAME = "test-repo" + +_original_make_api_call = botocore.client.BaseClient._make_api_call + +_REQUESTS_GET = "prowler.providers.aws.services.ecr.image_inspection.requests.get" + + +def mock_make_api_call(self, operation_name, kwarg): + """Serve BatchGetImage/GetDownloadUrlForLayer from fixtures; delegate the rest. + + moto implements neither operation, so they are answered from the per-test + ``MANIFESTS_BY_DIGEST`` fixtures; every other call falls through to the real + (moto-backed) implementation. + """ + if operation_name == "BatchGetImage": + digest = kwarg["imageIds"][0]["imageDigest"] + manifest = MANIFESTS_BY_DIGEST.get(digest) + if manifest is None: + return { + "images": [], + "failures": [ + { + "imageId": {"imageDigest": digest}, + "failureCode": "ImageNotFound", + } + ], + } + return { + "images": [ + { + "imageManifest": json.dumps(manifest), + "imageManifestMediaType": manifest.get("mediaType", ""), + } + ] + } + if operation_name == "GetDownloadUrlForLayer": + digest = kwarg["layerDigest"] + return {"downloadUrl": f"https://layers.example.com/{digest}"} + return _original_make_api_call(self, operation_name, kwarg) + + +@patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call) +class Test_ImageInspector: + """Tests for the bounded image-content extraction in image_inspection.""" + + @pytest.fixture(autouse=True) + def _reset_image_fixtures(self): + """Isolate the BatchGetImage/GetDownloadUrlForLayer fixtures per test.""" + reset_image_fixtures() + yield + reset_image_fixtures() + + @staticmethod + def _fetch(digest=IMAGE_DIGEST): + """Fetch scan data for one image, with the layer download stubbed.""" + ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1) + with patch(_REQUESTS_GET, new=mock_requests_get): + return ImageInspector().fetch_image_scan_data( + ecr_client, AWS_ACCOUNT_NUMBER, REPO_NAME, digest + ) + + @mock_aws + def test_fetch_image_scan_data_simple_image(self): + """A single-manifest image's config and layer file are scanned.""" + MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = SIMPLE_MANIFEST + BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode() + BLOBS_BY_DIGEST[LAYER_DIGEST] = build_gzip_tar({"app/config.py": "TOKEN = 'x'"}) + + scan_data = self._fetch() + + assert scan_data is not None + assert scan_data.env == ["PATH=/usr/bin", "TOKEN=super-secret-value"] + assert scan_data.history == ["/bin/sh -c #(nop) ADD file", "RUN echo hi"] + assert len(scan_data.files) == 1 + assert scan_data.files[0].path == "app/config.py" + assert scan_data.files[0].layer_digest == LAYER_DIGEST + assert scan_data.files[0].content == "TOKEN = 'x'" + assert scan_data.truncated is False + + @mock_aws + def test_fetch_image_scan_data_resolves_multi_arch_manifest(self): + """A multi-arch scan is incomplete when only one child is inspected.""" + MANIFESTS_BY_DIGEST[MULTI_ARCH_INDEX_DIGEST] = MULTI_ARCH_MANIFEST_LIST + MANIFESTS_BY_DIGEST[CHILD_AMD64_DIGEST] = SIMPLE_MANIFEST + BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode() + BLOBS_BY_DIGEST[LAYER_DIGEST] = build_gzip_tar({"app/config.py": "TOKEN = 'x'"}) + + scan_data = self._fetch(digest=MULTI_ARCH_INDEX_DIGEST) + + # Only the amd64/linux child manifest is resolved and scanned; the + # arm64 and attestation entries in the manifest list are ignored. + assert scan_data is not None + assert scan_data.env == ["PATH=/usr/bin", "TOKEN=super-secret-value"] + assert len(scan_data.files) == 1 + assert scan_data.files[0].path == "app/config.py" + assert scan_data.truncated is True + + @mock_aws + def test_fetch_image_scan_data_skips_oversized_layer(self): + """A layer over the size cap is skipped, not downloaded.""" + oversized_manifest = { + "schemaVersion": 2, + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "config": {"digest": CONFIG_DIGEST, "size": 10}, + "layers": [ + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "digest": LAYER_DIGEST, + "size": MAX_LAYER_DOWNLOAD_BYTES + 1, + } + ], + } + MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = oversized_manifest + BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode() + + scan_data = self._fetch() + + # The oversized layer is never downloaded (only the config blob is in + # BLOBS_BY_DIGEST), yet the fetch completes without raising. + assert scan_data is not None + assert scan_data.files == [] + assert scan_data.truncated is True + + @mock_aws + def test_fetch_image_scan_data_manifest_not_found_returns_none(self): + """An unknown digest resolves to no scan data.""" + ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1) + + scan_data = ImageInspector().fetch_image_scan_data( + ecr_client, AWS_ACCOUNT_NUMBER, REPO_NAME, f"sha256:{'f' * 64}" + ) + + assert scan_data is None + + @mock_aws + def test_fetch_image_scan_data_config_download_failure_marks_truncated(self): + """A config blob that cannot be retrieved marks coverage incomplete. + + Empty env/history would otherwise be indistinguishable from a clean + config, so the fetch flags the result as truncated rather than risking + a false PASS at the check level. + """ + MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = SIMPLE_MANIFEST + # Register the layer but NOT the config blob, so the config download + # fails while the layer is still scanned cleanly. + BLOBS_BY_DIGEST[LAYER_DIGEST] = build_gzip_tar({"app/config.py": "clean"}) + + scan_data = self._fetch() + + assert scan_data is not None + assert scan_data.env == [] + assert scan_data.history == [] + assert scan_data.truncated is True + assert [f.path for f in scan_data.files] == ["app/config.py"] + + @mock_aws + def test_fetch_image_scan_data_member_over_remaining_budget_is_truncated(self): + """A layer exceeding the remaining stream budget is truncated. + + Tar headers and padding consume the authoritative decompressed-byte + budget before member payloads are exposed for scanning. + """ + MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = SIMPLE_MANIFEST + BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode() + BLOBS_BY_DIGEST[LAYER_DIGEST] = build_gzip_tar( + {"app/first.txt": "a" * 100, "app/second.txt": "b" * 5000} + ) + + ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1) + with ( + patch(_REQUESTS_GET, new=mock_requests_get), + patch( + "prowler.providers.aws.services.ecr.image_inspection.MAX_TOTAL_BYTES_PER_IMAGE", + 1000, + ), + ): + scan_data = ImageInspector().fetch_image_scan_data( + ecr_client, AWS_ACCOUNT_NUMBER, REPO_NAME, IMAGE_DIGEST + ) + + assert scan_data is not None + assert scan_data.files == [] + assert scan_data.truncated is True + + @mock_aws + def test_fetch_image_scan_data_oversized_members_count_toward_budget(self): + """Members skipped for size still count toward the per-image budget. + + A streaming reader must decompress each member to advance past it, so + oversized-and-skipped members must still consume budget; otherwise a + layer of many just-over-limit files would decompress unbounded. The + loop must stop before reaching a later scannable member. + """ + MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = SIMPLE_MANIFEST + BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode() + # Ten 300-byte members (each over the patched 100-byte MAX_FILE_BYTES, + # so each is skipped for content) followed by a small, scannable file. + layer = {f"app/big{i}.bin": "x" * 300 for i in range(10)} + layer["app/reachable.txt"] = "hello" + BLOBS_BY_DIGEST[LAYER_DIGEST] = build_gzip_tar(layer) + + ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1) + with ( + patch(_REQUESTS_GET, new=mock_requests_get), + patch( + "prowler.providers.aws.services.ecr.image_inspection.MAX_FILE_BYTES", + 100, + ), + patch( + "prowler.providers.aws.services.ecr.image_inspection.MAX_TOTAL_BYTES_PER_IMAGE", + 1000, + ), + ): + scan_data = ImageInspector().fetch_image_scan_data( + ecr_client, AWS_ACCOUNT_NUMBER, REPO_NAME, IMAGE_DIGEST + ) + + assert scan_data is not None + # The oversized members exhaust the 1000-byte budget after ~3 of them, + # so the loop stops before ever reaching app/reachable.txt. If skipped + # members were not counted, reachable.txt would be scanned. + assert scan_data.files == [] + assert scan_data.truncated is True + + @mock_aws + def test_fetch_image_scan_data_tar_over_stream_budget_is_truncated(self): + """Tar headers, padding, and non-files consume the image byte budget.""" + MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = SIMPLE_MANIFEST + BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode() + layer = BytesIO() + with tarfile.open(fileobj=layer, mode="w") as archive: + for index in range(20): + directory = tarfile.TarInfo(f"metadata-{index}/") + directory.type = tarfile.DIRTYPE + archive.addfile(directory) + content = b"x" + member = tarfile.TarInfo("app/reachable.txt") + member.size = len(content) + archive.addfile(member, BytesIO(content)) + BLOBS_BY_DIGEST[LAYER_DIGEST] = gzip.compress(layer.getvalue()) + + ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1) + with ( + patch(_REQUESTS_GET, new=mock_requests_get), + patch( + "prowler.providers.aws.services.ecr.image_inspection.MAX_TOTAL_BYTES_PER_IMAGE", + 10 * 1024 - 1, + ), + ): + scan_data = ImageInspector().fetch_image_scan_data( + ecr_client, AWS_ACCOUNT_NUMBER, REPO_NAME, IMAGE_DIGEST + ) + + assert scan_data is not None + assert scan_data.files == [] + assert scan_data.truncated is True + + def test_select_child_manifest_digest_falls_back_to_non_amd64(self): + """With no amd64/linux entry, the first non-attestation candidate is picked.""" + manifest_list = { + "manifests": [ + { + "digest": CHILD_ARM64_DIGEST, + "platform": {"architecture": "arm64", "os": "linux"}, + }, + { + "digest": f"sha256:{'5' * 64}", + "platform": {"architecture": "unknown", "os": "unknown"}, + "annotations": { + "vnd.docker.reference.type": "attestation-manifest" + }, + }, + ] + } + + digest = ImageInspector._select_child_manifest_digest(manifest_list) + + assert digest == CHILD_ARM64_DIGEST + + @mock_aws + def test_fetch_image_scan_data_zstd_layer(self): + """A zstd-compressed layer is streamed, decompressed, and scanned.""" + zstd_manifest = { + "schemaVersion": 2, + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "config": {"digest": CONFIG_DIGEST, "size": 100}, + "layers": [ + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+zstd", + "digest": LAYER_DIGEST, + "size": 200, + } + ], + } + MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = zstd_manifest + BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode() + BLOBS_BY_DIGEST[LAYER_DIGEST] = zstandard.ZstdCompressor().compress( + build_tar({"app/config.py": "TOKEN = 'x'"}) + ) + + scan_data = self._fetch() + + assert scan_data is not None + assert len(scan_data.files) == 1 + assert scan_data.files[0].content == "TOKEN = 'x'" + + @mock_aws + def test_fetch_image_scan_data_zstd_layer_over_compressed_cap_is_truncated(self): + """A zstd layer whose compressed bytes exceed the cap is truncated. + + The streaming decompressor reads through a _CappedLayerReader, so a + layer whose compressed size exceeds MAX_LAYER_DOWNLOAD_BYTES (patched + here) is cut off and disclosed via truncated instead of being buffered + or decompressed unbounded. + """ + zstd_manifest = { + "schemaVersion": 2, + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "config": {"digest": CONFIG_DIGEST, "size": 100}, + "layers": [ + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+zstd", + "digest": LAYER_DIGEST, + # Declares zero size so it passes the pre-download check; + # the actual compressed bytes exceed the (patched) cap. + "size": 0, + } + ], + } + # Incompressible payload (so the compressed frame stays large), split + # across two members so the first is read before the cap trips while + # the second is streamed. + rng = random.Random(0) + incompressible = bytes(rng.randrange(256) for _ in range(64 * 1024)).decode( + "latin-1" + ) + layer_blob = zstandard.ZstdCompressor().compress( + build_tar({"app/config.py": "TOKEN = 'x'", "app/big.bin": incompressible}) + ) + assert len(layer_blob) > 10 * 1024 + MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = zstd_manifest + BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode() + BLOBS_BY_DIGEST[LAYER_DIGEST] = layer_blob + + ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1) + with ( + patch(_REQUESTS_GET, new=mock_requests_get), + patch( + "prowler.providers.aws.services.ecr.image_inspection.MAX_LAYER_DOWNLOAD_BYTES", + len(layer_blob) - 1, + ), + ): + scan_data = ImageInspector().fetch_image_scan_data( + ecr_client, AWS_ACCOUNT_NUMBER, REPO_NAME, IMAGE_DIGEST + ) + + assert scan_data is not None + assert scan_data.truncated is True + + @mock_aws + def test_fetch_image_scan_data_uncompressed_tar_layer(self): + """An uncompressed tar layer is read and scanned directly.""" + tar_manifest = { + "schemaVersion": 2, + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "config": {"digest": CONFIG_DIGEST, "size": 100}, + "layers": [ + { + "mediaType": "application/vnd.oci.image.layer.v1.tar", + "digest": LAYER_DIGEST, + "size": 200, + } + ], + } + MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = tar_manifest + BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode() + BLOBS_BY_DIGEST[LAYER_DIGEST] = build_tar({"app/config.py": "TOKEN = 'x'"}) + + scan_data = self._fetch() + + assert scan_data is not None + assert len(scan_data.files) == 1 + assert scan_data.files[0].content == "TOKEN = 'x'" + + @mock_aws + def test_fetch_image_scan_data_skips_whiteout_and_oversized_file(self): + """Whiteout markers and oversized files are skipped, not scanned.""" + MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = SIMPLE_MANIFEST + BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode() + BLOBS_BY_DIGEST[LAYER_DIGEST] = build_gzip_tar( + { + ".wh.deleted": "should never appear", + "app/config.py": "TOKEN = 'x'", + "app/oversized.bin": "x" * (MAX_FILE_BYTES + 1), + } + ) + + scan_data = self._fetch() + + assert scan_data is not None + assert [f.path for f in scan_data.files] == ["app/config.py"] + assert scan_data.truncated is True + + def test_capped_layer_reader_allows_up_to_max(self): + """Reading exactly the byte budget succeeds and then reports EOF.""" + import io + + reader = _CappedLayerReader(io.BytesIO(b"x" * 10), max_bytes=10) + assert reader.read() == b"x" * 10 + assert reader.read() == b"" + + def test_capped_layer_reader_raises_when_exceeding_max(self): + """A stream longer than the byte budget raises _LayerTooLargeError.""" + import io + + reader = _CappedLayerReader(io.BytesIO(b"x" * 100), max_bytes=10) + with pytest.raises(_LayerTooLargeError): + reader.read() + + @mock_aws + def test_fetch_image_scan_data_streamed_layer_over_cap_is_truncated(self): + """A layer streaming past MAX_LAYER_DOWNLOAD_BYTES is skipped, not buffered.""" + undersized_manifest = { + "schemaVersion": 2, + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "config": {"digest": CONFIG_DIGEST, "size": 100}, + "layers": [ + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "digest": LAYER_DIGEST, + # Declares zero size so it passes the pre-download check; + # the actual streamed bytes exceed the (patched) cap. + "size": 0, + } + ], + } + # Two-member layer: a small first file that is scanned, then a large + # incompressible second file. The blob must exceed tarfile's internal + # read buffer (~10 KB) so tarfile.open() consumes only part of it and + # the cap (set one byte below the full layer) is instead exceeded while + # the second member's content is read during archive iteration. + rng = random.Random(0) + incompressible = bytes(rng.randrange(256) for _ in range(64 * 1024)).decode( + "latin-1" + ) + layer_blob = build_gzip_tar( + {"app/config.py": "TOKEN = 'x'", "app/big.bin": incompressible} + ) + assert len(layer_blob) > 10 * 1024 # larger than tarfile's read buffer + MANIFESTS_BY_DIGEST[IMAGE_DIGEST] = undersized_manifest + BLOBS_BY_DIGEST[CONFIG_DIGEST] = json.dumps(CONFIG_JSON).encode() + BLOBS_BY_DIGEST[LAYER_DIGEST] = layer_blob + + ecr_client = client("ecr", region_name=AWS_REGION_EU_WEST_1) + with ( + patch(_REQUESTS_GET, new=mock_requests_get), + patch( + "prowler.providers.aws.services.ecr.image_inspection.MAX_LAYER_DOWNLOAD_BYTES", + len(layer_blob) - 1, + ), + ): + scan_data = ImageInspector().fetch_image_scan_data( + ecr_client, AWS_ACCOUNT_NUMBER, REPO_NAME, IMAGE_DIGEST + ) + + # The over-cap layer is disclosed via truncated, and the config-derived + # env/history (fetched independently of the layer) are still returned. + assert scan_data is not None + assert scan_data.truncated is True + assert scan_data.env == ["PATH=/usr/bin", "TOKEN=super-secret-value"] diff --git a/tests/providers/aws/services/ecr/image_scan_fixtures.py b/tests/providers/aws/services/ecr/image_scan_fixtures.py new file mode 100644 index 0000000000..0f3d68a59a --- /dev/null +++ b/tests/providers/aws/services/ecr/image_scan_fixtures.py @@ -0,0 +1,145 @@ +"""Shared fixtures for ECR image-scanning tests. + +Used by both the ECR service tests (orchestration) and the image_inspection +tests (bounded extraction), so the manifest/layer fixtures and the fake layer +download live in one place. moto implements neither BatchGetImage nor +GetDownloadUrlForLayer, so each test registers the manifests/blobs it needs in +``MANIFESTS_BY_DIGEST``/``BLOBS_BY_DIGEST`` and a patched ``_make_api_call`` +serves them. +""" + +import io +import tarfile + +IMAGE_DIGEST = f"sha256:{'1' * 64}" +CONFIG_DIGEST = f"sha256:{'c' * 64}" +LAYER_DIGEST = f"sha256:{'d' * 64}" +MULTI_ARCH_INDEX_DIGEST = f"sha256:{'2' * 64}" +CHILD_AMD64_DIGEST = f"sha256:{'3' * 64}" +CHILD_ARM64_DIGEST = f"sha256:{'4' * 64}" +ATTESTATION_DIGEST = f"sha256:{'5' * 64}" + +SIMPLE_MANIFEST = { + "schemaVersion": 2, + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "config": { + "mediaType": "application/vnd.docker.container.image.v1+json", + "digest": CONFIG_DIGEST, + "size": 100, + }, + "layers": [ + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "digest": LAYER_DIGEST, + "size": 200, + } + ], +} + +MULTI_ARCH_MANIFEST_LIST = { + "schemaVersion": 2, + "mediaType": "application/vnd.docker.distribution.manifest.list.v2+json", + "manifests": [ + { + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "digest": CHILD_AMD64_DIGEST, + "size": 10, + "platform": {"architecture": "amd64", "os": "linux"}, + }, + { + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "digest": CHILD_ARM64_DIGEST, + "size": 10, + "platform": {"architecture": "arm64", "os": "linux"}, + }, + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": ATTESTATION_DIGEST, + "size": 10, + "platform": {"architecture": "unknown", "os": "unknown"}, + "annotations": {"vnd.docker.reference.type": "attestation-manifest"}, + }, + ], +} + +CONFIG_JSON = { + "config": {"Env": ["PATH=/usr/bin", "TOKEN=super-secret-value"]}, + "history": [ + {"created_by": "/bin/sh -c #(nop) ADD file"}, + {"created_by": "RUN echo hi"}, + ], +} + +# Per-test fixtures keyed by digest. moto implements neither BatchGetImage nor +# GetDownloadUrlForLayer, so tests populate these and a patched _make_api_call / +# requests.get serves them. Cleared between tests via reset_image_fixtures(). +MANIFESTS_BY_DIGEST = {} +BLOBS_BY_DIGEST = {} + + +def reset_image_fixtures(): + """Clear the per-test manifest/blob fixtures.""" + MANIFESTS_BY_DIGEST.clear() + BLOBS_BY_DIGEST.clear() + + +def build_tar(files: dict) -> bytes: + """Build an uncompressed tar archive from the given files.""" + tar_buffer = io.BytesIO() + with tarfile.open(fileobj=tar_buffer, mode="w") as tar: + for name, content in files.items(): + data = content.encode("latin-1") + info = tarfile.TarInfo(name=name) + info.size = len(data) + tar.addfile(info, io.BytesIO(data)) + return tar_buffer.getvalue() + + +def build_gzip_tar(files: dict) -> bytes: + """Build a gzip-compressed tar archive from the given files.""" + tar_buffer = io.BytesIO() + with tarfile.open(fileobj=tar_buffer, mode="w:gz") as tar: + for name, content in files.items(): + data = content.encode("latin-1") + info = tarfile.TarInfo(name=name) + info.size = len(data) + tar.addfile(info, io.BytesIO(data)) + return tar_buffer.getvalue() + + +class FakeLayerResponse: + """A minimal stand-in for a requests.Response over a layer download.""" + + def __init__(self, data: bytes): + """Store the fixture bytes to serve (via iter_content and .raw).""" + self._data = data + # Streaming gzip/tar layers read the compressed bytes straight from + # response.raw; the buffered config/zstd path uses iter_content. + self.raw = io.BytesIO(data) + + def raise_for_status(self): + """No-op: fixture responses are always successful.""" + + def iter_content(self, chunk_size=1024 * 1024): + """Yield the fixture bytes in chunks.""" + for start in range(0, len(self._data), chunk_size): + yield self._data[start : start + chunk_size] + + def close(self): + """Close the backing raw stream, mirroring requests.Response.close.""" + self.raw.close() + + def __enter__(self): + """Support use as a context manager.""" + return self + + def __exit__(self, *_): + """Close on exit, mirroring requests.Response context-manager use.""" + self.close() + return False + + +def mock_requests_get(url, **_): + """Return the fixture bytes registered for the requested layer's URL.""" + digest = url.rsplit("/", 1)[-1] + return FakeLayerResponse(BLOBS_BY_DIGEST[digest]) diff --git a/tests/providers/aws/services/ecs/ecs_task_definitions_undescribed_test.py b/tests/providers/aws/services/ecs/ecs_task_definitions_undescribed_test.py new file mode 100644 index 0000000000..40b25ed4d2 --- /dev/null +++ b/tests/providers/aws/services/ecs/ecs_task_definitions_undescribed_test.py @@ -0,0 +1,164 @@ +from datetime import datetime, timezone +from importlib import import_module +from types import SimpleNamespace +from unittest.mock import patch + +import botocore +import pytest + +from prowler.providers.aws.services.ecs.ecs_service import ECS, TaskDefinition +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +TASK_NAME = "test-task" +TASK_REVISION = "1" +TASK_ARN = ( + f"arn:aws:ecs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:" + f"task-definition/{TASK_NAME}:{TASK_REVISION}" +) +make_api_call = botocore.client.BaseClient._make_api_call + + +def _mock_ecs_api(describe_result): + def mock_make_api_call(self, operation_name, kwargs): + if operation_name == "ListTaskDefinitions": + return {"taskDefinitionArns": [TASK_ARN]} + if operation_name == "DescribeTaskDefinition": + if isinstance(describe_result, Exception): + raise describe_result + return describe_result + if operation_name == "ListClusters": + return {"clusterArns": []} + return make_api_call(self, operation_name, kwargs) + + return mock_make_api_call + + +def _collect_task_definition(describe_result): + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + with patch( + "botocore.client.BaseClient._make_api_call", + new=_mock_ecs_api(describe_result), + ): + return ECS(aws_provider).task_definitions[TASK_ARN] + + +def _undescribed_ecs_client(): + task_definition = TaskDefinition( + name=TASK_NAME, + arn=TASK_ARN, + revision=TASK_REVISION, + region=AWS_REGION_US_EAST_1, + environment_variables=[], + ) + task_definition.container_definitions = None + return SimpleNamespace( + audit_config={}, + task_definitions={TASK_ARN: task_definition}, + ) + + +def test_failed_describe_leaves_task_definition_undescribed(): + error = botocore.exceptions.ClientError( + {"Error": {"Code": "ThrottlingException", "Message": "rate exceeded"}}, + "DescribeTaskDefinition", + ) + + task_definition = _collect_task_definition(error) + + assert task_definition.container_definitions is None + assert task_definition.pid_mode is None + assert task_definition.network_mode is None + + +def test_successful_describe_preserves_empty_container_definitions(): + task_definition = _collect_task_definition( + { + "taskDefinition": { + "containerDefinitions": [], + "pidMode": "task", + "networkMode": "awsvpc", + }, + "tags": [], + } + ) + + assert task_definition.container_definitions == [] + assert task_definition.pid_mode == "task" + assert task_definition.network_mode == "awsvpc" + + +def test_partial_parse_leaves_task_definition_undescribed(): + task_definition = _collect_task_definition( + { + "taskDefinition": { + "containerDefinitions": [ + {"name": "valid-container"}, + {"privileged": False}, + ], + "pidMode": "host", + "networkMode": "host", + "registeredAt": datetime(2026, 8, 13, tzinfo=timezone.utc), + }, + "tags": [{"key": "Environment", "value": "production"}], + } + ) + + assert task_definition.container_definitions is None + assert task_definition.pid_mode is None + assert task_definition.network_mode is None + assert task_definition.registered_at is None + assert task_definition.tags == [] + + +@pytest.mark.parametrize( + ("check_package", "check_name"), + [ + ( + "ecs_task_definitions_containers_readonly_access", + "ecs_task_definitions_containers_readonly_access", + ), + ( + "ecs_task_definitions_host_namespace_not_shared", + "ecs_task_definitions_host_namespace_not_shared", + ), + ( + "ecs_task_definitions_host_networking_mode_users", + "ecs_task_definitions_host_networking_mode_users", + ), + ( + "ecs_task_definitions_logging_block_mode", + "ecs_task_definitions_logging_block_mode", + ), + ( + "ecs_task_definitions_logging_enabled", + "ecs_task_definitions_logging_enabled", + ), + ( + "ecs_task_definitions_no_environment_secrets", + "ecs_task_definitions_no_environment_secrets", + ), + ( + "ecs_task_definitions_no_privileged_containers", + "ecs_task_definitions_no_privileged_containers", + ), + ], +) +def test_undescribed_task_definitions_are_not_reported( + check_package, check_name, monkeypatch +): + with patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider([AWS_REGION_US_EAST_1]), + ): + module = import_module( + f"prowler.providers.aws.services.ecs.{check_package}.{check_name}" + ) + monkeypatch.setattr(module, "ecs_client", _undescribed_ecs_client()) + + check = getattr(module, check_name)() + + assert check.execute() == [] 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/elasticbeanstalk/elasticbeanstalk_environment_no_secrets_in_configuration/elasticbeanstalk_environment_no_secrets_in_configuration_test.py b/tests/providers/aws/services/elasticbeanstalk/elasticbeanstalk_environment_no_secrets_in_configuration/elasticbeanstalk_environment_no_secrets_in_configuration_test.py new file mode 100644 index 0000000000..966f129739 --- /dev/null +++ b/tests/providers/aws/services/elasticbeanstalk/elasticbeanstalk_environment_no_secrets_in_configuration/elasticbeanstalk_environment_no_secrets_in_configuration_test.py @@ -0,0 +1,315 @@ +from unittest import mock + +from prowler.lib.utils.utils import SecretsScanError +from prowler.providers.aws.services.elasticbeanstalk.elasticbeanstalk_service import ( + Environment, +) +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + + +class Test_elasticbeanstalk_environment_no_secrets_in_configuration: + def test_environment_configuration_with_secrets(self): + elasticbeanstalk_client = mock.MagicMock() + elasticbeanstalk_client.audit_config = {"secrets_ignore_patterns": []} + eb_env_arn = f"arn:partition:elasticbeanstalk:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:environment/production" + + environment = Environment( + id="e-vbxmknpy2z", + name="production", + arn=eb_env_arn, + region=AWS_REGION_US_EAST_1, + application_name="test-app", + option_settings=[ + { + "Namespace": "aws:elasticbeanstalk:application:environment", + "OptionName": "JSON_WEB_TOKEN", + "Value": "test-token", + }, + { + "Namespace": "aws:elasticbeanstalk:application:environment", + "OptionName": "MONGODB_URI", + "Value": "test-mongodb-uri", + }, + ], + ) + elasticbeanstalk_client.environments = {eb_env_arn: environment} + + mocked_scan_results = { + (0, 0): [ + { + "filename": "payload", + "line_number": 1, + "type": "SecretKeyword", + "hashed_secret": "mocked-hash", + "is_verified": False, + } + ], + (0, 1): [ + { + "filename": "payload", + "line_number": 1, + "type": "SecretKeyword", + "hashed_secret": "mocked-hash-2", + "is_verified": False, + } + ], + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider(), + ), + mock.patch( + "prowler.providers.aws.services.elasticbeanstalk.elasticbeanstalk_environment_no_secrets_in_configuration.elasticbeanstalk_environment_no_secrets_in_configuration.elasticbeanstalk_client", + new=elasticbeanstalk_client, + ), + mock.patch( + "prowler.providers.aws.services.elasticbeanstalk.elasticbeanstalk_environment_no_secrets_in_configuration.elasticbeanstalk_environment_no_secrets_in_configuration.detect_secrets_scan_batch", + return_value=mocked_scan_results, + ), + ): + from prowler.providers.aws.services.elasticbeanstalk.elasticbeanstalk_environment_no_secrets_in_configuration.elasticbeanstalk_environment_no_secrets_in_configuration import ( + elasticbeanstalk_environment_no_secrets_in_configuration, + ) + + check = elasticbeanstalk_environment_no_secrets_in_configuration() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert environment.name in result[0].status_extended + assert "JSON_WEB_TOKEN" in result[0].status_extended + assert "MONGODB_URI" in result[0].status_extended + + def test_environment_configuration_without_secrets(self): + elasticbeanstalk_client = mock.MagicMock() + elasticbeanstalk_client.audit_config = {"secrets_ignore_patterns": []} + eb_env_arn = f"arn:partition:elasticbeanstalk:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:environment/staging" + + environment = Environment( + id="e-icsgecu3wf", + name="staging", + arn=eb_env_arn, + region=AWS_REGION_US_EAST_1, + application_name="test-app", + option_settings=[ + { + "Namespace": "aws:elasticbeanstalk:application:environment", + "OptionName": "LOG_LEVEL", + "Value": "INFO", + }, + { + "Namespace": "aws:elasticbeanstalk:application:environment", + "OptionName": "SystemType", + "Value": "enhanced", + }, + ], + ) + elasticbeanstalk_client.environments = {eb_env_arn: environment} + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider(), + ), + mock.patch( + "prowler.providers.aws.services.elasticbeanstalk.elasticbeanstalk_environment_no_secrets_in_configuration.elasticbeanstalk_environment_no_secrets_in_configuration.elasticbeanstalk_client", + new=elasticbeanstalk_client, + ), + ): + from prowler.providers.aws.services.elasticbeanstalk.elasticbeanstalk_environment_no_secrets_in_configuration.elasticbeanstalk_environment_no_secrets_in_configuration import ( + elasticbeanstalk_environment_no_secrets_in_configuration, + ) + + check = elasticbeanstalk_environment_no_secrets_in_configuration() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"No secrets found in the configuration of Elastic Beanstalk environment {environment.name}." + ) + + def test_environment_configuration_scan_error(self): + elasticbeanstalk_client = mock.MagicMock() + elasticbeanstalk_client.audit_config = {"secrets_ignore_patterns": []} + eb_env_arn = f"arn:partition:elasticbeanstalk:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:environment/testing" + + environment = Environment( + id="e-mz7paq4pqp", + name="testing", + arn=eb_env_arn, + region=AWS_REGION_US_EAST_1, + application_name="test-app", + option_settings=[ + { + "Namespace": "aws:elasticbeanstalk:application:environment", + "OptionName": "DB_SSL_ENABLED", + "Value": "true", + }, + { + "Namespace": "aws:elasticbeanstalk:application:environment", + "OptionName": "CACHE_TTL", + "Value": "300", + }, + ], + ) + elasticbeanstalk_client.environments = {eb_env_arn: environment} + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider(), + ), + mock.patch( + "prowler.providers.aws.services.elasticbeanstalk.elasticbeanstalk_environment_no_secrets_in_configuration.elasticbeanstalk_environment_no_secrets_in_configuration.elasticbeanstalk_client", + new=elasticbeanstalk_client, + ), + mock.patch( + "prowler.providers.aws.services.elasticbeanstalk.elasticbeanstalk_environment_no_secrets_in_configuration.elasticbeanstalk_environment_no_secrets_in_configuration.detect_secrets_scan_batch", + side_effect=SecretsScanError("Kingfisher exited with code 1"), + ), + ): + from prowler.providers.aws.services.elasticbeanstalk.elasticbeanstalk_environment_no_secrets_in_configuration.elasticbeanstalk_environment_no_secrets_in_configuration import ( + elasticbeanstalk_environment_no_secrets_in_configuration, + ) + + check = elasticbeanstalk_environment_no_secrets_in_configuration() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + f"Could not scan the configuration of Elastic Beanstalk environment {environment.name} for secrets; manual review is required." + in result[0].status_extended + ) + + def test_no_environment(self): + elasticbeanstalk_client = mock.MagicMock() + elasticbeanstalk_client.audit_config = {"secrets_ignore_patterns": []} + + elasticbeanstalk_client.environments = {} + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider(), + ), + mock.patch( + "prowler.providers.aws.services.elasticbeanstalk.elasticbeanstalk_environment_no_secrets_in_configuration.elasticbeanstalk_environment_no_secrets_in_configuration.elasticbeanstalk_client", + new=elasticbeanstalk_client, + ), + ): + from prowler.providers.aws.services.elasticbeanstalk.elasticbeanstalk_environment_no_secrets_in_configuration.elasticbeanstalk_environment_no_secrets_in_configuration import ( + elasticbeanstalk_environment_no_secrets_in_configuration, + ) + + check = elasticbeanstalk_environment_no_secrets_in_configuration() + result = check.execute() + + assert len(result) == 0 + + def test_environment_option_settings_is_none(self): + elasticbeanstalk_client = mock.MagicMock() + elasticbeanstalk_client.audit_config = {"secrets_ignore_patterns": []} + eb_env_arn = f"arn:partition:elasticbeanstalk:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:environment/dev" + + environment = Environment( + id="e-c4f7hda2nb", + name="dev", + arn=eb_env_arn, + region=AWS_REGION_US_EAST_1, + application_name="test-app", + option_settings=None, + ) + elasticbeanstalk_client.environments = {eb_env_arn: environment} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider(), + ), + mock.patch( + "prowler.providers.aws.services.elasticbeanstalk.elasticbeanstalk_environment_no_secrets_in_configuration.elasticbeanstalk_environment_no_secrets_in_configuration.elasticbeanstalk_client", + new=elasticbeanstalk_client, + ), + ): + from prowler.providers.aws.services.elasticbeanstalk.elasticbeanstalk_environment_no_secrets_in_configuration.elasticbeanstalk_environment_no_secrets_in_configuration import ( + elasticbeanstalk_environment_no_secrets_in_configuration, + ) + + check = elasticbeanstalk_environment_no_secrets_in_configuration() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == f"Could not retrieve the configuration of Elastic Beanstalk environment {environment.name}; manual review is required." + ) + + def test_environment_configuration_with_password_real_scanner(self): + """Run the real scanner: a plaintext password in an environment variable must be reported. + + The option name is part of the scanned payload; without it generic + credentials such as passwords are not detected. + """ + elasticbeanstalk_client = mock.MagicMock() + elasticbeanstalk_client.audit_config = {"secrets_ignore_patterns": []} + eb_env_arn = f"arn:partition:elasticbeanstalk:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:environment/production" + + environment = Environment( + id="e-vbxmknpy2z", + name="production", + arn=eb_env_arn, + region=AWS_REGION_US_EAST_1, + application_name="test-app", + option_settings=[ + { + "Namespace": "aws:elasticbeanstalk:application:environment", + "OptionName": "APP_ENV", + "Value": "production", + }, + { + "Namespace": "aws:elasticbeanstalk:application:environment", + "OptionName": "DB_PASSWORD", + "Value": "Tr0ub4dor3xKq9vLmZ", + }, + { + "Namespace": "aws:autoscaling:launchconfiguration", + "OptionName": "InstanceType", + "Value": "t3.micro", + }, + ], + ) + elasticbeanstalk_client.environments = {eb_env_arn: environment} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider(), + ), + mock.patch( + "prowler.providers.aws.services.elasticbeanstalk.elasticbeanstalk_environment_no_secrets_in_configuration.elasticbeanstalk_environment_no_secrets_in_configuration.elasticbeanstalk_client", + new=elasticbeanstalk_client, + ), + ): + from prowler.providers.aws.services.elasticbeanstalk.elasticbeanstalk_environment_no_secrets_in_configuration.elasticbeanstalk_environment_no_secrets_in_configuration import ( + elasticbeanstalk_environment_no_secrets_in_configuration, + ) + + check = elasticbeanstalk_environment_no_secrets_in_configuration() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Potential secret found in the configuration of Elastic Beanstalk environment production -> aws:elasticbeanstalk:application:environment/DB_PASSWORD." + ) + assert result[0].resource_id == "e-vbxmknpy2z" + assert result[0].resource_arn == eb_env_arn + assert result[0].region == AWS_REGION_US_EAST_1 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/memorydb/memorydb_cluster_in_transit_encryption_enabled/memorydb_cluster_in_transit_encryption_enabled_test.py b/tests/providers/aws/services/memorydb/memorydb_cluster_in_transit_encryption_enabled/memorydb_cluster_in_transit_encryption_enabled_test.py new file mode 100644 index 0000000000..f30c10b123 --- /dev/null +++ b/tests/providers/aws/services/memorydb/memorydb_cluster_in_transit_encryption_enabled/memorydb_cluster_in_transit_encryption_enabled_test.py @@ -0,0 +1,133 @@ +from unittest import mock + +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, +) + +CLUSTER_NAME = "db-cluster-1" +CLUSTER_ARN = f"arn:aws:memorydb:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:cluster:{CLUSTER_NAME}" + +CHECK_PATH = "prowler.providers.aws.services.memorydb.memorydb_cluster_in_transit_encryption_enabled.memorydb_cluster_in_transit_encryption_enabled.memorydb_client" +PROVIDER_PATH = "prowler.providers.common.provider.Provider.get_global_provider" + +make_api_call = botocore.client.BaseClient._make_api_call + + +def cluster_payload(tls_enabled: bool) -> dict: + return { + "Name": CLUSTER_NAME, + "Status": "available", + "NumberOfShards": 2, + "AvailabilityMode": "multiaz", + "Engine": "valkey", + "EngineVersion": "7.2", + "EnginePatchVersion": "7.2.4", + "SecurityGroups": [ + {"SecurityGroupId": "sg-0a1434xxxxxc9fae", "Status": "active"} + ], + "TLSEnabled": tls_enabled, + "ARN": CLUSTER_ARN, + "SnapshotRetentionLimit": 0, + "AutoMinorVersionUpgrade": True, + } + + +def mock_describe_clusters(clusters: list): + """Stub DescribeClusters, which moto covers only partially. + + Moto accepts ``Engine`` on CreateCluster but never returns it from + DescribeClusters, and the service layer requires that field, so every + cluster would be dropped before reaching the check. + """ + + def mock_make_api_call(self, operation_name, kwargs): + if operation_name == "DescribeClusters": + return {"Clusters": clusters} + return make_api_call(self, operation_name, kwargs) + + return mock_make_api_call + + +def build_memorydb_client(clusters: list): + from prowler.providers.aws.services.memorydb.memorydb_service import MemoryDB + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + with mock.patch( + "botocore.client.BaseClient._make_api_call", + new=mock_describe_clusters(clusters), + ): + return aws_provider, MemoryDB(aws_provider) + + +class Test_memorydb_cluster_in_transit_encryption_enabled: + @mock_aws + def test_no_clusters(self): + aws_provider, memorydb_client = build_memorydb_client([]) + + with mock.patch(PROVIDER_PATH, return_value=aws_provider): + with mock.patch(CHECK_PATH, new=memorydb_client): + from prowler.providers.aws.services.memorydb.memorydb_cluster_in_transit_encryption_enabled.memorydb_cluster_in_transit_encryption_enabled import ( + memorydb_cluster_in_transit_encryption_enabled, + ) + + check = memorydb_cluster_in_transit_encryption_enabled() + result = check.execute() + + assert len(result) == 0 + + @mock_aws + def test_cluster_in_transit_encryption_disabled(self): + aws_provider, memorydb_client = build_memorydb_client( + [cluster_payload(tls_enabled=False)] + ) + + with mock.patch(PROVIDER_PATH, return_value=aws_provider): + with mock.patch(CHECK_PATH, new=memorydb_client): + from prowler.providers.aws.services.memorydb.memorydb_cluster_in_transit_encryption_enabled.memorydb_cluster_in_transit_encryption_enabled import ( + memorydb_cluster_in_transit_encryption_enabled, + ) + + check = memorydb_cluster_in_transit_encryption_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Memory DB Cluster {CLUSTER_NAME} does not have in-transit encryption enabled." + ) + assert result[0].resource_id == CLUSTER_NAME + assert result[0].resource_arn == CLUSTER_ARN + assert result[0].region == AWS_REGION_US_EAST_1 + assert result[0].resource_tags == [] + + @mock_aws + def test_cluster_in_transit_encryption_enabled(self): + aws_provider, memorydb_client = build_memorydb_client( + [cluster_payload(tls_enabled=True)] + ) + + with mock.patch(PROVIDER_PATH, return_value=aws_provider): + with mock.patch(CHECK_PATH, new=memorydb_client): + from prowler.providers.aws.services.memorydb.memorydb_cluster_in_transit_encryption_enabled.memorydb_cluster_in_transit_encryption_enabled import ( + memorydb_cluster_in_transit_encryption_enabled, + ) + + check = memorydb_cluster_in_transit_encryption_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Memory DB Cluster {CLUSTER_NAME} has in-transit encryption enabled." + ) + assert result[0].resource_id == CLUSTER_NAME + assert result[0].resource_arn == CLUSTER_ARN + assert result[0].region == AWS_REGION_US_EAST_1 + assert result[0].resource_tags == [] 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 new file mode 100644 index 0000000000..230523239d --- /dev/null +++ b/tests/providers/aws/services/rolesanywhere/rolesanywhere_profile_restricts_session_permissions/rolesanywhere_profile_restricts_session_permissions_test.py @@ -0,0 +1,775 @@ +from types import SimpleNamespace +from unittest import mock + +from prowler.providers.aws.services.rolesanywhere.rolesanywhere_service import Profile +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +PROFILE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" +PROFILE_NAME = "workload-profile" +PROFILE_ARN = f"arn:aws:rolesanywhere:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:profile/{PROFILE_ID}" +ADMIN_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/admin-role" +READONLY_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/workload-role" +UNKNOWN_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/cross-account-role" +CUSTOM_ADMIN_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/custom-admin-role" +INLINE_ADMIN_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/inline-admin-role" +NAME_COLLISION_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/name-collision-role" +UNRESOLVED_POLICY_ROLE_ARN = ( + f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/unresolved-policy-role" +) +ADMIN_UNRESOLVED_POLICY_ROLE_ARN = ( + f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/admin-unresolved-policy-role" +) +INVALID_POLICY_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/invalid-policy-role" +DENY_OVERRIDE_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/deny-override-role" +CONDITIONAL_DENY_ROLE_ARN = ( + f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/conditional-deny-role" +) +CONDITIONAL_ADMIN_ROLE_ARN = ( + f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/conditional-admin-role" +) +BOUNDED_ADMIN_ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/bounded-admin-role" +UNRESOLVED_BOUNDARY_ROLE_ARN = ( + f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/unresolved-boundary-role" +) +ADMIN_BOUNDED_ROLE_ARN = ( + f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/admin-bounded-admin-role" +) +AWS_ADMIN_POLICY_ARN = "arn:aws:iam::aws:policy/AdministratorAccess" +CUSTOMER_ADMIN_NAMED_POLICY_ARN = ( + f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/AdministratorAccess" +) +CUSTOM_ADMIN_POLICY_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/custom-admin" +MANAGED_POLICY_ARN = "arn:aws:iam::aws:policy/ReadOnlyAccess" +CUSTOMER_FULL_ACCESS_POLICY_ARN = ( + f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/full-access-session" +) +BOUNDARY_POLICY_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/scoped-boundary" +UNRESOLVED_BOUNDARY_POLICY_ARN = ( + f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/unresolved-boundary" +) +UNRESOLVED_SESSION_POLICY_ARN = ( + "arn:aws:iam::aws:policy/job-function/SupportUser" # not in iam_client.policies +) + +SESSION_POLICY = ( + '{"Version":"2012-10-17","Statement":[{"Effect":"Allow",' + '"Action":["s3:GetObject"],"Resource":["*"]}]}' +) +FULL_ACCESS_SESSION_POLICY = ( + '{"Version":"2012-10-17","Statement":[{"Effect":"Allow",' + '"Action":"*","Resource":"*"}]}' +) + +FULL_ACCESS_DOCUMENT = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": "*", "Resource": "*"}], +} +READONLY_DOCUMENT = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": "s3:Get*", "Resource": "*"}], +} +DENY_ALL_DOCUMENT = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Deny", "Action": "*", "Resource": "*"}], +} +CONDITIONAL_ADMIN_DOCUMENT = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "*", + "Resource": "*", + "Condition": {"Bool": {"aws:MultiFactorAuthPresent": "true"}}, + } + ], +} +CONDITIONAL_DENY_DOCUMENT = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Deny", + "Action": "*", + "Resource": "*", + "Condition": {"StringNotEquals": {"aws:PrincipalTag/team": "security"}}, + } + ], +} + + +def _profile( + *, + enabled: bool = True, + session_policy: str = "", + managed_policy_arns=None, + role_arns=None, +): + return Profile( + arn=PROFILE_ARN, + id=PROFILE_ID, + name=PROFILE_NAME, + region=AWS_REGION_US_EAST_1, + enabled=enabled, + role_arns=role_arns if role_arns is not None else [READONLY_ROLE_ARN], + session_policy=session_policy, + managed_policy_arns=managed_policy_arns or [], + ) + + +def _role(arn, attached_policies=None, inline_policies=None, permissions_boundary=None): + return SimpleNamespace( + arn=arn, + attached_policies=attached_policies or [], + inline_policies=inline_policies or [], + permissions_boundary=permissions_boundary, + ) + + +def _iam_client(): + """IAM client stub mirroring iam_service models: roles with attached/inline + policies and a policies dict keyed by ARN (inline keyed {role_arn}:policy/{name}). + """ + roles = [ + _role( + ADMIN_ROLE_ARN, + attached_policies=[ + {"PolicyName": "AdministratorAccess", "PolicyArn": AWS_ADMIN_POLICY_ARN} + ], + ), + _role( + READONLY_ROLE_ARN, + attached_policies=[ + {"PolicyName": "ReadOnlyAccess", "PolicyArn": MANAGED_POLICY_ARN} + ], + ), + _role( + CUSTOM_ADMIN_ROLE_ARN, + attached_policies=[ + {"PolicyName": "custom-admin", "PolicyArn": CUSTOM_ADMIN_POLICY_ARN} + ], + ), + _role(INLINE_ADMIN_ROLE_ARN, inline_policies=["inline-admin"]), + _role( + NAME_COLLISION_ROLE_ARN, + attached_policies=[ + { + "PolicyName": "AdministratorAccess", + "PolicyArn": CUSTOMER_ADMIN_NAMED_POLICY_ARN, + } + ], + ), + _role( + UNRESOLVED_POLICY_ROLE_ARN, + attached_policies=[ + { + "PolicyName": "unresolved", + "PolicyArn": f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/unresolved", + } + ], + ), + # Proven admin attached policy combined with an unresolved one: the + # unresolved document could contain a deny, so the outcome is unknown. + _role( + ADMIN_UNRESOLVED_POLICY_ROLE_ARN, + attached_policies=[ + { + "PolicyName": "AdministratorAccess", + "PolicyArn": AWS_ADMIN_POLICY_ARN, + }, + { + "PolicyName": "unresolved", + "PolicyArn": f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/unresolved", + }, + ], + ), + # Attached policy resolves to a malformed (non-dict) document. + _role( + INVALID_POLICY_ROLE_ARN, + attached_policies=[ + { + "PolicyName": "invalid", + "PolicyArn": f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/invalid", + } + ], + ), + # Allow *:* in the attached policy negated by an unconditional Deny *:* + # in an inline policy: not effectively administrative. + _role( + DENY_OVERRIDE_ROLE_ARN, + attached_policies=[ + {"PolicyName": "custom-admin", "PolicyArn": CUSTOM_ADMIN_POLICY_ARN} + ], + inline_policies=["deny-all"], + ), + # Allow *:* in the attached policy plus a Condition-guarded Deny: the + # deny may or may not apply, so the outcome is unknown. + _role( + CONDITIONAL_DENY_ROLE_ARN, + attached_policies=[ + {"PolicyName": "custom-admin", "PolicyArn": CUSTOM_ADMIN_POLICY_ARN} + ], + inline_policies=["conditional-deny"], + ), + # Allow *:* guarded by a Condition: not statically provable as admin. + _role( + CONDITIONAL_ADMIN_ROLE_ARN, + attached_policies=[ + { + "PolicyName": "conditional-admin", + "PolicyArn": f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/conditional-admin", + } + ], + ), + # Administrative identity policies constrained by a restrictive + # permissions boundary: not effectively administrative. + _role( + BOUNDED_ADMIN_ROLE_ARN, + attached_policies=[ + {"PolicyName": "AdministratorAccess", "PolicyArn": AWS_ADMIN_POLICY_ARN} + ], + permissions_boundary={ + "PermissionsBoundaryType": "Policy", + "PermissionsBoundaryArn": BOUNDARY_POLICY_ARN, + }, + ), + # Boundary present but its document is not in the IAM inventory: + # restrictions cannot be evaluated, so the role is not classified admin. + _role( + UNRESOLVED_BOUNDARY_ROLE_ARN, + attached_policies=[ + {"PolicyName": "AdministratorAccess", "PolicyArn": AWS_ADMIN_POLICY_ARN} + ], + permissions_boundary={ + "PermissionsBoundaryType": "Policy", + "PermissionsBoundaryArn": UNRESOLVED_BOUNDARY_POLICY_ARN, + }, + ), + # AdministratorAccess as the boundary does not restrict anything. + _role( + ADMIN_BOUNDED_ROLE_ARN, + attached_policies=[ + {"PolicyName": "AdministratorAccess", "PolicyArn": AWS_ADMIN_POLICY_ARN} + ], + permissions_boundary={ + "PermissionsBoundaryType": "Policy", + "PermissionsBoundaryArn": AWS_ADMIN_POLICY_ARN, + }, + ), + ] + policies = { + AWS_ADMIN_POLICY_ARN: SimpleNamespace(document=FULL_ACCESS_DOCUMENT), + CUSTOM_ADMIN_POLICY_ARN: SimpleNamespace(document=FULL_ACCESS_DOCUMENT), + MANAGED_POLICY_ARN: SimpleNamespace(document=READONLY_DOCUMENT), + # Customer-managed policy that merely shares the AdministratorAccess name. + CUSTOMER_ADMIN_NAMED_POLICY_ARN: SimpleNamespace(document=READONLY_DOCUMENT), + f"{INLINE_ADMIN_ROLE_ARN}:policy/inline-admin": SimpleNamespace( + document=FULL_ACCESS_DOCUMENT + ), + f"{DENY_OVERRIDE_ROLE_ARN}:policy/deny-all": SimpleNamespace( + document=DENY_ALL_DOCUMENT + ), + f"{CONDITIONAL_DENY_ROLE_ARN}:policy/conditional-deny": SimpleNamespace( + document=CONDITIONAL_DENY_DOCUMENT + ), + f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/invalid": SimpleNamespace( + document="invalid" + ), + f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/conditional-admin": SimpleNamespace( + document=CONDITIONAL_ADMIN_DOCUMENT + ), + BOUNDARY_POLICY_ARN: SimpleNamespace(document=READONLY_DOCUMENT), + # Customer-managed session policy whose document grants *:*. + CUSTOMER_FULL_ACCESS_POLICY_ARN: SimpleNamespace(document=FULL_ACCESS_DOCUMENT), + } + iam = mock.MagicMock() + iam.roles = roles + iam.policies = policies + return iam + + +def _build_client(profiles): + ra_client = mock.MagicMock() + ra_client.profiles = profiles + return ra_client + + +def _patched(ra_client): + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + check_module = "prowler.providers.aws.services.rolesanywhere.rolesanywhere_profile_restricts_session_permissions.rolesanywhere_profile_restricts_session_permissions" + return [ + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch(f"{check_module}.rolesanywhere_client", new=ra_client), + mock.patch(f"{check_module}.iam_client", new=_iam_client()), + ] + + +def _enter(patches): + from contextlib import ExitStack + + stack = ExitStack() + for p in patches: + stack.enter_context(p) + return stack + + +def _run(): + from prowler.providers.aws.services.rolesanywhere.rolesanywhere_profile_restricts_session_permissions.rolesanywhere_profile_restricts_session_permissions import ( + rolesanywhere_profile_restricts_session_permissions, + ) + + return rolesanywhere_profile_restricts_session_permissions().execute() + + +class Test_rolesanywhere_profile_restricts_session_permissions: + def test_no_profiles(self): + with _enter(_patched(_build_client({}))): + assert len(_run()) == 0 + + def test_unscoped_profile_with_admin_role_fails(self): + with _enter( + _patched(_build_client({PROFILE_ARN: _profile(role_arns=[ADMIN_ROLE_ARN])})) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == PROFILE_ID + assert result[0].resource_arn == PROFILE_ARN + assert result[0].region == AWS_REGION_US_EAST_1 + assert ADMIN_ROLE_ARN in result[0].status_extended + assert "administrative" in result[0].status_extended + + def test_unscoped_profile_with_custom_admin_policy_fails(self): + with _enter( + _patched( + _build_client( + {PROFILE_ARN: _profile(role_arns=[CUSTOM_ADMIN_ROLE_ARN])} + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert CUSTOM_ADMIN_ROLE_ARN in result[0].status_extended + + def test_unscoped_profile_with_inline_admin_policy_fails(self): + with _enter( + _patched( + _build_client( + {PROFILE_ARN: _profile(role_arns=[INLINE_ADMIN_ROLE_ARN])} + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert INLINE_ADMIN_ROLE_ARN in result[0].status_extended + + def test_mixed_roles_fail_lists_only_admin_role(self): + with _enter( + _patched( + _build_client( + { + PROFILE_ARN: _profile( + role_arns=[READONLY_ROLE_ARN, ADMIN_ROLE_ARN] + ) + } + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ADMIN_ROLE_ARN in result[0].status_extended + assert READONLY_ROLE_ARN not in result[0].status_extended + + def test_customer_policy_named_administratoraccess_passes(self): + # Name collision: customer-managed policy called AdministratorAccess + # whose document is read-only must not flag the role as administrative. + with _enter( + _patched( + _build_client( + {PROFILE_ARN: _profile(role_arns=[NAME_COLLISION_ROLE_ARN])} + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_unresolved_attached_policy_is_manual(self): + # Attached policy ARN missing from iam_client.policies: a missing + # document is unknown, not proof that the role is unprivileged. + with _enter( + _patched( + _build_client( + {PROFILE_ARN: _profile(role_arns=[UNRESOLVED_POLICY_ROLE_ARN])} + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "could not be evaluated" in result[0].status_extended + + def test_invalid_policy_document_is_manual(self): + # A malformed policy document cannot prove anything about the role. + with _enter( + _patched( + _build_client( + {PROFILE_ARN: _profile(role_arns=[INVALID_POLICY_ROLE_ARN])} + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "MANUAL" + + def test_allow_all_with_unresolved_policy_is_manual(self): + # Proven admin policy plus an unresolved one: the unresolved document + # could contain a deny, so the classification is unknown. + with _enter( + _patched( + _build_client( + { + PROFILE_ARN: _profile( + role_arns=[ADMIN_UNRESOLVED_POLICY_ROLE_ARN] + ) + } + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "MANUAL" + + def test_unscoped_profile_with_least_privilege_role_passes(self): + with _enter( + _patched( + _build_client({PROFILE_ARN: _profile(role_arns=[READONLY_ROLE_ARN])}) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "defense-in-depth" in result[0].status_extended + + def test_unscoped_profile_with_unknown_role_is_manual(self): + # A referenced role missing from the IAM inventory is unknown, not + # proof that no administrative role exists. + with _enter( + _patched( + _build_client({PROFILE_ARN: _profile(role_arns=[UNKNOWN_ROLE_ARN])}) + ) + ): + result = _run() + assert len(result) == 1 + 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() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_profile_with_session_policy_passes(self): + with _enter( + _patched( + _build_client( + { + PROFILE_ARN: _profile( + session_policy=SESSION_POLICY, role_arns=[ADMIN_ROLE_ARN] + ) + } + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "session policy" in result[0].status_extended + + def test_full_access_session_policy_does_not_scope(self): + # A sessionPolicy granting *:* does not restrict anything. + with _enter( + _patched( + _build_client( + { + PROFILE_ARN: _profile( + session_policy=FULL_ACCESS_SESSION_POLICY, + role_arns=[ADMIN_ROLE_ARN], + ) + } + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_profile_with_managed_policies_passes(self): + with _enter( + _patched( + _build_client( + { + PROFILE_ARN: _profile( + managed_policy_arns=[MANAGED_POLICY_ARN], + role_arns=[ADMIN_ROLE_ARN], + ) + } + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_admin_managed_session_policy_does_not_scope(self): + # AdministratorAccess as the managed session policy restricts nothing. + with _enter( + _patched( + _build_client( + { + PROFILE_ARN: _profile( + managed_policy_arns=[AWS_ADMIN_POLICY_ARN], + role_arns=[ADMIN_ROLE_ARN], + ) + } + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_restrictive_inline_with_admin_managed_policy_fails(self): + # The session-policy set is evaluated as a union: AdministratorAccess as + # a managed session policy makes the boundary unrestricted even though + # the inline session policy is restrictive. + with _enter( + _patched( + _build_client( + { + PROFILE_ARN: _profile( + session_policy=SESSION_POLICY, + managed_policy_arns=[AWS_ADMIN_POLICY_ARN], + role_arns=[ADMIN_ROLE_ARN], + ) + } + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_full_access_inline_with_restrictive_managed_policy_fails(self): + # Conversely, a *:* inline session policy leaves the union unrestricted + # regardless of a restrictive managed session policy. + with _enter( + _patched( + _build_client( + { + PROFILE_ARN: _profile( + session_policy=FULL_ACCESS_SESSION_POLICY, + managed_policy_arns=[MANAGED_POLICY_ARN], + role_arns=[ADMIN_ROLE_ARN], + ) + } + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_restrictive_inline_and_restrictive_managed_policy_passes(self): + with _enter( + _patched( + _build_client( + { + PROFILE_ARN: _profile( + session_policy=SESSION_POLICY, + managed_policy_arns=[MANAGED_POLICY_ARN], + role_arns=[ADMIN_ROLE_ARN], + ) + } + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_customer_managed_full_access_session_policy_fails(self): + # A customer-managed session policy whose document grants *:* must be + # resolved through iam_client.policies and treated as unscoped. + with _enter( + _patched( + _build_client( + { + PROFILE_ARN: _profile( + managed_policy_arns=[CUSTOMER_FULL_ACCESS_POLICY_ARN], + role_arns=[ADMIN_ROLE_ARN], + ) + } + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_unresolved_managed_session_policy_is_manual(self): + # A managed session policy whose document was not collected does not + # prove that the session is restricted: the outcome is unknown. + with _enter( + _patched( + _build_client( + { + PROFILE_ARN: _profile( + managed_policy_arns=[UNRESOLVED_SESSION_POLICY_ARN], + role_arns=[ADMIN_ROLE_ARN], + ) + } + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "session scoping could not be evaluated" in result[0].status_extended + + def test_invalid_inline_session_policy_is_manual(self): + # An inline session policy that fails to parse does not prove that the + # session is restricted: the outcome is unknown. + with _enter( + _patched( + _build_client( + { + PROFILE_ARN: _profile( + session_policy="not-json", role_arns=[ADMIN_ROLE_ARN] + ) + } + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "MANUAL" + + def test_allow_all_with_cross_policy_deny_all_passes(self): + # Allow *:* in an attached policy plus an unconditional Deny *:* in an + # inline policy: the merged evaluation must not classify the role admin. + with _enter( + _patched( + _build_client( + {PROFILE_ARN: _profile(role_arns=[DENY_OVERRIDE_ROLE_ARN])} + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_conditional_admin_allow_is_manual(self): + # An Allow *:* guarded by a Condition is not statically provable in + # either direction: the classification is unknown. + with _enter( + _patched( + _build_client( + {PROFILE_ARN: _profile(role_arns=[CONDITIONAL_ADMIN_ROLE_ARN])} + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "MANUAL" + + def test_allow_all_with_conditional_deny_is_manual(self): + # Unconditional Allow *:* plus a Condition-guarded Deny: the deny may + # or may not negate the grant, so the classification is unknown. + with _enter( + _patched( + _build_client( + {PROFILE_ARN: _profile(role_arns=[CONDITIONAL_DENY_ROLE_ARN])} + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "MANUAL" + + def test_admin_role_with_restrictive_boundary_passes(self): + # Admin identity policies intersected with a read-only permissions + # boundary are not effectively administrative. + with _enter( + _patched( + _build_client( + {PROFILE_ARN: _profile(role_arns=[BOUNDED_ADMIN_ROLE_ARN])} + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_admin_role_with_unresolved_boundary_is_manual(self): + # When the boundary document cannot be resolved the restrictions are + # unknown: neither administrative nor safe can be proven. + with _enter( + _patched( + _build_client( + {PROFILE_ARN: _profile(role_arns=[UNRESOLVED_BOUNDARY_ROLE_ARN])} + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "MANUAL" + + def test_admin_role_with_admin_boundary_fails(self): + # An AdministratorAccess boundary restricts nothing: still admin. + with _enter( + _patched( + _build_client( + {PROFILE_ARN: _profile(role_arns=[ADMIN_BOUNDED_ROLE_ARN])} + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ADMIN_BOUNDED_ROLE_ARN in result[0].status_extended + + def test_disabled_profile_passes(self): + with _enter( + _patched( + _build_client( + {PROFILE_ARN: _profile(enabled=False, role_arns=[ADMIN_ROLE_ARN])} + ) + ) + ): + result = _run() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "disabled" in result[0].status_extended diff --git a/tests/providers/aws/services/rolesanywhere/rolesanywhere_service_test.py b/tests/providers/aws/services/rolesanywhere/rolesanywhere_service_test.py index d21d4e20b7..5a1106c852 100644 --- a/tests/providers/aws/services/rolesanywhere/rolesanywhere_service_test.py +++ b/tests/providers/aws/services/rolesanywhere/rolesanywhere_service_test.py @@ -4,6 +4,7 @@ import botocore from moto import mock_aws from prowler.providers.aws.services.rolesanywhere.rolesanywhere_service import ( + Profile, RolesAnywhere, TrustAnchor, ) @@ -16,6 +17,9 @@ from tests.providers.aws.utils import ( TA_ID = "11111111-2222-3333-4444-555555555555" TA_ARN = f"arn:aws:rolesanywhere:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:trust-anchor/{TA_ID}" PCA_ARN = f"arn:aws:acm-pca:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:certificate-authority/abc" +PROFILE_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" +PROFILE_ARN = f"arn:aws:rolesanywhere:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:profile/{PROFILE_ID}" +ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/workload-role" make_api_call = botocore.client.BaseClient._make_api_call @@ -36,6 +40,21 @@ def mock_make_api_call(self, operation_name, kwarg): } ] } + if operation_name == "ListProfiles": + return { + "profiles": [ + { + "profileArn": PROFILE_ARN, + "profileId": PROFILE_ID, + "name": "workload-profile", + "enabled": True, + "roleArns": [ROLE_ARN], + "sessionPolicy": '{"Version":"2012-10-17","Statement":[]}', + "durationSeconds": 3600, + "acceptRoleSessionName": True, + } + ] + } if operation_name == "ListTagsForResource": return {"tags": [{"key": "Environment", "value": "test"}]} return make_api_call(self, operation_name, kwarg) @@ -78,6 +97,25 @@ class Test_RolesAnywhere_Service: assert ta.region == AWS_REGION_US_EAST_1 assert ta.tags == [{"key": "Environment", "value": "test"}] + @patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call) + @mock_aws + def test_list_profiles(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + rolesanywhere = RolesAnywhere(aws_provider) + assert len(rolesanywhere.profiles) == 1 + profile = rolesanywhere.profiles[PROFILE_ARN] + assert isinstance(profile, Profile) + assert profile.id == PROFILE_ID + assert profile.name == "workload-profile" + assert profile.enabled is True + assert profile.role_arns == [ROLE_ARN] + assert profile.session_policy == '{"Version":"2012-10-17","Statement":[]}' + assert profile.managed_policy_arns == [] + assert profile.duration_seconds == 3600 + assert profile.accept_role_session_name is True + assert profile.region == AWS_REGION_US_EAST_1 + assert profile.tags == [{"key": "Environment", "value": "test"}] + @patch( "botocore.client.BaseClient._make_api_call", new=mock_make_api_call_tags_failure ) 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/ses/ses_identity_not_publicly_accessible/ses_identity_not_publicly_accessible_test.py b/tests/providers/aws/services/ses/ses_identity_not_publicly_accessible/ses_identity_not_publicly_accessible_test.py index 15b8a9dd32..dbd12d189d 100644 --- a/tests/providers/aws/services/ses/ses_identity_not_publicly_accessible/ses_identity_not_publicly_accessible_test.py +++ b/tests/providers/aws/services/ses/ses_identity_not_publicly_accessible/ses_identity_not_publicly_accessible_test.py @@ -1,6 +1,8 @@ +from copy import deepcopy from unittest import mock import botocore +import pytest from boto3 import client from moto import mock_aws @@ -54,6 +56,113 @@ def mock_make_api_call_v2(self, operation_name, kwarg): return make_api_call(self, operation_name, kwarg) +PUBLIC_ALLOW_POLICY = '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"ses:SendEmail","Resource":"*"}]}' +PRIVATE_ALLOW_POLICY = '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::123456789012:root"},"Action":"ses:SendEmail","Resource":"*"}]}' +MATCHING_DENY_POLICY = '{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":"*","Action":"ses:SendEmail","Resource":"*"}]}' +UNRELATED_DENY_POLICY = '{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":"*","Action":"ses:SendRawEmail","Resource":"*"}]}' +PUBLIC_ALLOW_AND_DENY_POLICY = '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"ses:SendEmail","Resource":"*"},{"Effect":"Deny","Principal":"*","Action":"ses:SendEmail","Resource":"*"}]}' +PUBLIC_ALLOW_SINGLE_STATEMENT_POLICY = '{"Version":"2012-10-17","Statement":{"Effect":"Allow","Principal":"*","Action":"ses:SendEmail","Resource":"*"}}' +PRIVATE_ALLOW_SINGLE_STATEMENT_POLICY = '{"Version":"2012-10-17","Statement":{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::123456789012:root"},"Action":"ses:SendEmail","Resource":"*"}}' +MATCHING_DENY_SINGLE_STATEMENT_POLICY = '{"Version":"2012-10-17","Statement":{"Effect":"Deny","Principal":"*","Action":"ses:SendEmail","Resource":"*"}}' +CONDITIONAL_ALLOW_SINGLE_STATEMENT_POLICY = '{"Version":"2012-10-17","Statement":{"Effect":"Allow","Principal":"*","Action":"ses:SendEmail","Resource":"*","Condition":{"StringEquals":{"AWS:SourceAccount":"123456789012"}}}}' + + +def make_multiple_policies_api_mock(policies): + def mock_api_call(self, operation_name, kwarg): + if operation_name == "ListEmailIdentities": + return { + "EmailIdentities": [ + { + "IdentityType": "DOMAIN", + "IdentityName": "test-email-identity-multiple-policies", + } + ], + } + elif operation_name == "GetEmailIdentity": + return {"Policies": policies, "Tags": {}} + return make_api_call(self, operation_name, kwarg) + + return mock_api_call + + +mock_make_api_call_multiple_policies = make_multiple_policies_api_mock( + { + "public-policy": PUBLIC_ALLOW_POLICY, + "private-policy": PRIVATE_ALLOW_POLICY, + } +) +mock_make_api_call_multiple_policies_reversed = make_multiple_policies_api_mock( + { + "private-policy": PRIVATE_ALLOW_POLICY, + "public-policy": PUBLIC_ALLOW_POLICY, + } +) +mock_make_api_call_public_allow_and_matching_deny = make_multiple_policies_api_mock( + { + "public-policy": PUBLIC_ALLOW_POLICY, + "deny-policy": MATCHING_DENY_POLICY, + } +) +mock_make_api_call_matching_deny_and_public_allow = make_multiple_policies_api_mock( + { + "deny-policy": MATCHING_DENY_POLICY, + "public-policy": PUBLIC_ALLOW_POLICY, + } +) +mock_make_api_call_public_allow_and_unrelated_deny = make_multiple_policies_api_mock( + { + "public-policy": PUBLIC_ALLOW_POLICY, + "deny-policy": UNRELATED_DENY_POLICY, + } +) +mock_make_api_call_same_policy_allow_and_deny = make_multiple_policies_api_mock( + {"combined-policy": PUBLIC_ALLOW_AND_DENY_POLICY} +) +mock_make_api_call_multiple_private_policies = make_multiple_policies_api_mock( + { + "private-policy-1": PRIVATE_ALLOW_POLICY, + "private-policy-2": PRIVATE_ALLOW_POLICY, + } +) +mock_make_api_call_public_single_statement = make_multiple_policies_api_mock( + {"public-policy": PUBLIC_ALLOW_SINGLE_STATEMENT_POLICY} +) +mock_make_api_call_private_single_statement = make_multiple_policies_api_mock( + {"private-policy": PRIVATE_ALLOW_SINGLE_STATEMENT_POLICY} +) +mock_make_api_call_public_and_deny_single_statements = make_multiple_policies_api_mock( + { + "public-policy": PUBLIC_ALLOW_SINGLE_STATEMENT_POLICY, + "deny-policy": MATCHING_DENY_SINGLE_STATEMENT_POLICY, + } +) +mock_make_api_call_conditional_single_statement = make_multiple_policies_api_mock( + {"conditional-policy": CONDITIONAL_ALLOW_SINGLE_STATEMENT_POLICY} +) + + +def execute_check_with_api_mock(api_call_mock): + with mock.patch("botocore.client.BaseClient._make_api_call", new=api_call_mock): + client("sesv2", region_name=AWS_REGION_EU_WEST_1) + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.ses.ses_identity_not_publicly_accessible.ses_identity_not_publicly_accessible.ses_client", + new=SES(aws_provider), + ), + ): + from prowler.providers.aws.services.ses.ses_identity_not_publicly_accessible.ses_identity_not_publicly_accessible import ( + ses_identity_not_publicly_accessible, + ) + + return ses_identity_not_publicly_accessible().execute() + + class Test_ses_identities_not_publicly_accessible: @mock_aws def test_no_identities(self): @@ -114,6 +223,114 @@ class Test_ses_identities_not_publicly_accessible: assert result[0].resource_tags == {"tag1": "value1", "tag2": "value2"} assert result[0].region == AWS_REGION_EU_WEST_1 + @mock_aws + @pytest.mark.parametrize( + "api_call_mock", + [ + mock_make_api_call_multiple_policies, + mock_make_api_call_multiple_policies_reversed, + ], + ids=["public-policy-first", "public-policy-last"], + ) + def test_email_identity_public_when_any_policy_is_public(self, api_call_mock): + result = execute_check_with_api_mock(api_call_mock) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "SES identity test-email-identity-multiple-policies is publicly accessible due to its resource policies." + ) + + @mock_aws + @pytest.mark.parametrize( + "api_call_mock", + [ + mock_make_api_call_public_allow_and_matching_deny, + mock_make_api_call_matching_deny_and_public_allow, + mock_make_api_call_public_allow_and_unrelated_deny, + mock_make_api_call_same_policy_allow_and_deny, + ], + ids=[ + "matching-deny-last", + "matching-deny-first", + "unrelated-deny", + "same-policy-deny", + ], + ) + def test_email_identity_public_allow_with_explicit_deny_is_manual( + self, api_call_mock + ): + result = execute_check_with_api_mock(api_call_mock) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == "SES identity test-email-identity-multiple-policies has public Allow and explicit Deny statements in its resource policies. Effective public access requires manual review." + ) + + @mock_aws + def test_email_identity_multiple_private_policies(self): + result = execute_check_with_api_mock( + mock_make_api_call_multiple_private_policies + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "SES identity test-email-identity-multiple-policies is not publicly accessible." + ) + + @mock_aws + @pytest.mark.parametrize( + ("api_call_mock", "expected_status"), + [ + (mock_make_api_call_public_single_statement, "FAIL"), + (mock_make_api_call_private_single_statement, "PASS"), + (mock_make_api_call_public_and_deny_single_statements, "MANUAL"), + ], + ids=["public", "private", "public-with-deny"], + ) + def test_email_identity_single_statement_policy( + self, api_call_mock, expected_status + ): + result = execute_check_with_api_mock(api_call_mock) + + assert len(result) == 1 + assert result[0].status == expected_status + + @mock_aws + def test_check_preserves_nested_policy_condition_keys(self): + with mock.patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_conditional_single_statement, + ): + client("sesv2", region_name=AWS_REGION_EU_WEST_1) + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + ses_client = SES(aws_provider) + identity = next(iter(ses_client.email_identities.values())) + policies_before_check = deepcopy(identity.policies) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.ses.ses_identity_not_publicly_accessible.ses_identity_not_publicly_accessible.ses_client", + new=ses_client, + ), + ): + from prowler.providers.aws.services.ses.ses_identity_not_publicly_accessible.ses_identity_not_publicly_accessible import ( + ses_identity_not_publicly_accessible, + ) + + ses_identity_not_publicly_accessible().execute() + + assert identity.policies == policies_before_check + @mock_aws @mock.patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call_v2) def test_email_identity_public(self): @@ -140,7 +357,7 @@ class Test_ses_identities_not_publicly_accessible: assert result[0].status == "FAIL" assert ( result[0].status_extended - == "SES identity test-email-identity-public is publicly accessible due to its resource policy." + == "SES identity test-email-identity-public is publicly accessible due to its resource policies." ) assert result[0].resource_id == "test-email-identity-public" assert ( diff --git a/tests/providers/aws/services/ses/ses_service_test.py b/tests/providers/aws/services/ses/ses_service_test.py index 223ed94365..92146f6a01 100644 --- a/tests/providers/aws/services/ses/ses_service_test.py +++ b/tests/providers/aws/services/ses/ses_service_test.py @@ -27,6 +27,7 @@ def mock_make_api_call(self, operation_name, kwarg): return { "Policies": { "policy1": '{"policy1": "value1"}', + "policy2": '{"policy2": "value2"}', }, "Tags": {"tag1": "value1", "tag2": "value2"}, "DkimAttributes": { @@ -81,7 +82,11 @@ class Test_SES_Service: assert ses.email_identities[arn].type == "EMAIL_ADDRESS" assert ses.email_identities[arn].arn == arn assert ses.email_identities[arn].region == AWS_REGION_EU_WEST_1 - assert ses.email_identities[arn].policy == {"policy1": "value1"} + assert ses.email_identities[arn].policy == {"policy2": "value2"} + assert ses.email_identities[arn].policies == { + "policy1": {"policy1": "value1"}, + "policy2": {"policy2": "value2"}, + } assert ses.email_identities[arn].tags == {"tag1": "value1", "tag2": "value2"} assert ses.email_identities[arn].dkim_status == "SUCCESS" assert ses.email_identities[arn].dkim_signing_attributes_origin == "AWS_SES" 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/external/test_dynamic_provider_loading.py b/tests/providers/external/test_dynamic_provider_loading.py index d4e7b4665d..115b6bf180 100644 --- a/tests/providers/external/test_dynamic_provider_loading.py +++ b/tests/providers/external/test_dynamic_provider_loading.py @@ -1624,8 +1624,11 @@ class TestCompliance: assert "custom_1.0_ext" in frameworks - @patch("prowler.config.config.importlib.metadata.entry_points") - def test_get_available_compliance_includes_external_universal(self, mock_ep): + @patch("prowler.config.config._get_ep_compliance_dirs", return_value={}) + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + def test_get_available_compliance_includes_external_universal( + self, mock_ep, _mock_ep_dirs + ): """External universal frameworks under prowler.compliance.universal are listed, for a provider and for the provider=None case that feeds --compliance choices.""" diff --git a/tests/providers/gcp/gcp_fixtures.py b/tests/providers/gcp/gcp_fixtures.py index 1abcaf5bf0..5964d51142 100644 --- a/tests/providers/gcp/gcp_fixtures.py +++ b/tests/providers/gcp/gcp_fixtures.py @@ -490,6 +490,19 @@ def mock_api_projects_calls(client: MagicMock): } client.projects().serviceAccounts().list_next.return_value = None + # Workload Identity Federation pools/providers: return empty pages and stop + # pagination so the discovery while-loops in the IAM service terminate. + client.projects().locations().workloadIdentityPools().list().execute.return_value = { + "workloadIdentityPools": [] + } + client.projects().locations().workloadIdentityPools().list_next.return_value = None + client.projects().locations().workloadIdentityPools().providers().list().execute.return_value = { + "workloadIdentityPoolProviders": [] + } + client.projects().locations().workloadIdentityPools().providers().list_next.return_value = ( + None + ) + def mock_list_service_accounts_keys(name): return_value = MagicMock() if ( 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/iam_workload_identity_pool_provider_attribute_condition/iam_workload_identity_pool_provider_attribute_condition_test.py b/tests/providers/gcp/services/iam/iam_workload_identity_pool_provider_attribute_condition/iam_workload_identity_pool_provider_attribute_condition_test.py new file mode 100644 index 0000000000..6fcc609489 --- /dev/null +++ b/tests/providers/gcp/services/iam/iam_workload_identity_pool_provider_attribute_condition/iam_workload_identity_pool_provider_attribute_condition_test.py @@ -0,0 +1,148 @@ +from unittest import mock + +from tests.providers.gcp.gcp_fixtures import ( + GCP_PROJECT_ID, + GCP_US_CENTER1_LOCATION, + set_mocked_gcp_provider, +) + +CHECK_MODULE = "prowler.providers.gcp.services.iam.iam_workload_identity_pool_provider_attribute_condition.iam_workload_identity_pool_provider_attribute_condition" + + +def _run(provider_kwargs): + iam_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch(f"{CHECK_MODULE}.iam_client", new=iam_client), + ): + from prowler.providers.gcp.services.iam.iam_service import ( + WorkloadIdentityPoolProvider, + ) + from prowler.providers.gcp.services.iam.iam_workload_identity_pool_provider_attribute_condition.iam_workload_identity_pool_provider_attribute_condition import ( + iam_workload_identity_pool_provider_attribute_condition, + ) + + providers = [] + for kwargs in provider_kwargs: + provider_id = kwargs.get("provider_id", "my-provider") + providers.append( + WorkloadIdentityPoolProvider( + name=( + f"projects/{GCP_PROJECT_ID}/locations/global/" + f"workloadIdentityPools/my-pool/providers/{provider_id}" + ), + id=provider_id, + pool_id="my-pool", + pool_disabled=kwargs.get("pool_disabled", False), + project_id=GCP_PROJECT_ID, + state=kwargs.get("state", "ACTIVE"), + disabled=kwargs.get("disabled", False), + attribute_condition=kwargs.get("attribute_condition", ""), + provider_type=kwargs.get("provider_type", "oidc"), + issuer_uri=kwargs.get( + "issuer_uri", + "https://token.actions.githubusercontent.com", + ), + display_name="My Provider", + ) + ) + + iam_client.project_ids = [GCP_PROJECT_ID] + iam_client.region = GCP_US_CENTER1_LOCATION + iam_client.workload_identity_pool_providers = providers + return iam_workload_identity_pool_provider_attribute_condition().execute() + + +class Test_iam_workload_identity_pool_provider_attribute_condition: + def test_no_providers(self): + assert len(_run([])) == 0 + + def test_multi_tenant_issuer_without_attribute_condition_fails(self): + result = _run( + [ + { + "attribute_condition": "", + "issuer_uri": "https://token.actions.githubusercontent.com", + } + ] + ) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id.endswith("my-provider") + assert result[0].location == "global" + assert "multi-tenant issuer" in result[0].status_extended + + def test_dedicated_issuer_without_attribute_condition_passes(self): + result = _run( + [ + { + "attribute_condition": "", + "issuer_uri": "https://oidc.eks.eu-west-1.amazonaws.com/id/ABC123", + } + ] + ) + assert len(result) == 1 + assert result[0].status == "PASS" + assert "dedicated issuer" in result[0].status_extended + + def test_non_oidc_provider_without_attribute_condition_passes(self): + result = _run( + [{"attribute_condition": "", "provider_type": "aws", "issuer_uri": ""}] + ) + assert len(result) == 1 + assert result[0].status == "PASS" + assert "not an OIDC provider" in result[0].status_extended + + def test_multi_tenant_issuer_with_port_and_uppercase_fails(self): + result = _run( + [{"attribute_condition": "", "issuer_uri": "https://GitLab.com:443"}] + ) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_multi_tenant_issuer_bare_host_fails(self): + result = _run( + [ + { + "attribute_condition": "", + "issuer_uri": "token.actions.githubusercontent.com", + } + ] + ) + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_multi_tenant_issuer_with_attribute_condition_passes(self): + result = _run( + [ + { + "attribute_condition": "assertion.repository_owner == 'acme'", + "issuer_uri": "https://token.actions.githubusercontent.com", + } + ] + ) + assert len(result) == 1 + assert result[0].status == "PASS" + assert "enforces an attribute condition" in result[0].status_extended + + def test_disabled_provider_passes(self): + result = _run([{"disabled": True}]) + assert len(result) == 1 + assert result[0].status == "PASS" + assert "not active" in result[0].status_extended + + def test_non_active_provider_passes(self): + result = _run([{"state": "DELETED"}]) + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_active_provider_in_disabled_pool_passes(self): + # The provider itself is ACTIVE and unconditioned on a multi-tenant + # issuer, but its parent pool is disabled and cannot vend credentials. + result = _run([{"pool_disabled": True}]) + assert len(result) == 1 + assert result[0].status == "PASS" + assert "disabled pool" in result[0].status_extended diff --git a/tests/providers/gcp/services/iam/iam_workloadidentity_service_test.py b/tests/providers/gcp/services/iam/iam_workloadidentity_service_test.py new file mode 100644 index 0000000000..51385b0204 --- /dev/null +++ b/tests/providers/gcp/services/iam/iam_workloadidentity_service_test.py @@ -0,0 +1,215 @@ +from unittest.mock import MagicMock, patch + +from tests.providers.gcp.gcp_fixtures import ( + GCP_PROJECT_ID, + mock_is_api_active, + set_mocked_gcp_provider, +) + +PROJECT_A = GCP_PROJECT_ID +PROJECT_B = "test-project-b" + + +def _pool_name(project_id, pool_id="my-pool"): + return f"projects/{project_id}/locations/global/workloadIdentityPools/{pool_id}" + + +def _provider_payload(pool_name, provider_id="my-provider"): + return { + "name": f"{pool_name}/providers/{provider_id}", + "state": "ACTIVE", + "disabled": False, + "attributeMapping": {"google.subject": "assertion.sub"}, + "oidc": {"issuerUri": "https://token.actions.githubusercontent.com"}, + "displayName": "gh", + } + + +def _empty_service_accounts(client): + """Stub the service-account calls used by the rest of the IAM __init__.""" + sa = client.projects.return_value.serviceAccounts.return_value + sa.list.return_value.execute.return_value = {"accounts": []} + sa.list_next.return_value = None + + +def _wif_client(_GCPService, _service, _api_version, _credentials): + """Discovery client stub returning one pool with one provider.""" + client = MagicMock() + + pool_name = _pool_name(GCP_PROJECT_ID) + pools = ( + client.projects.return_value.locations.return_value.workloadIdentityPools.return_value + ) + pools.list.return_value.execute.return_value = { + "workloadIdentityPools": [{"name": pool_name, "state": "ACTIVE"}] + } + pools.list_next.return_value = None + + providers = pools.providers.return_value + providers.list.return_value.execute.return_value = { + "workloadIdentityPoolProviders": [_provider_payload(pool_name)] + } + providers.list_next.return_value = None + + _empty_service_accounts(client) + return client + + +def _disabled_pool_client(_GCPService, _service, _api_version, _credentials): + """Discovery client stub: a disabled pool containing an ACTIVE provider.""" + client = MagicMock() + + pool_name = _pool_name(GCP_PROJECT_ID) + pools = ( + client.projects.return_value.locations.return_value.workloadIdentityPools.return_value + ) + pools.list.return_value.execute.return_value = { + "workloadIdentityPools": [ + {"name": pool_name, "state": "ACTIVE", "disabled": True} + ] + } + pools.list_next.return_value = None + + providers = pools.providers.return_value + providers.list.return_value.execute.return_value = { + "workloadIdentityPoolProviders": [_provider_payload(pool_name)] + } + providers.list_next.return_value = None + + _empty_service_accounts(client) + return client + + +def _pool_list_failure_client(_GCPService, _service, _api_version, _credentials): + """Pool listing fails for PROJECT_A but succeeds for PROJECT_B.""" + client = MagicMock() + pool_name_b = _pool_name(PROJECT_B) + + pools = ( + client.projects.return_value.locations.return_value.workloadIdentityPools.return_value + ) + + def pools_list(parent): + request = MagicMock() + if f"projects/{PROJECT_A}/" in parent: + request.execute.side_effect = Exception("permission denied listing pools") + else: + request.execute.return_value = { + "workloadIdentityPools": [{"name": pool_name_b, "state": "ACTIVE"}] + } + return request + + pools.list.side_effect = pools_list + pools.list_next.return_value = None + + providers = pools.providers.return_value + providers.list.return_value.execute.return_value = { + "workloadIdentityPoolProviders": [_provider_payload(pool_name_b)] + } + providers.list_next.return_value = None + + _empty_service_accounts(client) + return client + + +def _provider_list_failure_client(_GCPService, _service, _api_version, _credentials): + """Provider listing fails for pool-1 but succeeds for pool-2 in one project.""" + client = MagicMock() + pool_1 = _pool_name(GCP_PROJECT_ID, "pool-1") + pool_2 = _pool_name(GCP_PROJECT_ID, "pool-2") + + pools = ( + client.projects.return_value.locations.return_value.workloadIdentityPools.return_value + ) + pools.list.return_value.execute.return_value = { + "workloadIdentityPools": [ + {"name": pool_1, "state": "ACTIVE"}, + {"name": pool_2, "state": "ACTIVE"}, + ] + } + pools.list_next.return_value = None + + providers = pools.providers.return_value + + def providers_list(parent): + request = MagicMock() + if parent == pool_1: + request.execute.side_effect = Exception( + "permission denied listing providers" + ) + else: + request.execute.return_value = { + "workloadIdentityPoolProviders": [ + _provider_payload(pool_2, provider_id="provider-2") + ] + } + return request + + providers.list.side_effect = providers_list + providers.list_next.return_value = None + + _empty_service_accounts(client) + return client + + +def _run_service(client_factory, project_ids): + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(project_ids=project_ids), + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=client_factory, + ), + ): + from prowler.providers.gcp.services.iam.iam_service import IAM + + return IAM(set_mocked_gcp_provider(project_ids=project_ids)) + + +class TestIAMWorkloadIdentityService: + def test_get_workload_identity_pool_providers(self): + iam = _run_service(_wif_client, [GCP_PROJECT_ID]) + + assert len(iam.workload_identity_pool_providers) == 1 + provider = iam.workload_identity_pool_providers[0] + assert provider.id == "my-provider" + assert provider.pool_id == "my-pool" + assert provider.project_id == GCP_PROJECT_ID + assert provider.state == "ACTIVE" + assert provider.disabled is False + assert provider.pool_disabled is False + assert provider.attribute_condition == "" + assert provider.provider_type == "oidc" + assert provider.issuer_uri == "https://token.actions.githubusercontent.com" + + def test_disabled_pool_state_propagates_to_provider(self): + iam = _run_service(_disabled_pool_client, [GCP_PROJECT_ID]) + + # The provider is ACTIVE, but its parent pool is disabled: the pool's + # effective state must travel with the provider record. + assert len(iam.workload_identity_pool_providers) == 1 + provider = iam.workload_identity_pool_providers[0] + assert provider.state == "ACTIVE" + assert provider.disabled is False + assert provider.pool_disabled is True + + def test_pool_list_failure_does_not_block_other_projects(self): + iam = _run_service(_pool_list_failure_client, [PROJECT_A, PROJECT_B]) + + # PROJECT_A's pool listing failed, but PROJECT_B is still processed. + assert len(iam.workload_identity_pool_providers) == 1 + assert iam.workload_identity_pool_providers[0].project_id == PROJECT_B + + def test_provider_list_failure_only_skips_that_pool(self): + iam = _run_service(_provider_list_failure_client, [GCP_PROJECT_ID]) + + # pool-1's provider listing failed, but pool-2's provider is still found. + assert len(iam.workload_identity_pool_providers) == 1 + assert iam.workload_identity_pool_providers[0].pool_id == "pool-2" + assert iam.workload_identity_pool_providers[0].id == "provider-2" 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/organization/organization_actions_pull_request_approval_disabled/organization_actions_pull_request_approval_disabled_test.py b/tests/providers/github/services/organization/organization_actions_pull_request_approval_disabled/organization_actions_pull_request_approval_disabled_test.py new file mode 100644 index 0000000000..c188fe2c18 --- /dev/null +++ b/tests/providers/github/services/organization/organization_actions_pull_request_approval_disabled/organization_actions_pull_request_approval_disabled_test.py @@ -0,0 +1,79 @@ +from unittest import mock + +from prowler.providers.github.services.organization.organization_service import Org +from tests.providers.github.github_fixtures import set_mocked_github_provider + +CHECK_CLIENT_PATH = "prowler.providers.github.services.organization.organization_actions_pull_request_approval_disabled.organization_actions_pull_request_approval_disabled.organization_client" + + +class Test_organization_actions_pull_request_approval_disabled: + """Unit tests for the organization_actions_pull_request_approval_disabled check.""" + + def _run_check(self, organizations): + """Execute the check against the provided organizations.""" + organization_client = mock.MagicMock + organization_client.organizations = organizations + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch(CHECK_CLIENT_PATH, new=organization_client), + ): + from prowler.providers.github.services.organization.organization_actions_pull_request_approval_disabled.organization_actions_pull_request_approval_disabled import ( + organization_actions_pull_request_approval_disabled, + ) + + check = organization_actions_pull_request_approval_disabled() + return check.execute() + + def test_no_organizations(self): + """Test that no findings are reported when there are no organizations.""" + assert len(self._run_check({})) == 0 + + def test_pull_request_approval_unknown(self): + """Test that no finding is reported when the setting could not be read.""" + organizations = { + 1: Org( + id=1, name="test-organization", can_approve_pull_request_reviews=None + ) + } + + assert len(self._run_check(organizations)) == 0 + + def test_pull_request_approval_disabled(self): + """Test that an organization not allowing workflow approvals passes the check.""" + organizations = { + 1: Org( + id=1, name="test-organization", can_approve_pull_request_reviews=False + ) + } + result = self._run_check(organizations) + + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == "test-organization" + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Organization test-organization does not allow GitHub Actions to approve pull requests." + ) + + def test_pull_request_approval_enabled(self): + """Test that an organization allowing workflow approvals fails the check.""" + organizations = { + 1: Org( + id=1, name="test-organization", can_approve_pull_request_reviews=True + ) + } + result = self._run_check(organizations) + + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == "test-organization" + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Organization test-organization allows GitHub Actions to approve pull requests." + ) diff --git a/tests/providers/github/services/organization/organization_default_workflow_permissions_read_only/organization_default_workflow_permissions_read_only_test.py b/tests/providers/github/services/organization/organization_default_workflow_permissions_read_only/organization_default_workflow_permissions_read_only_test.py new file mode 100644 index 0000000000..ddface4a73 --- /dev/null +++ b/tests/providers/github/services/organization/organization_default_workflow_permissions_read_only/organization_default_workflow_permissions_read_only_test.py @@ -0,0 +1,132 @@ +from unittest import mock + +from prowler.providers.github.services.organization.organization_service import Org +from tests.providers.github.github_fixtures import set_mocked_github_provider + + +class Test_organization_default_workflow_permissions_read_only: + def test_no_organizations(self): + """Test that no findings are reported when there are no organizations""" + organization_client = mock.MagicMock + organization_client.organizations = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.organization.organization_default_workflow_permissions_read_only.organization_default_workflow_permissions_read_only.organization_client", + new=organization_client, + ), + ): + from prowler.providers.github.services.organization.organization_default_workflow_permissions_read_only.organization_default_workflow_permissions_read_only import ( + organization_default_workflow_permissions_read_only, + ) + + check = organization_default_workflow_permissions_read_only() + result = check.execute() + assert len(result) == 0 + + def test_organization_default_workflow_permissions_unknown(self): + """Test that no finding is reported when the setting could not be read""" + organization_client = mock.MagicMock + organization_client.organizations = { + 1: Org( + id=1, + name="test-organization", + default_workflow_permissions=None, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.organization.organization_default_workflow_permissions_read_only.organization_default_workflow_permissions_read_only.organization_client", + new=organization_client, + ), + ): + from prowler.providers.github.services.organization.organization_default_workflow_permissions_read_only.organization_default_workflow_permissions_read_only import ( + organization_default_workflow_permissions_read_only, + ) + + check = organization_default_workflow_permissions_read_only() + result = check.execute() + assert len(result) == 0 + + def test_organization_default_workflow_permissions_write(self): + """Test that a write-capable default GITHUB_TOKEN fails the check""" + organization_client = mock.MagicMock + org_name = "test-organization" + organization_client.organizations = { + 1: Org( + id=1, + name=org_name, + default_workflow_permissions="write", + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.organization.organization_default_workflow_permissions_read_only.organization_default_workflow_permissions_read_only.organization_client", + new=organization_client, + ), + ): + from prowler.providers.github.services.organization.organization_default_workflow_permissions_read_only.organization_default_workflow_permissions_read_only import ( + organization_default_workflow_permissions_read_only, + ) + + check = organization_default_workflow_permissions_read_only() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == org_name + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Organization {org_name} grants workflows a default GITHUB_TOKEN with write permissions." + ) + + def test_organization_default_workflow_permissions_read(self): + """Test that a read-only default GITHUB_TOKEN passes the check""" + organization_client = mock.MagicMock + org_name = "test-organization" + organization_client.organizations = { + 1: Org( + id=1, + name=org_name, + default_workflow_permissions="read", + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.organization.organization_default_workflow_permissions_read_only.organization_default_workflow_permissions_read_only.organization_client", + new=organization_client, + ), + ): + from prowler.providers.github.services.organization.organization_default_workflow_permissions_read_only.organization_default_workflow_permissions_read_only import ( + organization_default_workflow_permissions_read_only, + ) + + check = organization_default_workflow_permissions_read_only() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == org_name + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Organization {org_name} grants workflows a read-only default GITHUB_TOKEN." + ) diff --git a/tests/providers/github/services/organization/organization_service_test.py b/tests/providers/github/services/organization/organization_service_test.py index 22b65e3267..43e8e4ae8e 100644 --- a/tests/providers/github/services/organization/organization_service_test.py +++ b/tests/providers/github/services/organization/organization_service_test.py @@ -1,6 +1,7 @@ from unittest.mock import MagicMock, patch from github import GithubException, RateLimitExceededException +from pytest import raises from prowler.providers.github.services.organization.organization_service import ( Org, @@ -464,7 +465,7 @@ class Test_Organization_ErrorHandling: assert any("Access denied" in msg for msg in log_messages) def test_rate_limit_error_handling(self): - """Test that rate limit errors are logged appropriately""" + """Test that rate limit errors in the scoped-organization path are propagated""" provider = set_mocked_github_provider() provider.repositories = [] provider.organizations = ["test-org1"] @@ -484,11 +485,140 @@ class Test_Organization_ErrorHandling: with patch( "prowler.providers.github.services.organization.organization_service.logger" ) as mock_logger: - # Rate limit errors should be caught and logged at the outer level - orgs = organization_service._list_organizations() + # Rate limit errors are logged and propagated instead of silently + # dropping the scoped organization + with raises(RateLimitExceededException): + organization_service._list_organizations() - # Should be empty due to rate limit error - assert len(orgs) == 0 - # Should log rate limit error mock_logger.error.assert_called() assert "Rate limit exceeded" in str(mock_logger.error.call_args) + mock_client.get_user.assert_not_called() + + +class Test_Organization_Default_Workflow_Permissions: + def _get_service(self): + """Build an Organization service without contacting GitHub""" + with patch( + "prowler.providers.github.services.organization.organization_service.GithubService.__init__" + ): + return Organization(set_mocked_github_provider()) + + def test_default_workflow_permissions_read(self): + """Test that the default workflow permissions value is read from the API response""" + organization_service = self._get_service() + mock_org = MagicMock() + mock_org.login = "test-org" + mock_org._requester.requestJsonAndCheck.return_value = ( + {}, + {"default_workflow_permissions": "read"}, + ) + + assert ( + organization_service._get_actions_workflow_permissions(mock_org)[0] + == "read" + ), "The organization default GITHUB_TOKEN permissions should be read from the Actions permissions endpoint" + mock_org._requester.requestJsonAndCheck.assert_called_once_with( + "GET", + "/orgs/test-org/actions/permissions/workflow", + headers={ + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + + def test_can_approve_pull_request_reviews(self): + """Test that the pull request approval setting is read from the same API response""" + organization_service = self._get_service() + mock_org = MagicMock() + mock_org.login = "test-org" + mock_org._requester.requestJsonAndCheck.return_value = ( + {}, + { + "default_workflow_permissions": "write", + "can_approve_pull_request_reviews": True, + }, + ) + + assert organization_service._get_actions_workflow_permissions(mock_org) == ( + "write", + True, + ), "Both Actions workflow permissions settings should come from a single request" + assert ( + mock_org._requester.requestJsonAndCheck.call_count == 1 + ), "Both settings should be read without issuing a second request" + + def test_default_workflow_permissions_missing_field(self): + """Test that a response without the permissions field yields no value""" + organization_service = self._get_service() + mock_org = MagicMock() + mock_org.login = "test-org" + mock_org._requester.requestJsonAndCheck.return_value = ({}, {}) + + assert organization_service._get_actions_workflow_permissions(mock_org) == ( + None, + None, + ), "An unexpected response payload should not be reported as a permissions value" + + def test_default_workflow_permissions_unsupported_value(self): + """Test that a value outside read/write is treated as unknown rather than a failure + + The endpoint documents only "read" and "write". Accepting any string meant a + value such as "READ" or "none" reached the check and produced a FAIL finding, + reporting a write-capable default the organization may not actually have. + """ + organization_service = self._get_service() + mock_org = MagicMock() + mock_org.login = "test-org" + mock_org._requester.requestJsonAndCheck.return_value = ( + {}, + {"default_workflow_permissions": "none"}, + ) + + assert organization_service._get_actions_workflow_permissions(mock_org) == ( + None, + None, + ), "An unsupported permissions value should be left unknown instead of reported as write" + + def test_default_workflow_permissions_not_available(self): + """Test that accounts without Actions permissions settings yield no value""" + organization_service = self._get_service() + mock_org = MagicMock() + mock_org.login = "test-user" + mock_org._requester.requestJsonAndCheck.side_effect = GithubException( + 404, "Not Found", None + ) + + assert organization_service._get_actions_workflow_permissions(mock_org) == ( + None, + None, + ), "Accounts without Actions permissions settings should not produce a value" + + def test_default_workflow_permissions_access_denied(self): + """Test that a token without organization administration access yields no value""" + organization_service = self._get_service() + mock_org = MagicMock() + mock_org.login = "test-org" + mock_org._requester.requestJsonAndCheck.side_effect = GithubException( + 403, "Forbidden", None + ) + + with patch( + "prowler.providers.github.services.organization.organization_service.logger" + ) as mock_logger: + assert organization_service._get_actions_workflow_permissions(mock_org) == ( + None, + None, + ), "Insufficient permissions should not produce a value" + mock_logger.warning.assert_called() + + def test_default_workflow_permissions_rate_limit(self): + """Test that rate limit errors are propagated instead of being reported as unavailable""" + organization_service = self._get_service() + mock_org = MagicMock() + mock_org.login = "test-org" + mock_org._requester.requestJsonAndCheck.side_effect = ( + RateLimitExceededException(403, "Rate limit exceeded", None) + ) + + with raises(RateLimitExceededException): + organization_service._get_actions_workflow_permissions(mock_org) diff --git a/tests/providers/github/services/repository/repository_default_workflow_permissions_read_only/repository_default_workflow_permissions_read_only_test.py b/tests/providers/github/services/repository/repository_default_workflow_permissions_read_only/repository_default_workflow_permissions_read_only_test.py new file mode 100644 index 0000000000..1c64813df1 --- /dev/null +++ b/tests/providers/github/services/repository/repository_default_workflow_permissions_read_only/repository_default_workflow_permissions_read_only_test.py @@ -0,0 +1,98 @@ +from datetime import datetime, timezone +from unittest import mock + +from prowler.providers.github.services.repository.repository_service import Branch, Repo +from tests.providers.github.github_fixtures import set_mocked_github_provider + +CHECK_CLIENT_PATH = "prowler.providers.github.services.repository.repository_default_workflow_permissions_read_only.repository_default_workflow_permissions_read_only.repository_client" + + +class Test_repository_default_workflow_permissions_read_only: + """Unit tests for the repository_default_workflow_permissions_read_only check.""" + + def _build_repo(self, default_workflow_permissions): + """Create a Repo instance with the provided default workflow permissions state.""" + default_branch = Branch( + name="main", + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=1, + required_linear_history=True, + allow_force_pushes=False, + branch_deletion=False, + status_checks=True, + enforce_admins=True, + require_code_owner_reviews=True, + require_signed_commits=True, + conversation_resolution=True, + ) + return Repo( + id=1, + name="repo1", + owner="account-name", + full_name="account-name/repo1", + default_workflow_permissions=default_workflow_permissions, + default_branch=default_branch, + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=True, + codeowners_exists=True, + secret_scanning_enabled=True, + dependabot_alerts_enabled=True, + delete_branch_on_merge=False, + ) + + def _run_check(self, repositories): + """Execute the check against the provided repositories.""" + repository_client = mock.MagicMock + repository_client.repositories = repositories + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch(CHECK_CLIENT_PATH, new=repository_client), + ): + from prowler.providers.github.services.repository.repository_default_workflow_permissions_read_only.repository_default_workflow_permissions_read_only import ( + repository_default_workflow_permissions_read_only, + ) + + check = repository_default_workflow_permissions_read_only() + return check.execute() + + def test_no_repositories(self): + """Test that no findings are reported when there are no repositories.""" + assert len(self._run_check({})) == 0 + + def test_default_workflow_permissions_unknown(self): + """Test that no finding is reported when the setting could not be read.""" + assert len(self._run_check({1: self._build_repo(None)})) == 0 + + def test_default_workflow_permissions_read(self): + """Test that a read-only default GITHUB_TOKEN passes the check.""" + result = self._run_check({1: self._build_repo("read")}) + + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == "repo1" + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Repository repo1 grants workflows a read-only default GITHUB_TOKEN." + ) + + def test_default_workflow_permissions_write(self): + """Test that a write-capable default GITHUB_TOKEN fails the check.""" + result = self._run_check({1: self._build_repo("write")}) + + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == "repo1" + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Repository repo1 grants workflows a default GITHUB_TOKEN with write permissions." + ) diff --git a/tests/providers/github/services/repository/repository_service_test.py b/tests/providers/github/services/repository/repository_service_test.py index d0f3573e49..af174a6e54 100644 --- a/tests/providers/github/services/repository/repository_service_test.py +++ b/tests/providers/github/services/repository/repository_service_test.py @@ -1,9 +1,12 @@ from datetime import datetime, timezone from unittest.mock import MagicMock, patch +import pytest import requests from github import GithubException, RateLimitExceededException +from pytest import raises +from prowler.providers.github.models import GithubAppIdentityInfo from prowler.providers.github.services.repository.repository_service import ( Branch, Repo, @@ -434,7 +437,7 @@ class Test_Repository_ErrorHandling: assert any("Access denied" in msg for msg in log_messages) def test_rate_limit_error_handling(self): - """Test that rate limit errors are logged appropriately""" + """Test that rate limit errors propagate out of _list_repositories""" provider = set_mocked_github_provider() provider.repositories = ["owner/repo1"] provider.organizations = [] @@ -451,17 +454,10 @@ class Test_Repository_ErrorHandling: repository_service.clients = [mock_client] repository_service.provider = provider - with patch( - "prowler.providers.github.lib.service.service.logger" - ) as mock_logger: - # Rate limit errors should be caught and logged at the outer level - repos = repository_service._list_repositories() - - # Should be empty due to rate limit error - assert len(repos) == 0 - # Should log rate limit error - mock_logger.error.assert_called() - assert "Rate limit exceeded" in str(mock_logger.error.call_args) + # Rate limits are transient: the scan must abort rather than + # continue with an incomplete repository set + with pytest.raises(RateLimitExceededException): + repository_service._list_repositories() class Test_Repository_BranchProtectionRulesets: @@ -944,3 +940,419 @@ class Test_Repository_BranchProtectionRulesets: assert repos[1].default_branch.enforce_admins_source is None # The branch is still reported as protected-but-inactive regardless of bypass. assert repos[1].default_branch.protected_source == "ruleset_not_active" + + +class Test_Repository_Default_Workflow_Permissions: + def setup_method(self): + """Build a Repository service instance without contacting GitHub.""" + self.repository_service = Repository.__new__(Repository) + self.repository_service.provider = set_mocked_github_provider() + + def _mock_repo(self): + """Create a mocked PyGithub repository.""" + repo = MagicMock() + repo.full_name = "account-name/repo1" + return repo + + def test_default_workflow_permissions_read(self): + """Test that the default workflow permissions value is read from the API response.""" + repo = self._mock_repo() + repo._requester.requestJsonAndCheck.return_value = ( + {}, + {"default_workflow_permissions": "read"}, + ) + + assert ( + self.repository_service._get_default_workflow_permissions(repo) == "read" + ), "The repository default GITHUB_TOKEN permissions should be read from the Actions permissions endpoint" + + def test_default_workflow_permissions_missing_field(self): + """Test that a response without the permissions field yields no value.""" + repo = self._mock_repo() + repo._requester.requestJsonAndCheck.return_value = ({}, {}) + + assert ( + self.repository_service._get_default_workflow_permissions(repo) is None + ), "An unexpected response payload should not be reported as a permissions value" + + def test_default_workflow_permissions_unsupported_value(self): + """Test that a value outside read/write is treated as unknown rather than a failure.""" + repo = self._mock_repo() + repo._requester.requestJsonAndCheck.return_value = ( + {}, + {"default_workflow_permissions": "none"}, + ) + + assert ( + self.repository_service._get_default_workflow_permissions(repo) is None + ), "An unsupported permissions value should be left unknown instead of reported as write" + + def test_default_workflow_permissions_not_available(self): + """Test that repositories without the setting yield no value.""" + repo = self._mock_repo() + repo._requester.requestJsonAndCheck.side_effect = GithubException( + 404, "Not Found", None + ) + + assert ( + self.repository_service._get_default_workflow_permissions(repo) is None + ), "A repository without Actions permissions settings should not produce a value" + + def test_default_workflow_permissions_access_denied(self): + """Test that a token without repository administration access yields no value.""" + repo = self._mock_repo() + repo._requester.requestJsonAndCheck.side_effect = GithubException( + 403, "Forbidden", None + ) + + with patch( + "prowler.providers.github.services.repository.repository_service.logger" + ) as mock_logger: + assert ( + self.repository_service._get_default_workflow_permissions(repo) is None + ), "Insufficient permissions should not produce a value" + mock_logger.warning.assert_called() + + def test_default_workflow_permissions_rate_limit(self): + """Test that rate limit errors are propagated instead of being reported as unavailable.""" + repo = self._mock_repo() + repo._requester.requestJsonAndCheck.side_effect = RateLimitExceededException( + 403, "Rate limit exceeded", None + ) + + with raises(RateLimitExceededException): + self.repository_service._get_default_workflow_permissions(repo) + + def test_default_workflow_permissions_unexpected_api_error(self): + """Test that an unexpected GitHub API error is logged and yields no value.""" + repo = self._mock_repo() + repo._requester.requestJsonAndCheck.side_effect = GithubException( + 500, "Internal Server Error", None + ) + + with patch( + "prowler.providers.github.lib.service.service.logger" + ) as mock_logger: + assert ( + self.repository_service._get_default_workflow_permissions(repo) is None + ), "An unexpected API error should not produce a value" + mock_logger.error.assert_called() + assert "Internal Server Error" in str(mock_logger.error.call_args) + + def test_default_workflow_permissions_unexpected_error(self): + """Test that an unexpected non-GitHub error is logged and yields no value.""" + repo = self._mock_repo() + repo._requester.requestJsonAndCheck.side_effect = Exception("unexpected") + + with patch( + "prowler.providers.github.services.repository.repository_service.logger" + ) as mock_logger: + assert ( + self.repository_service._get_default_workflow_permissions(repo) is None + ), "An unexpected error should not produce a value" + mock_logger.error.assert_called() + + def test_process_repository_propagates_rate_limit(self): + """Test that a rate limit while reading the setting aborts repository processing.""" + self.repository_service.clients = [] + self.repository_service.audit_config = None + self.repository_service.fixer_config = None + + repo = MagicMock() + repo.id = 1 + repo.name = "repo1" + repo.owner.login = "account-name" + repo.full_name = "account-name/repo1" + repo.default_branch = "main" + repo.private = False + repo.archived = False + repo.pushed_at = datetime.now(timezone.utc) + repo.delete_branch_on_merge = False + repo.security_and_analysis = None + repo.get_contents.side_effect = [None, None, None, None] + repo.get_dependabot_alerts.side_effect = Exception("403 Forbidden") + repo._requester.requestJsonAndCheck.side_effect = RateLimitExceededException( + 403, "Rate limit exceeded", None + ) + + repos = {} + with raises(RateLimitExceededException): + self.repository_service._process_repository(repo, repos) + + assert repos == {}, "A rate limited repository should not be partially recorded" + + +class Test_Repository_List_Rate_Limit_Propagation: + """Rate limits must abort _list_repositories instead of being swallowed.""" + + def _service_with(self, provider, mock_client): + with patch.object(Repository, "__init__", lambda *_: None): + repository_service = Repository(provider) + repository_service.clients = [mock_client] + repository_service.provider = provider + return repository_service + + def test_direct_repositories_branch_propagates_rate_limit(self): + """A rate limit on a directly requested repository aborts the listing.""" + provider = set_mocked_github_provider() + provider.repositories = ["owner/repo1"] + provider.organizations = [] + + mock_client = MagicMock() + mock_client.get_repo.side_effect = RateLimitExceededException( + 429, "Rate limit exceeded", None + ) + + repository_service = self._service_with(provider, mock_client) + + with raises(RateLimitExceededException): + repository_service._list_repositories() + + def test_organization_branch_propagates_rate_limit(self): + """A rate limit while listing an organization's repositories aborts the listing.""" + provider = set_mocked_github_provider() + provider.repositories = [] + provider.organizations = ["org1"] + + mock_client = MagicMock() + with patch.object( + Repository, + "_get_repositories_from_owner", + side_effect=RateLimitExceededException(429, "Rate limit exceeded", None), + ): + repository_service = self._service_with(provider, mock_client) + + with raises(RateLimitExceededException): + repository_service._list_repositories() + + def test_installations_branch_propagates_rate_limit(self): + """A rate limit while listing a GitHub App installation's repositories aborts the listing.""" + provider = set_mocked_github_provider() + provider.repositories = [] + provider.organizations = [] + provider.identity = GithubAppIdentityInfo( + app_id="1", app_name="app", installations=["installed-org"] + ) + + mock_client = MagicMock() + with patch.object( + Repository, + "_get_repositories_from_owner", + side_effect=RateLimitExceededException(429, "Rate limit exceeded", None), + ): + repository_service = self._service_with(provider, mock_client) + + with raises(RateLimitExceededException): + repository_service._list_repositories() + + def test_graphql_branch_propagates_rate_limit(self): + """A rate limit on a repository discovered via GraphQL aborts the listing.""" + provider = set_mocked_github_provider() + provider.repositories = [] + provider.organizations = [] + + mock_client = MagicMock() + mock_client.get_repo.side_effect = RateLimitExceededException( + 429, "Rate limit exceeded", None + ) + + repository_service = self._service_with(provider, mock_client) + + with patch.object( + repository_service, + "_get_accessible_repos_graphql", + return_value=["owner1/repo1"], + ): + 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/huaweicloud/services/vpc/huaweicloud_vpc_service_test.py b/tests/providers/huaweicloud/services/vpc/huaweicloud_vpc_service_test.py index 54b289aa99..85543ae2d5 100644 --- a/tests/providers/huaweicloud/services/vpc/huaweicloud_vpc_service_test.py +++ b/tests/providers/huaweicloud/services/vpc/huaweicloud_vpc_service_test.py @@ -6,6 +6,7 @@ from prowler.providers.huaweicloud.services.vpc.vpc_service import ( SecurityGroupRule, SecurityGroups, VPCs, + rule_source_is_open, ) from tests.providers.huaweicloud.huaweicloud_fixtures import ( set_mocked_huaweicloud_provider, @@ -24,6 +25,18 @@ def _provider_with_client(regional_client): class TestVPCService: + def test_rule_with_remote_address_group_is_not_open(self): + rule = SecurityGroupRule( + id="rule-1", + direction="ingress", + protocol="tcp", + ethertype="IPv4", + remote_ip_prefix="", + remote_address_group_id="address-group-1", + ) + + assert rule_source_is_open(rule) is False + def test_list_vpcs_and_security_groups_parses(self): vpc = SimpleNamespace( id="vpc-1", @@ -36,12 +49,14 @@ class TestVPCService: rule = SimpleNamespace( id="rule-1", direction="ingress", + action="deny", protocol="tcp", ethertype="IPv4", port_range_min=22, port_range_max=22, remote_ip_prefix="0.0.0.0/0", remote_group_id="", + remote_address_group_id="address-group-1", description="ssh open", ) sg = SimpleNamespace( @@ -82,8 +97,10 @@ class TestVPCService: parsed_rule = parsed_sg.rules[0] assert isinstance(parsed_rule, SecurityGroupRule) assert parsed_rule.direction == "ingress" + assert parsed_rule.action == "deny" assert parsed_rule.protocol == "tcp" assert parsed_rule.remote_ip_prefix == "0.0.0.0/0" + assert parsed_rule.remote_address_group_id == "address-group-1" assert parsed_rule.port_range_min == 22 assert parsed_rule.port_range_max == 22 @@ -94,12 +111,14 @@ class TestVPCService: rule = SimpleNamespace( id="rule-1", direction=None, + action=None, protocol=None, ethertype=None, port_range_min=None, port_range_max=None, remote_ip_prefix=None, remote_group_id=None, + remote_address_group_id=None, description=None, ) sg = SimpleNamespace( @@ -134,8 +153,10 @@ class TestVPCService: assert parsed_sg.name == "sg-1" # falls back to id assert parsed_sg.vpc_id == "" parsed_rule = parsed_sg.rules[0] + assert parsed_rule.action == "allow" assert parsed_rule.protocol == "" assert parsed_rule.remote_ip_prefix == "" + assert parsed_rule.remote_address_group_id == "" assert parsed_rule.description == "" assert parsed_rule.direction == "" diff --git a/tests/providers/huaweicloud/services/vpc/vpc_security_group_open_egress/vpc_security_group_open_egress_test.py b/tests/providers/huaweicloud/services/vpc/vpc_security_group_open_egress/vpc_security_group_open_egress_test.py new file mode 100644 index 0000000000..6c952fe55d --- /dev/null +++ b/tests/providers/huaweicloud/services/vpc/vpc_security_group_open_egress/vpc_security_group_open_egress_test.py @@ -0,0 +1,277 @@ +from unittest import mock + +from tests.providers.huaweicloud.huaweicloud_fixtures import ( + set_mocked_huaweicloud_provider, +) + + +class TestVpcSecurityGroupOpenEgress: + def test_no_open_egress_passes(self): + vpc_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_huaweicloud_provider(), + ), + mock.patch( + "prowler.providers.huaweicloud.services.vpc.vpc_security_group_open_egress.vpc_security_group_open_egress.vpc_client", + new=vpc_client, + ), + ): + from prowler.providers.huaweicloud.services.vpc.vpc_security_group_open_egress.vpc_security_group_open_egress import ( + vpc_security_group_open_egress, + ) + from prowler.providers.huaweicloud.services.vpc.vpc_service import ( + SecurityGroupRule, + SecurityGroups, + ) + + sg = SecurityGroups( + id="sg-1", + name="safe-sg", + region="la-south-2", + vpc_id="vpc-1", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="egress", + protocol="tcp", + ethertype="IPv4", + remote_ip_prefix="10.0.0.0/24", + port_range_min=443, + port_range_max=443, + ), + SecurityGroupRule( + id="deny-rule", + direction="egress", + action="deny", + protocol="", + ethertype="IPv4", + remote_ip_prefix="", + ), + SecurityGroupRule( + id="address-group-rule", + direction="egress", + action="allow", + protocol="", + ethertype="IPv4", + remote_ip_prefix="", + remote_address_group_id="address-group-1", + ), + ], + ) + vpc_client.security_groups = {"sg-1": sg} + vpc_client.audited_account = "123456789012" + + check = vpc_security_group_open_egress() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + "Security group safe-sg (sg-1) does not allow open egress to " + "the internet." + ) + assert result[0].resource_id == "sg-1" + assert result[0].resource_name == "safe-sg" + assert result[0].resource_arn == ( + "huaweicloud:vpc:la-south-2:123456789012:security-group/sg-1" + ) + assert result[0].region == "la-south-2" + + def test_open_egress_ipv4_fails(self): + vpc_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_huaweicloud_provider(), + ), + mock.patch( + "prowler.providers.huaweicloud.services.vpc.vpc_security_group_open_egress.vpc_security_group_open_egress.vpc_client", + new=vpc_client, + ), + ): + from prowler.providers.huaweicloud.services.vpc.vpc_security_group_open_egress.vpc_security_group_open_egress import ( + vpc_security_group_open_egress, + ) + from prowler.providers.huaweicloud.services.vpc.vpc_service import ( + SecurityGroupRule, + SecurityGroups, + ) + + sg = SecurityGroups( + id="sg-1", + name="open-egress-sg", + region="la-south-2", + vpc_id="vpc-1", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="egress", + protocol="tcp", + ethertype="IPv4", + remote_ip_prefix="0.0.0.0/0", + port_range_min=80, + port_range_max=80, + ), + ], + ) + vpc_client.security_groups = {"sg-1": sg} + vpc_client.audited_account = "123456789012" + + check = vpc_security_group_open_egress() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "Security group open-egress-sg (sg-1) allows open egress " + "(0.0.0.0/0) to the internet." + ) + assert result[0].resource_id == "sg-1" + assert result[0].resource_name == "open-egress-sg" + assert result[0].resource_arn == ( + "huaweicloud:vpc:la-south-2:123456789012:security-group/sg-1" + ) + assert result[0].region == "la-south-2" + + def test_open_egress_ipv6_fails(self): + vpc_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_huaweicloud_provider(), + ), + mock.patch( + "prowler.providers.huaweicloud.services.vpc.vpc_security_group_open_egress.vpc_security_group_open_egress.vpc_client", + new=vpc_client, + ), + ): + from prowler.providers.huaweicloud.services.vpc.vpc_security_group_open_egress.vpc_security_group_open_egress import ( + vpc_security_group_open_egress, + ) + from prowler.providers.huaweicloud.services.vpc.vpc_service import ( + SecurityGroupRule, + SecurityGroups, + ) + + sg = SecurityGroups( + id="sg-1", + name="open-egress-sg-ipv6", + region="la-south-2", + vpc_id="vpc-1", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="egress", + protocol="tcp", + ethertype="IPv6", + remote_ip_prefix="::/0", + port_range_min=443, + port_range_max=443, + ), + ], + ) + vpc_client.security_groups = {"sg-1": sg} + vpc_client.audited_account = "123456789012" + + check = vpc_security_group_open_egress() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "Security group open-egress-sg-ipv6 (sg-1) allows open egress " + "(::/0) to the internet." + ) + assert result[0].resource_id == "sg-1" + assert result[0].resource_name == "open-egress-sg-ipv6" + assert result[0].resource_arn == ( + "huaweicloud:vpc:la-south-2:123456789012:security-group/sg-1" + ) + assert result[0].region == "la-south-2" + + def test_open_egress_with_empty_destination_fails(self): + vpc_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_huaweicloud_provider(), + ), + mock.patch( + "prowler.providers.huaweicloud.services.vpc.vpc_security_group_open_egress.vpc_security_group_open_egress.vpc_client", + new=vpc_client, + ), + ): + from prowler.providers.huaweicloud.services.vpc.vpc_security_group_open_egress.vpc_security_group_open_egress import ( + vpc_security_group_open_egress, + ) + from prowler.providers.huaweicloud.services.vpc.vpc_service import ( + SecurityGroupRule, + SecurityGroups, + ) + + sg = SecurityGroups( + id="sg-1", + name="open-egress-sg-empty-destination", + region="la-south-2", + vpc_id="vpc-1", + rules=[ + SecurityGroupRule( + id="rule-1", + direction="egress", + action="allow", + protocol="", + ethertype="IPv4", + remote_ip_prefix="", + remote_address_group_id="", + ), + ], + ) + vpc_client.security_groups = {"sg-1": sg} + vpc_client.audited_account = "123456789012" + + check = vpc_security_group_open_egress() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "Security group open-egress-sg-empty-destination (sg-1) allows " + "open egress (all destinations) to the internet." + ) + assert result[0].resource_id == "sg-1" + assert result[0].resource_name == "open-egress-sg-empty-destination" + assert result[0].resource_arn == ( + "huaweicloud:vpc:la-south-2:123456789012:security-group/sg-1" + ) + assert result[0].region == "la-south-2" + + def test_no_security_groups(self): + vpc_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_huaweicloud_provider(), + ), + mock.patch( + "prowler.providers.huaweicloud.services.vpc.vpc_security_group_open_egress.vpc_security_group_open_egress.vpc_client", + new=vpc_client, + ), + ): + from prowler.providers.huaweicloud.services.vpc.vpc_security_group_open_egress.vpc_security_group_open_egress import ( + vpc_security_group_open_egress, + ) + + vpc_client.security_groups = {} + vpc_client.audited_account = "123456789012" + + check = vpc_security_group_open_egress() + result = check.execute() + + assert len(result) == 0 diff --git a/tests/providers/iac/iac_provider_test.py b/tests/providers/iac/iac_provider_test.py index 8b4b51000a..fe51aacb8c 100644 --- a/tests/providers/iac/iac_provider_test.py +++ b/tests/providers/iac/iac_provider_test.py @@ -7,6 +7,10 @@ from unittest.mock import MagicMock, patch import pytest from prowler.lib.check.models import CheckReportIAC +from prowler.providers.iac.exceptions.exceptions import ( + IacRepositoryCloneError, + IacScanError, +) from prowler.providers.iac.iac_provider import IacProvider from tests.providers.iac.iac_fixtures import ( DEFAULT_SCAN_PATH, @@ -323,13 +327,11 @@ class TestIacProvider: stdout=get_invalid_trivy_output(), stderr="" ) - with pytest.raises(SystemExit) as excinfo: + with pytest.raises(IacScanError): # Consume the generator for _ in provider.run_scan("/test/directory", ["all"], []): pass - assert excinfo.value.code == 1 - @patch("subprocess.run") def test_iac_provider_run_scan_null_output(self, mock_subprocess): """Test IAC scan with null Trivy output""" @@ -337,13 +339,12 @@ class TestIacProvider: mock_subprocess.return_value = MagicMock(stdout="null", stderr="") - with pytest.raises(SystemExit) as exc_info: + with pytest.raises(IacScanError): # Consume the generator for _ in provider.run_scan( "/test/directory", ["vuln", "misconfig", "secret"], [] ): pass - assert exc_info.value.code == 1 def test_iac_provider_process_finding_dockerfile(self): """Test processing a Dockerfile finding""" @@ -514,15 +515,13 @@ class TestIacProvider: # Make subprocess.run raise an exception mock_subprocess.side_effect = Exception("Test exception") - with pytest.raises(SystemExit) as exc_info: + with pytest.raises(IacScanError): # Consume the generator for _ in provider.run_scan( "/test/directory", ["vuln", "misconfig", "secret"], [] ): pass - assert exc_info.value.code == 1 - @patch("subprocess.run") def test_run_scan_with_different_frameworks(self, mock_subprocess): """Test run_scan with different scanner configurations""" @@ -817,6 +816,26 @@ class TestIacProvider: assert temp_dir == "/tmp/fake-dir" assert branch_name == "master" + @mock.patch("prowler.providers.iac.iac_provider.porcelain.clone") + @mock.patch("tempfile.mkdtemp", return_value="/tmp/fake-dir") + def test_clone_repository_failure_raises(self, _mock_mkdtemp, mock_clone): + """A failed clone must raise a typed error instead of returning `None`. + + `_clone_repository` is annotated `-> tuple[str, str]` and `__init__` + unpacks the result directly, so falling through the error handler used + to raise `TypeError: cannot unpack non-sequence NoneType`. Raising + `IacRepositoryCloneError` lets the CLI exit with the logged message and + lets the API report the failure as a normal task error instead of a + `SystemExit` escaping the Celery worker. + """ + mock_clone.side_effect = Exception("repository not found") + + with pytest.raises(IacRepositoryCloneError) as exc_info: + IacProvider(scan_repository_url="https://github.com/user/repo.git") + + assert "repository not found" in str(exc_info.value) + assert exc_info.value.code == 21000 + def test_detect_branch_name_main(self): """Test detecting 'main' branch from .git/HEAD""" provider = IacProvider() 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/kubernetes/services/control_plane_pods_test.py b/tests/providers/kubernetes/services/control_plane_pods_test.py new file mode 100644 index 0000000000..396a6196ed --- /dev/null +++ b/tests/providers/kubernetes/services/control_plane_pods_test.py @@ -0,0 +1,293 @@ +import importlib +from unittest import mock + +from prowler.providers.kubernetes.services.core.core_service import ( + ConfigMap, + Container, + Pod, +) +from tests.providers.kubernetes.kubernetes_fixtures import ( + set_mocked_kubernetes_provider, +) + +# Each of these services scrapes a subset of the resources already collected by +# the core service, then exposes the result for its checks to iterate. +# +# source - attribute read off the mocked core client +# attribute - attribute the service exposes to its checks +# match/miss - a resource that should and should not be collected +GATHERERS = { + "apiserver": { + "module": "prowler.providers.kubernetes.services.apiserver.apiserver_service", + "class": "APIServer", + "source": "pods", + "namespaced": True, + "attribute": "apiserver_pods", + "match": "kube-apiserver-control-plane", + "miss": "unrelated-workload", + "check": ( + "prowler.providers.kubernetes.services.apiserver." + "apiserver_anonymous_requests.apiserver_anonymous_requests" + ), + "check_class": "apiserver_anonymous_requests", + "client_attr": "apiserver_client", + }, + "controllermanager": { + "module": ( + "prowler.providers.kubernetes.services.controllermanager." + "controllermanager_service" + ), + "class": "ControllerManager", + "source": "pods", + "namespaced": True, + "attribute": "controllermanager_pods", + "match": "kube-controller-manager-control-plane", + "miss": "unrelated-workload", + "check": ( + "prowler.providers.kubernetes.services.controllermanager." + "controllermanager_disable_profiling.controllermanager_disable_profiling" + ), + "check_class": "controllermanager_disable_profiling", + "client_attr": "controllermanager_client", + }, + "etcd": { + "module": "prowler.providers.kubernetes.services.etcd.etcd_service", + "class": "Etcd", + "source": "pods", + "namespaced": True, + "attribute": "etcd_pods", + "match": "etcd-control-plane", + "miss": "unrelated-workload", + "check": ( + "prowler.providers.kubernetes.services.etcd." + "etcd_client_cert_auth.etcd_client_cert_auth" + ), + "check_class": "etcd_client_cert_auth", + "client_attr": "etcd_client", + }, + "scheduler": { + "module": "prowler.providers.kubernetes.services.scheduler.scheduler_service", + "class": "Scheduler", + "source": "pods", + "namespaced": True, + "attribute": "scheduler_pods", + "match": "kube-scheduler-control-plane", + "miss": "unrelated-workload", + "check": ( + "prowler.providers.kubernetes.services.scheduler." + "scheduler_bind_address.scheduler_bind_address" + ), + "check_class": "scheduler_bind_address", + "client_attr": "scheduler_client", + }, + "kubelet": { + "module": "prowler.providers.kubernetes.services.kubelet.kubelet_service", + "class": "Kubelet", + "source": "config_maps", + "namespaced": False, + "attribute": "kubelet_config_maps", + "match": "kubelet-config-node", + "miss": "unrelated-config", + "check": ( + "prowler.providers.kubernetes.services.kubelet." + "kubelet_disable_anonymous_auth.kubelet_disable_anonymous_auth" + ), + "check_class": "kubelet_disable_anonymous_auth", + "client_attr": "kubelet_client", + }, +} + + +def make_pod(name, namespace="kube-system"): + return Pod( + name=name, + uid=f"{name}-uid", + namespace=namespace, + labels=None, + annotations=None, + node_name="control-plane", + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=False, + host_ipc=False, + host_network=False, + security_context={}, + containers={ + "container": Container( + name="container", + image="registry.k8s.io/kube-apiserver:v1.30.0", + command=[], + ports=None, + env=None, + security_context={}, + resources=None, + liveness_probe=None, + readiness_probe=None, + ) + }, + init_containers={}, + ephemeral_containers={}, + volumes=None, + ) + + +def make_config_map(name, namespace="kube-system"): + return ConfigMap( + name=name, + uid=f"{name}-uid", + namespace=namespace, + data={"kubelet": "authentication:\n anonymous:\n enabled: false\n"}, + ) + + +def make_resource(spec, name, namespace="kube-system"): + if spec["source"] == "pods": + return make_pod(name, namespace) + return make_config_map(name, namespace) + + +def build_service(spec, core_client): + """Instantiate a service against a mocked core client.""" + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch(f"{spec['module']}.core_client", new=core_client), + ): + module = importlib.import_module(spec["module"]) + service_class = getattr(module, spec["class"]) + return service_class(set_mocked_kubernetes_provider()) + + +def failing_core_client(spec): + """A core client whose resource collection raises, as on an API error.""" + core_client = mock.MagicMock() + setattr( + type(core_client), + spec["source"], + mock.PropertyMock(side_effect=Exception("Kubernetes API unavailable")), + ) + return core_client + + +class Test_kubernetes_resource_gathering: + """Regression tests for failed resource gathering in Kubernetes services. + + Before this fix, ``_get__pods`` and ``_get_kubelet_config_maps`` + placed their ``return`` inside the ``try`` block, so when resource + collection raised, the ``except`` branch only logged and the function fell + through to an implicit ``return None``. That ``None`` was assigned straight + to the service attribute, and every check that iterates it raised + ``TypeError: 'NoneType' object is not iterable``. + """ + + def test_resources_are_gathered_and_filtered(self): + """Sanity check: only matching resources are collected. + + The pod gatherers filter on both namespace and name prefix; the kubelet + gatherer matches on name prefix alone, so a same-named config map in + another namespace is expected to be collected there. + """ + for name, spec in GATHERERS.items(): + core_client = mock.MagicMock() + setattr( + core_client, + spec["source"], + { + "match": make_resource(spec, spec["match"]), + # Right prefix, different namespace. + "other-namespace": make_resource( + spec, spec["match"], namespace="default" + ), + # Right namespace, wrong prefix. + "wrong-prefix": make_resource(spec, spec["miss"]), + }, + ) + + service = build_service(spec, core_client) + gathered = getattr(service, spec["attribute"]) + + expected = [(spec["match"], "kube-system")] + if not spec["namespaced"]: + expected.append((spec["match"], "default")) + assert [(item.name, item.namespace) for item in gathered] == expected, ( + f"{name}: expected only the matching resource(s); " + f"namespaced={spec['namespaced']}" + ) + + def test_gather_failure_yields_empty_list_not_none(self): + """A failed gather must not produce a non-iterable ``None``.""" + for name, spec in GATHERERS.items(): + service = build_service(spec, failing_core_client(spec)) + gathered = getattr(service, spec["attribute"]) + + assert gathered is not None, ( + f"{name}: {spec['attribute']} is None after a gather failure, " + "which makes every check that iterates it raise TypeError" + ) + assert gathered == [], f"{name}: expected an empty list" + + def test_checks_do_not_crash_when_gather_fails(self): + """Checks must degrade to zero findings, not raise TypeError.""" + for name, spec in GATHERERS.items(): + service = build_service(spec, failing_core_client(spec)) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch(f"{spec['check']}.{spec['client_attr']}", new=service), + ): + check_module = importlib.import_module(spec["check"]) + check = getattr(check_module, spec["check_class"])() + findings = check.execute() + + assert findings == [], f"{name}: expected no findings after a failed gather" + + +class Test_kubelet_config_map_parsing: + """A broken `kubelet-config*` ConfigMap must not hide the valid ones.""" + + def _kubelet_config_maps(self, config_maps): + spec = GATHERERS["kubelet"] + core_client = mock.MagicMock() + core_client.config_maps = config_maps + return build_service(spec, core_client).kubelet_config_maps + + def test_malformed_yaml_config_map_is_skipped(self): + bad = ConfigMap( + name="kubelet-config-bad", + uid="bad-uid", + namespace="kube-system", + data={"kubelet": "authentication: [unclosed"}, + ) + good = make_config_map("kubelet-config") + + # The broken ConfigMap is listed first, so an early return would lose + # the valid one that follows it. + gathered = self._kubelet_config_maps({"bad": bad, "good": good}) + + assert [cm.name for cm in gathered] == ["kubelet-config"] + assert gathered[0].kubelet_args["authentication"]["anonymous"] == { + "enabled": False + } + + def test_config_map_without_data_is_kept_with_empty_args(self): + empty = ConfigMap( + name="kubelet-config-empty", uid="empty-uid", namespace="kube-system" + ) + good = make_config_map("kubelet-config") + + gathered = self._kubelet_config_maps({"empty": empty, "good": good}) + + assert [cm.name for cm in gathered] == [ + "kubelet-config-empty", + "kubelet-config", + ] + # Checks use both `key not in cm.kubelet_args` and `cm.kubelet_args.get`, + # so an empty configuration must be a dict, not a list. + assert gathered[0].kubelet_args == {} 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_domain_dmarc_records_published/defender_domain_dmarc_records_published_test.py b/tests/providers/m365/services/defender/defender_domain_dmarc_records_published/defender_domain_dmarc_records_published_test.py new file mode 100644 index 0000000000..b1e6ea09f9 --- /dev/null +++ b/tests/providers/m365/services/defender/defender_domain_dmarc_records_published/defender_domain_dmarc_records_published_test.py @@ -0,0 +1,452 @@ +from unittest import mock + +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + + +class Test_defender_domain_dmarc_records_published: + def test_no_domains(self): + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_client.audited_domain = DOMAIN + defender_client.domain_dmarc_configurations = {} + defender_client.domain_discovery_failed = False + + 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_domain_dmarc_records_published.defender_domain_dmarc_records_published.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_domain_dmarc_records_published.defender_domain_dmarc_records_published import ( + defender_domain_dmarc_records_published, + ) + + check = defender_domain_dmarc_records_published() + result = check.execute() + assert len(result) == 0 + + def test_domain_discovery_failed(self): + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_client.audited_domain = DOMAIN + defender_client.tenant_domain = DOMAIN + defender_client.domain_dmarc_configurations = {} + defender_client.domain_discovery_failed = True + + 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_domain_dmarc_records_published.defender_domain_dmarc_records_published.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_domain_dmarc_records_published.defender_domain_dmarc_records_published import ( + defender_domain_dmarc_records_published, + ) + + check = defender_domain_dmarc_records_published() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == "DMARC records could not be verified because the Exchange Online domain list could not be retrieved; manual review is required." + ) + assert result[0].resource_name == DOMAIN + assert result[0].resource_id == DOMAIN + + def test_domain_dmarc_invalid_version(self): + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_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.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_domain_dmarc_records_published.defender_domain_dmarc_records_published.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_domain_dmarc_records_published.defender_domain_dmarc_records_published import ( + defender_domain_dmarc_records_published, + ) + from prowler.providers.m365.services.defender.defender_service import ( + DomainDmarcConfiguration, + ) + + domain_id = "domain8" + + # "v=DMARC10" must not be accepted as a valid "v=DMARC1" record. + defender_client.domain_dmarc_configurations = { + domain_id: DomainDmarcConfiguration( + domain=domain_id, + dmarc_record="v=DMARC10; p=reject", + ) + } + + check = defender_domain_dmarc_records_published() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"DMARC record for domain with ID {domain_id} is malformed and does not include a valid enforcement policy." + ) + + def test_domain_dmarc_reject(self): + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_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.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_domain_dmarc_records_published.defender_domain_dmarc_records_published.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_domain_dmarc_records_published.defender_domain_dmarc_records_published import ( + defender_domain_dmarc_records_published, + ) + from prowler.providers.m365.services.defender.defender_service import ( + DomainDmarcConfiguration, + ) + + domain_id = "domain1" + + defender_client.domain_dmarc_configurations = { + domain_id: DomainDmarcConfiguration( + domain=domain_id, + dmarc_record="v=DMARC1; p=reject; rua=mailto:dmarc@domain1", + ) + } + + check = defender_domain_dmarc_records_published() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"DMARC record is published on Exchange Online for domain with ID {domain_id} with enforcement policy p=reject." + ) + assert result[0].resource == {} + assert result[0].resource_name == domain_id + assert result[0].resource_id == domain_id + assert result[0].location == "global" + + def test_domain_dmarc_quarantine(self): + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_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.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_domain_dmarc_records_published.defender_domain_dmarc_records_published.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_domain_dmarc_records_published.defender_domain_dmarc_records_published import ( + defender_domain_dmarc_records_published, + ) + from prowler.providers.m365.services.defender.defender_service import ( + DomainDmarcConfiguration, + ) + + domain_id = "domain2" + + defender_client.domain_dmarc_configurations = { + domain_id: DomainDmarcConfiguration( + domain=domain_id, + dmarc_record="v=DMARC1;p=quarantine;pct=100", + ) + } + + check = defender_domain_dmarc_records_published() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"DMARC record is published on Exchange Online for domain with ID {domain_id} with enforcement policy p=quarantine." + ) + assert result[0].resource == {} + assert result[0].resource_name == domain_id + assert result[0].resource_id == domain_id + assert result[0].location == "global" + + def test_domain_dmarc_none_policy(self): + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_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.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_domain_dmarc_records_published.defender_domain_dmarc_records_published.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_domain_dmarc_records_published.defender_domain_dmarc_records_published import ( + defender_domain_dmarc_records_published, + ) + from prowler.providers.m365.services.defender.defender_service import ( + DomainDmarcConfiguration, + ) + + domain_id = "domain3" + + defender_client.domain_dmarc_configurations = { + domain_id: DomainDmarcConfiguration( + domain=domain_id, + dmarc_record="v=DMARC1; p=none", + ) + } + + check = defender_domain_dmarc_records_published() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"DMARC record is published on Exchange Online for domain with ID {domain_id} but uses monitoring-only policy p=none." + ) + assert result[0].resource == {} + assert result[0].resource_name == domain_id + assert result[0].resource_id == domain_id + assert result[0].location == "global" + + def test_domain_dmarc_missing(self): + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_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.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_domain_dmarc_records_published.defender_domain_dmarc_records_published.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_domain_dmarc_records_published.defender_domain_dmarc_records_published import ( + defender_domain_dmarc_records_published, + ) + from prowler.providers.m365.services.defender.defender_service import ( + DomainDmarcConfiguration, + ) + + domain_id = "domain4" + + defender_client.domain_dmarc_configurations = { + domain_id: DomainDmarcConfiguration( + domain=domain_id, + dmarc_record=None, + ) + } + + check = defender_domain_dmarc_records_published() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"DMARC record is not published on Exchange Online for domain with ID {domain_id}." + ) + assert result[0].resource == {} + assert result[0].resource_name == domain_id + assert result[0].resource_id == domain_id + assert result[0].location == "global" + + def test_domain_dmarc_malformed(self): + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_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.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_domain_dmarc_records_published.defender_domain_dmarc_records_published.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_domain_dmarc_records_published.defender_domain_dmarc_records_published import ( + defender_domain_dmarc_records_published, + ) + from prowler.providers.m365.services.defender.defender_service import ( + DomainDmarcConfiguration, + ) + + domain_id = "domain5" + + defender_client.domain_dmarc_configurations = { + domain_id: DomainDmarcConfiguration( + domain=domain_id, + dmarc_record="this is not a dmarc record", + ) + } + + check = defender_domain_dmarc_records_published() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"DMARC record for domain with ID {domain_id} is malformed and does not include a valid enforcement policy." + ) + assert result[0].resource == {} + assert result[0].resource_name == domain_id + assert result[0].resource_id == domain_id + assert result[0].location == "global" + + def test_domain_dmarc_lookup_failed(self): + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_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.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_domain_dmarc_records_published.defender_domain_dmarc_records_published.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_domain_dmarc_records_published.defender_domain_dmarc_records_published import ( + defender_domain_dmarc_records_published, + ) + from prowler.providers.m365.services.defender.defender_service import ( + DomainDmarcConfiguration, + ) + + domain_id = "domain7" + + defender_client.domain_dmarc_configurations = { + domain_id: DomainDmarcConfiguration( + domain=domain_id, + dmarc_record=None, + lookup_failed=True, + ) + } + + check = defender_domain_dmarc_records_published() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == f"DMARC record for domain with ID {domain_id} could not be verified because the DNS lookup did not complete; manual review is required." + ) + assert result[0].resource == {} + assert result[0].resource_name == domain_id + assert result[0].resource_id == domain_id + assert result[0].location == "global" + + def test_domain_dmarc_no_policy_tag(self): + defender_client = mock.MagicMock() + defender_client.audited_tenant = "audited_tenant" + defender_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.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.defender.defender_domain_dmarc_records_published.defender_domain_dmarc_records_published.defender_client", + new=defender_client, + ), + ): + from prowler.providers.m365.services.defender.defender_domain_dmarc_records_published.defender_domain_dmarc_records_published import ( + defender_domain_dmarc_records_published, + ) + from prowler.providers.m365.services.defender.defender_service import ( + DomainDmarcConfiguration, + ) + + domain_id = "domain6" + + defender_client.domain_dmarc_configurations = { + domain_id: DomainDmarcConfiguration( + domain=domain_id, + dmarc_record="v=DMARC1; rua=mailto:dmarc@domain6", + ) + } + + check = defender_domain_dmarc_records_published() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"DMARC record for domain with ID {domain_id} is malformed and does not include a valid enforcement policy." + ) + assert result[0].resource == {} + assert result[0].resource_name == domain_id + assert result[0].resource_id == domain_id + assert result[0].location == "global" 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/defender/m365_defender_service_test.py b/tests/providers/m365/services/defender/m365_defender_service_test.py index 8134b15f59..4006488cf2 100644 --- a/tests/providers/m365/services/defender/m365_defender_service_test.py +++ b/tests/providers/m365/services/defender/m365_defender_service_test.py @@ -1,5 +1,7 @@ +import asyncio +from types import SimpleNamespace from unittest import mock -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock, patch from prowler.providers.m365.models import M365IdentityInfo from prowler.providers.m365.services.defender.defender_service import ( @@ -9,6 +11,7 @@ from prowler.providers.m365.services.defender.defender_service import ( Defender, DefenderInboundSpamPolicy, DkimConfig, + DomainDmarcConfiguration, InboundSpamRule, MalwarePolicy, MalwareRule, @@ -214,6 +217,19 @@ def mock_defender_get_outbound_spam_filter_rule(_): } +async def mock_defender_get_domain_dmarc_configurations(_): + return { + "domain1.com": DomainDmarcConfiguration( + domain="domain1.com", + dmarc_record="v=DMARC1; p=reject", + ), + "domain2.com": DomainDmarcConfiguration( + domain="domain2.com", + dmarc_record=None, + ), + } + + class Test_Defender_Service: def test_get_client(self): with ( @@ -555,6 +571,73 @@ class Test_Defender_Service: assert report_submission_policy.report_phish_addresses == [] assert report_submission_policy.report_chat_message_enabled is True + @patch( + "prowler.providers.m365.services.defender.defender_service.Defender._get_domain_dmarc_configurations", + new=mock_defender_get_domain_dmarc_configurations, + ) + def test_get_domain_dmarc_configurations(self): + with ( + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + ): + defender_client = Defender( + set_mocked_m365_provider( + identity=M365IdentityInfo(tenant_domain=DOMAIN) + ) + ) + domain_dmarc_configurations = defender_client.domain_dmarc_configurations + assert len(domain_dmarc_configurations) == 2 + assert ( + domain_dmarc_configurations["domain1.com"].dmarc_record + == "v=DMARC1; p=reject" + ) + assert domain_dmarc_configurations["domain2.com"].dmarc_record is None + defender_client.powershell.close() + + def test_get_dmarc_txt_record_found(self): + class FakeAnswer: + def __init__(self, strings): + self.strings = strings + + with mock.patch( + "prowler.providers.m365.services.defender.defender_service.dns.asyncresolver.resolve", + new=AsyncMock(return_value=[FakeAnswer([b"v=DMARC1; p=reject"])]), + ): + record, lookup_failed = asyncio.run( + Defender._get_dmarc_txt_record("domain1.com") + ) + assert record == "v=DMARC1; p=reject" + assert lookup_failed is False + + def test_get_dmarc_txt_record_not_found(self): + import dns.resolver + + with mock.patch( + "prowler.providers.m365.services.defender.defender_service.dns.asyncresolver.resolve", + new=AsyncMock(side_effect=dns.resolver.NXDOMAIN), + ): + record, lookup_failed = asyncio.run( + Defender._get_dmarc_txt_record("domain2.com") + ) + assert record is None + # A confirmed absence, not an unverifiable lookup. + assert lookup_failed is False + + def test_get_dmarc_txt_record_lookup_failed(self): + import dns.exception + + with mock.patch( + "prowler.providers.m365.services.defender.defender_service.dns.asyncresolver.resolve", + new=AsyncMock(side_effect=dns.exception.Timeout), + ): + record, lookup_failed = asyncio.run( + Defender._get_dmarc_txt_record("domain3.com") + ) + assert record is None + # A transient failure leaves the DMARC status unknown. + assert lookup_failed is True + @patch( "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.get_eop_protection_policy_rule", return_value=[ @@ -651,3 +734,69 @@ class Test_Defender_Service: email_tenant_settings = defender_client.email_tenant_settings assert email_tenant_settings.priority_account_protection_enabled is True defender_client.powershell.close() + + +def test_defender__get_domain_dmarc_configurations_handles_pagination(): + defender_service = Defender.__new__(Defender) + + domains_page_one = [ + SimpleNamespace(id="domain1.com", is_verified=True), + SimpleNamespace(id="unverified.com", is_verified=False), + ] + domains_page_two = [ + SimpleNamespace(id="domain2.com", is_verified=True), + ] + + domains_response_page_one = SimpleNamespace( + value=domains_page_one, + odata_next_link="next-link", + ) + domains_response_page_two = SimpleNamespace( + value=domains_page_two, odata_next_link=None + ) + + domains_with_url_builder = SimpleNamespace( + get=AsyncMock(return_value=domains_response_page_two) + ) + with_url_mock = MagicMock(return_value=domains_with_url_builder) + + domains_builder = SimpleNamespace( + get=AsyncMock(return_value=domains_response_page_one), + with_url=with_url_mock, + ) + + defender_service.client = SimpleNamespace(domains=domains_builder) + + with mock.patch( + "prowler.providers.m365.services.defender.defender_service.Defender._get_dmarc_txt_record", + new=AsyncMock(return_value=("v=DMARC1; p=reject", False)), + ): + domain_dmarc_configurations = asyncio.run( + defender_service._get_domain_dmarc_configurations() + ) + + assert set(domain_dmarc_configurations) == {"domain1.com", "domain2.com"} + assert ( + domain_dmarc_configurations["domain1.com"].dmarc_record == "v=DMARC1; p=reject" + ) + assert domains_builder.get.await_count == 1 + assert domains_with_url_builder.get.await_count == 1 + with_url_mock.assert_called_once_with("next-link") + + +def test_defender__get_domain_dmarc_configurations_marks_discovery_failure(): + defender_service = Defender.__new__(Defender) + defender_service.domain_discovery_failed = False + + domains_builder = SimpleNamespace( + get=AsyncMock(side_effect=Exception("Graph domains request failed")) + ) + defender_service.client = SimpleNamespace(domains=domains_builder) + + domain_dmarc_configurations = asyncio.run( + defender_service._get_domain_dmarc_configurations() + ) + + # An empty result caused by a discovery failure must be distinguishable. + assert domain_dmarc_configurations == {} + assert defender_service.domain_discovery_failed is True 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_app_registration_redirect_uris_secure/entra_app_registration_redirect_uris_secure_test.py b/tests/providers/m365/services/entra/entra_app_registration_redirect_uris_secure/entra_app_registration_redirect_uris_secure_test.py new file mode 100644 index 0000000000..7663165af1 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_app_registration_redirect_uris_secure/entra_app_registration_redirect_uris_secure_test.py @@ -0,0 +1,783 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.m365.services.entra.entra_service import AppRegistration +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + + +class Test_entra_app_registration_redirect_uris_secure: + """Tests for the entra_app_registration_redirect_uris_secure check.""" + + def test_no_app_registrations(self): + """No app registrations in tenant: no findings.""" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = {} + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 0 + + def test_app_no_redirect_uris(self): + """App with no redirect URIs configured: expected PASS.""" + app_id = str(uuid4()) + app_name = "Clean App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "no redirect URIs configured" in result[0].status_extended + assert result[0].resource_id == app_id + assert result[0].resource_name == app_name + + def test_app_with_all_secure_uris(self): + """App with only secure redirect URIs: expected PASS.""" + app_id = str(uuid4()) + app_name = "Secure App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + web_redirect_uris=[ + "https://portal.contoso.com/signin-oidc", + "https://app.contoso.com/callback", + ], + spa_redirect_uris=["http://localhost:3000"], + public_client_redirect_uris=[ + "msal11111111-1111-1111-1111-111111111111://auth" + ], + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "all redirect URIs secure" in result[0].status_extended + assert result[0].resource_id == app_id + assert result[0].resource_name == app_name + + def test_app_with_http_non_localhost_uri(self): + """App with an http:// URI on a non-localhost host: expected FAIL.""" + app_id = str(uuid4()) + app_name = "Http App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + web_redirect_uris=[ + "http://portal.contoso.com/signin-oidc", + ], + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "uses http://" in result[0].status_extended + assert "portal.contoso.com" in result[0].status_extended + assert result[0].resource_id == app_id + assert result[0].resource_name == app_name + + def test_app_with_http_localhost_allowed(self): + """App with http://localhost: expected PASS (loopback is allowed).""" + app_id = str(uuid4()) + app_name = "Localhost App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + spa_redirect_uris=[ + "http://localhost:3000", + "http://127.0.0.1:8080", + "http://[::1]:5000", + ], + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == app_id + + def test_app_with_azurewebsites_uri(self): + """App with a host on *.azurewebsites.net: expected FAIL.""" + app_id = str(uuid4()) + app_name = "Azure Web App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + web_redirect_uris=[ + "https://legacy-portal.azurewebsites.net/signin-oidc", + ], + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "azurewebsites.net" in result[0].status_extended + assert result[0].resource_id == app_id + assert result[0].resource_name == app_name + + def test_app_with_bare_azurewebsites_domain(self): + """App with host exactly azurewebsites.net (no subdomain): expected FAIL.""" + app_id = str(uuid4()) + app_name = "Bare Domain App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + web_redirect_uris=[ + "https://azurewebsites.net/callback", + ], + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "azurewebsites.net" in result[0].status_extended + + def test_app_with_wildcard_uri(self): + """App with a wildcard in the redirect URI: expected FAIL.""" + app_id = str(uuid4()) + app_name = "Wildcard App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + web_redirect_uris=[ + "https://*.contoso.com/callback", + ], + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "wildcard" in result[0].status_extended + assert result[0].resource_id == app_id + + def test_app_with_malformed_uri_no_scheme(self): + """App with a malformed URI (no scheme): expected FAIL.""" + app_id = str(uuid4()) + app_name = "Malformed App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + web_redirect_uris=[ + "just-a-path/callback", + ], + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "malformed URI" in result[0].status_extended + assert result[0].resource_id == app_id + assert result[0].resource_name == app_name + + def test_app_with_malformed_uri_no_host(self): + """App with a malformed URI (scheme but no host): expected FAIL.""" + app_id = str(uuid4()) + app_name = "No Host App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + web_redirect_uris=[ + "https:///callback", + ], + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "malformed URI" in result[0].status_extended + + def test_app_with_custom_scheme_public_client(self): + """App with custom scheme public-client URIs: expected PASS.""" + app_id = str(uuid4()) + app_name = "Native App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + public_client_redirect_uris=[ + "msal11111111-1111-1111-1111-111111111111://auth", + "ms-appx-web://Microsoft.AAD.BrokerPlugin/1234", + "brk-multihub://contoso", + "urn:ietf:wg:oauth:2.0:oob", + "com.company.app://callback", + "msauth.com.contoso.app://auth", + ], + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == app_id + assert result[0].resource_name == app_name + + def test_app_with_multiple_insecure_uris(self): + """App with multiple insecure redirect URIs: expected FAIL listing all.""" + app_id = str(uuid4()) + app_name = "Legacy Portal" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + web_redirect_uris=[ + "http://portal.contoso.com/signin-oidc", + "https://legacy-portal.azurewebsites.net/signin-oidc", + "https://*.contoso.com/callback", + ], + spa_redirect_uris=["http://localhost:3000"], + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "3 insecure redirect URI(s)" in result[0].status_extended + assert result[0].resource_name == app_name + + def test_multiple_apps_mixed_results(self): + """Multiple apps: one secure, one insecure.""" + app_id_pass = str(uuid4()) + app_name_pass = "Secure App" + app_id_fail = str(uuid4()) + app_name_fail = "Insecure App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id_pass: AppRegistration( + id=app_id_pass, + app_id=str(uuid4()), + name=app_name_pass, + web_redirect_uris=["https://app.contoso.com/callback"], + ), + app_id_fail: AppRegistration( + id=app_id_fail, + app_id=str(uuid4()), + name=app_name_fail, + web_redirect_uris=[ + "http://app.insecure.com/callback", + ], + ), + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 2 + result_pass = next(r for r in result if r.resource_id == app_id_pass) + result_fail = next(r for r in result if r.resource_id == app_id_fail) + assert result_pass.status == "PASS" + assert result_pass.resource_name == app_name_pass + assert result_fail.status == "FAIL" + assert result_fail.resource_name == app_name_fail + + def test_app_with_more_than_five_insecure_uris_truncated(self): + """App with more than 5 insecure URIs: status_extended is truncated.""" + app_id = str(uuid4()) + app_name = "Many URIs App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + insecure_uris = [f"http://host{i}.example.com/callback" for i in range(7)] + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + web_redirect_uris=insecure_uris, + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "7 insecure redirect URI(s)" in result[0].status_extended + assert "(and 2 more)" in result[0].status_extended + + def test_app_with_exactly_five_insecure_uris_no_truncation(self): + """App with exactly 5 insecure URIs: all displayed, no truncation.""" + app_id = str(uuid4()) + app_name = "Five URIs App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + insecure_uris = [f"http://host{i}.example.com/callback" for i in range(5)] + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + web_redirect_uris=insecure_uris, + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "5 insecure redirect URI(s)" in result[0].status_extended + assert "(and" not in result[0].status_extended + + def test_app_with_wildcard_in_path(self): + """App with a wildcard in the path (not host): expected FAIL.""" + app_id = str(uuid4()) + app_name = "Wildcard Path App" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + web_redirect_uris=[ + "https://contoso.com/*/callback", + ], + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "wildcard" in result[0].status_extended + + def test_app_with_insecure_spa_redirect_uri(self): + """App with insecure SPA redirect URI: expected FAIL.""" + app_id = str(uuid4()) + app_name = "Insecure SPA" + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=str(uuid4()), + name=app_name, + spa_redirect_uris=[ + "http://app.contoso.com:4200", + ], + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "uses http://" in result[0].status_extended + + def test_app_resource_name_falls_back_to_app_id(self): + """App with no display name: resource_name falls back to app_id.""" + app_id = str(uuid4()) + app_app_id = str(uuid4()) + entra_client = mock.MagicMock() + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + entra_app_registration_redirect_uris_secure, + ) + + entra_client.app_registrations = { + app_id: AppRegistration( + id=app_id, + app_id=app_app_id, + name="", + ) + } + + check = entra_app_registration_redirect_uris_secure() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_name == app_app_id + assert f"App registration {app_app_id} has" in result[0].status_extended + + def test_check_uri_edge_cases(self): + """Edge cases for the redirect URI evaluation helper.""" + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ): + from prowler.providers.m365.services.entra.entra_app_registration_redirect_uris_secure.entra_app_registration_redirect_uris_secure import ( + _check_uri, + ) + + # Loopback hosts are allowed over plain http. + assert _check_uri("http://localhost:3000/callback") is None + assert _check_uri("http://127.0.0.1:8080/callback") is None + assert _check_uri("http://[::1]:8080/callback") is None + # Wildcards are flagged even in native schemes. + assert _check_uri("com.contoso.app://*") == "contains wildcard" + # Prefixes only exempt the scheme, not any string that starts with them. + assert _check_uri("msalfoo.example.com/callback") == "malformed URI" + assert _check_uri("msal11111111-1111-1111-1111-111111111111://auth") is None + assert _check_uri("urn:ietf:wg:oauth:2.0:oob") is None + # Invalid ports make the URI malformed. + assert ( + _check_uri("https://example.com:notaport/callback") == "malformed URI" + ) + assert _check_uri("https://example.com:99999/callback") == "malformed URI" + # azurewebsites.net explains when the takeover risk applies. + reason = _check_uri("https://contoso.azurewebsites.net/signin-oidc") + assert "subdomain takeover risk if the App Service is removed" in reason diff --git a/tests/providers/m365/services/entra/entra_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_cross_tenant_access_default_outbound_restricted/entra_cross_tenant_access_default_outbound_restricted_test.py b/tests/providers/m365/services/entra/entra_cross_tenant_access_default_outbound_restricted/entra_cross_tenant_access_default_outbound_restricted_test.py new file mode 100644 index 0000000000..26a6329daa --- /dev/null +++ b/tests/providers/m365/services/entra/entra_cross_tenant_access_default_outbound_restricted/entra_cross_tenant_access_default_outbound_restricted_test.py @@ -0,0 +1,376 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + CrossTenantAccessDefault, + CrossTenantB2BSetting, + CrossTenantTarget, + CrossTenantTargetConfiguration, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_cross_tenant_access_default_outbound_restricted.entra_cross_tenant_access_default_outbound_restricted" + + +def _all_users_all_apps_allowed(): + """Return a B2B setting that allows all users and all applications.""" + return CrossTenantB2BSetting( + users_and_groups=CrossTenantTargetConfiguration( + access_type="allowed", + targets=[CrossTenantTarget(target="AllUsers", target_type="user")], + ), + applications=CrossTenantTargetConfiguration( + access_type="allowed", + targets=[ + CrossTenantTarget(target="AllApplications", target_type="application") + ], + ), + ) + + +def _blocked_setting(): + """Return a B2B setting that blocks all users and all applications.""" + return CrossTenantB2BSetting( + users_and_groups=CrossTenantTargetConfiguration( + access_type="blocked", + targets=[CrossTenantTarget(target="AllUsers", target_type="user")], + ), + applications=CrossTenantTargetConfiguration( + access_type="blocked", + targets=[ + CrossTenantTarget(target="AllApplications", target_type="application") + ], + ), + ) + + +def _specific_groups_allowed(): + """Return a B2B setting that allows only specific groups and all apps.""" + return CrossTenantB2BSetting( + users_and_groups=CrossTenantTargetConfiguration( + access_type="allowed", + targets=[ + CrossTenantTarget( + target="00000000-0000-0000-0000-000000000001", + target_type="user", + ) + ], + ), + applications=CrossTenantTargetConfiguration( + access_type="allowed", + targets=[ + CrossTenantTarget(target="AllApplications", target_type="application") + ], + ), + ) + + +def _setting(users_access, users_targets, apps_access, apps_targets): + return CrossTenantB2BSetting( + users_and_groups=CrossTenantTargetConfiguration( + access_type=users_access, + targets=[ + CrossTenantTarget(target=t, target_type="user") for t in users_targets + ], + ), + applications=CrossTenantTargetConfiguration( + access_type=apps_access, + targets=[ + CrossTenantTarget(target=t, target_type="application") + for t in apps_targets + ], + ), + ) + + +class Test_entra_cross_tenant_access_default_outbound_restricted: + def _run(self, policy): + entra_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_cross_tenant_access_default_outbound_restricted.entra_cross_tenant_access_default_outbound_restricted import ( + entra_cross_tenant_access_default_outbound_restricted, + ) + + entra_client.cross_tenant_access_default = policy + return entra_cross_tenant_access_default_outbound_restricted().execute() + + def test_no_policy(self): + """MANUAL when the policy could not be retrieved.""" + result = self._run(None) + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "could not be read" in result[0].status_extended + + def test_both_blocked_pass(self): + """PASS when both outbound settings are blocked.""" + result = self._run( + CrossTenantAccessDefault( + b2b_collaboration_outbound=_blocked_setting(), + b2b_direct_connect_outbound=_blocked_setting(), + ) + ) + assert len(result) == 1 + assert result[0].status == "PASS" + assert "restricts outbound access" in result[0].status_extended + + def test_collaboration_outbound_unrestricted_fail(self): + """FAIL when B2B collaboration outbound allows all users and all apps.""" + result = self._run( + CrossTenantAccessDefault( + b2b_collaboration_outbound=_all_users_all_apps_allowed(), + b2b_direct_connect_outbound=_blocked_setting(), + ) + ) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "B2B collaboration outbound" in result[0].status_extended + assert "B2B direct connect outbound" not in result[0].status_extended + + def test_direct_connect_outbound_unrestricted_fail(self): + """FAIL when B2B direct connect outbound allows all users and all apps.""" + result = self._run( + CrossTenantAccessDefault( + b2b_collaboration_outbound=_blocked_setting(), + b2b_direct_connect_outbound=_all_users_all_apps_allowed(), + ) + ) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "B2B direct connect outbound" in result[0].status_extended + assert "B2B collaboration outbound" not in result[0].status_extended + + def test_both_outbound_unrestricted_fail(self): + """FAIL when both outbound settings allow all users and all apps.""" + result = self._run( + CrossTenantAccessDefault( + b2b_collaboration_outbound=_all_users_all_apps_allowed(), + b2b_direct_connect_outbound=_all_users_all_apps_allowed(), + ) + ) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "B2B collaboration outbound" in result[0].status_extended + assert "B2B direct connect outbound" in result[0].status_extended + + def test_specific_groups_allowed_pass(self): + """PASS when users are scoped to specific groups (not AllUsers).""" + result = self._run( + CrossTenantAccessDefault( + b2b_collaboration_outbound=_specific_groups_allowed(), + b2b_direct_connect_outbound=_blocked_setting(), + ) + ) + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_none_b2b_settings_manual(self): + """MANUAL when the outbound settings could not be read.""" + result = self._run( + CrossTenantAccessDefault( + b2b_collaboration_outbound=None, + b2b_direct_connect_outbound=None, + ) + ) + assert len(result) == 1 + assert result[0].status == "MANUAL" + + def test_incomplete_setting_with_blocked_setting_manual(self): + """MANUAL when no setting is open but one has no users and groups data.""" + result = self._run( + CrossTenantAccessDefault( + b2b_collaboration_outbound=CrossTenantB2BSetting( + users_and_groups=None, + applications=_blocked_setting().applications, + ), + b2b_direct_connect_outbound=_blocked_setting(), + ) + ) + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "incomplete" in result[0].status_extended + + def test_setting_without_targets_manual(self): + """MANUAL when an access configuration has no targets.""" + result = self._run( + CrossTenantAccessDefault( + b2b_collaboration_outbound=CrossTenantB2BSetting( + users_and_groups=CrossTenantTargetConfiguration( + access_type="allowed", targets=[] + ), + applications=_blocked_setting().applications, + ), + b2b_direct_connect_outbound=_blocked_setting(), + ) + ) + assert len(result) == 1 + assert result[0].status == "MANUAL" + + def test_open_setting_with_incomplete_setting_fail(self): + """FAIL takes precedence when one setting is open and the other incomplete.""" + result = self._run( + CrossTenantAccessDefault( + b2b_collaboration_outbound=_all_users_all_apps_allowed(), + b2b_direct_connect_outbound=CrossTenantB2BSetting( + users_and_groups=None, applications=None + ), + ) + ) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "B2B collaboration outbound" in result[0].status_extended + + def test_open_setting_with_missing_setting_fail(self): + """FAIL takes precedence when one setting is open and the other missing.""" + result = self._run( + CrossTenantAccessDefault( + b2b_collaboration_outbound=_all_users_all_apps_allowed(), + b2b_direct_connect_outbound=None, + ) + ) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "B2B collaboration outbound" in result[0].status_extended + + def test_users_allowed_all_but_apps_blocked_pass(self): + """PASS when users are allowed for all but applications are blocked.""" + result = self._run( + CrossTenantAccessDefault( + b2b_collaboration_outbound=CrossTenantB2BSetting( + users_and_groups=CrossTenantTargetConfiguration( + access_type="allowed", + targets=[ + CrossTenantTarget(target="AllUsers", target_type="user") + ], + ), + applications=CrossTenantTargetConfiguration( + access_type="blocked", + targets=[ + CrossTenantTarget( + target="AllApplications", + target_type="application", + ) + ], + ), + ), + b2b_direct_connect_outbound=_blocked_setting(), + ) + ) + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_apps_allowed_all_but_users_blocked_pass(self): + """PASS when applications are allowed for all but users are blocked.""" + result = self._run( + CrossTenantAccessDefault( + b2b_collaboration_outbound=CrossTenantB2BSetting( + users_and_groups=CrossTenantTargetConfiguration( + access_type="blocked", + targets=[ + CrossTenantTarget(target="AllUsers", target_type="user") + ], + ), + applications=CrossTenantTargetConfiguration( + access_type="allowed", + targets=[ + CrossTenantTarget( + target="AllApplications", + target_type="application", + ) + ], + ), + ), + b2b_direct_connect_outbound=_blocked_setting(), + ) + ) + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_resource_metadata(self): + """Verify resource_name and resource_id are set correctly.""" + result = self._run( + CrossTenantAccessDefault( + b2b_collaboration_outbound=_blocked_setting(), + b2b_direct_connect_outbound=_blocked_setting(), + ) + ) + assert len(result) == 1 + assert result[0].resource_name == "Cross-Tenant Access Default Policy" + assert result[0].resource_id == "crossTenantAccessPolicyConfigurationDefault" + + def test_blocking_one_group_with_all_apps_allowed_fails(self): + """Blocking a single group leaves every other user able to reach every app.""" + result = self._run( + CrossTenantAccessDefault( + b2b_collaboration_outbound=_setting( + "blocked", ["group-1"], "allowed", ["AllApplications"] + ), + b2b_direct_connect_outbound=_blocked_setting(), + ) + ) + assert result[0].status == "FAIL" + assert "B2B collaboration outbound" in result[0].status_extended + + def test_blocking_one_app_with_all_users_allowed_fails(self): + """Blocking a single application leaves every other application reachable.""" + result = self._run( + CrossTenantAccessDefault( + b2b_collaboration_outbound=_setting( + "allowed", ["AllUsers"], "blocked", ["app-1"] + ), + b2b_direct_connect_outbound=_blocked_setting(), + ) + ) + assert result[0].status == "FAIL" + + def test_all_microsoft_apps_counts_as_open(self): + """AllMicrosoftApps reaches every Microsoft cloud application.""" + result = self._run( + CrossTenantAccessDefault( + b2b_collaboration_outbound=_setting( + "allowed", ["AllUsers"], "allowed", ["AllMicrosoftApps"] + ), + b2b_direct_connect_outbound=_blocked_setting(), + ) + ) + assert result[0].status == "FAIL" + + def test_blocking_all_microsoft_apps_restricts_applications(self): + """Blocking AllMicrosoftApps closes the applications dimension.""" + result = self._run( + CrossTenantAccessDefault( + b2b_collaboration_outbound=_setting( + "blocked", ["group-1"], "blocked", ["AllMicrosoftApps"] + ), + b2b_direct_connect_outbound=_blocked_setting(), + ) + ) + assert result[0].status == "PASS" + + def test_users_limited_to_group_with_partial_app_block_passes(self): + """Users allowed only for a specific group restrict the setting.""" + result = self._run( + CrossTenantAccessDefault( + b2b_collaboration_outbound=_setting( + "allowed", ["group-1"], "blocked", ["app-1"] + ), + b2b_direct_connect_outbound=_blocked_setting(), + ) + ) + assert result[0].status == "PASS" + + def test_all_users_blocked_with_all_apps_allowed_passes(self): + """Blocking every user restricts the setting whatever the applications.""" + result = self._run( + CrossTenantAccessDefault( + b2b_collaboration_outbound=_setting( + "blocked", ["AllUsers"], "allowed", ["AllApplications"] + ), + b2b_direct_connect_outbound=_blocked_setting(), + ) + ) + assert result[0].status == "PASS" diff --git a/tests/providers/m365/services/entra/entra_dynamic_group_member_of_rule_not_used/entra_dynamic_group_member_of_rule_not_used_test.py b/tests/providers/m365/services/entra/entra_dynamic_group_member_of_rule_not_used/entra_dynamic_group_member_of_rule_not_used_test.py new file mode 100644 index 0000000000..de439bf599 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_dynamic_group_member_of_rule_not_used/entra_dynamic_group_member_of_rule_not_used_test.py @@ -0,0 +1,578 @@ +from datetime import date +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import Group +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + +CHECK_MODULE = ( + "prowler.providers.m365.services.entra." + "entra_dynamic_group_member_of_rule_not_used." + "entra_dynamic_group_member_of_rule_not_used" +) + + +class Test_entra_dynamic_group_member_of_rule_not_used: + """Tests for the entra_dynamic_group_member_of_rule_not_used check.""" + + def test_no_dynamic_groups(self): + """No dynamic groups exist — check returns no findings.""" + entra_client = mock.MagicMock() + entra_client.groups = [] + entra_client.groups_error = None + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 0 + + def test_only_assigned_groups(self): + """Assigned (non-dynamic) groups produce no findings.""" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-assigned", + name="Static Group", + groupTypes=["Unified"], + membershipRule=None, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 0 + + def test_groups_error_returns_manual(self): + """When groups could not be read, emit a single MANUAL finding.""" + entra_client = mock.MagicMock() + entra_client.groups_error = ( + "Unable to retrieve groups from Microsoft Graph (ODataError)" + ) + entra_client.groups = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].resource_id == "groups" + assert result[0].resource_name == "Dynamic groups" + assert "Cannot evaluate" in result[0].status_extended + assert result[0].status_extended.endswith(".") + + def test_dynamic_group_null_rule_returns_pass(self): + """A dynamic group with a null membership rule cannot use memberOf.""" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-null-rule", + name="Null Rule Group", + groupTypes=["DynamicMembership"], + membershipRule=None, + membership_rule_processing_state="On", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "has no membership rule" in result[0].status_extended + + def test_dynamic_group_empty_rule_returns_pass(self): + """A dynamic group with an empty membership rule cannot use memberOf.""" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-empty-rule", + name="Empty Rule Group", + groupTypes=["DynamicMembership"], + membershipRule="", + membership_rule_processing_state="On", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_pass_no_memberof_rule(self): + """A dynamic group with attribute-based rule passes.""" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-pass", + name="Guests", + groupTypes=["DynamicMembership"], + membershipRule='(user.userType -eq "Guest")', + membership_rule_processing_state="On", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "g-pass" + assert result[0].resource_name == "Guests" + assert "does not use the memberOf" in result[0].status_extended + assert result[0].status_extended.endswith(".") + + def test_fail_user_memberof_before_retirement(self): + """A dynamic group using user.memberof fails with pre-retirement wording.""" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-fail-user", + name="All Finance (memberOf)", + groupTypes=["DynamicMembership"], + membershipRule="user.memberof -any (group.objectId -in ['0282e19d-bf41-435d-92a4-99bab93af305','11111111-2222-3333-4444-555555555555'])", + membership_rule_processing_state="On", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + mock.patch( + f"{CHECK_MODULE}.date", + wraps=date, + ) as mock_date, + ): + mock_date.today.return_value = date(2026, 10, 1) + + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "g-fail-user" + assert "processing: On" in result[0].status_extended + assert "is being retired on November 3, 2026" in result[0].status_extended + assert "0282e19d-bf41-435d-92a4-99bab93af305" in result[0].status_extended + assert "11111111-2222-3333-4444-555555555555" in result[0].status_extended + assert result[0].status_extended.endswith(".") + + def test_fail_device_memberof_after_retirement(self): + """A dynamic group using device.memberof fails with post-retirement wording.""" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-fail-device", + name="Corp Devices", + groupTypes=["DynamicMembership"], + membershipRule="device.memberOf -any (group.objectId -in ['aabbccdd-1122-3344-5566-778899001122'])", + membership_rule_processing_state="Paused", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + mock.patch( + f"{CHECK_MODULE}.date", + wraps=date, + ) as mock_date, + ): + mock_date.today.return_value = date(2026, 11, 3) + + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "processing: Paused" in result[0].status_extended + assert "was retired on November 3, 2026" in result[0].status_extended + assert "aabbccdd-1122-3344-5566-778899001122" in result[0].status_extended + + def test_no_false_positive_memberof_in_quoted_string(self): + """memberOf inside a quoted literal must not trigger a FAIL.""" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-quoted", + name="Dept Group", + groupTypes=["DynamicMembership"], + membershipRule='user.department -eq "user.memberOf"', + membership_rule_processing_state="On", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_fail_memberof_after_backtick_escaped_quote(self): + """A backtick-escaped quote in an earlier literal must not hide memberOf.""" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-escaped", + name="Escaped Quote Group", + groupTypes=["DynamicMembership"], + membershipRule=( + 'user.department -eq "R`"D" -or user.memberof -any ' + "(group.objectId -in ['11111111-1111-1111-1111-111111111111']) " + '-or user.city -eq "Madrid"' + ), + membership_rule_processing_state="On", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_case_insensitive_detection(self): + """Detection is case-insensitive (User.MemberOf should FAIL).""" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-case", + name="Case Group", + groupTypes=["DynamicMembership"], + membershipRule="User.MemberOf -any (group.objectId -in ['aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'])", + membership_rule_processing_state="On", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + mock.patch( + f"{CHECK_MODULE}.date", + wraps=date, + ) as mock_date, + ): + mock_date.today.return_value = date(2026, 10, 1) + + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_multiline_rule_normalised(self): + """Rules with CR/LF are normalised before matching.""" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-multiline", + name="Multiline Group", + groupTypes=["DynamicMembership"], + membershipRule="user.memberof\r\n-any (group.objectId -in ['12345678-1234-1234-1234-123456789abc'])", + membership_rule_processing_state="On", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + mock.patch( + f"{CHECK_MODULE}.date", + wraps=date, + ) as mock_date, + ): + mock_date.today.return_value = date(2026, 10, 1) + + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "12345678-1234-1234-1234-123456789abc" in result[0].status_extended + + def test_mixed_groups_pass_and_fail(self): + """Multiple groups: one PASS, one FAIL, one assigned (skipped).""" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-assigned", + name="Static Group", + groupTypes=["Unified"], + membershipRule=None, + ), + Group( + id="g-good", + name="Good Dynamic", + groupTypes=["DynamicMembership"], + membershipRule='(user.department -eq "Engineering")', + membership_rule_processing_state="On", + ), + Group( + id="g-bad", + name="Bad Dynamic", + groupTypes=["DynamicMembership"], + membershipRule="user.memberof -any (group.objectId -in ['abcdef01-2345-6789-abcd-ef0123456789'])", + membership_rule_processing_state="On", + ), + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + mock.patch( + f"{CHECK_MODULE}.date", + wraps=date, + ) as mock_date, + ): + mock_date.today.return_value = date(2026, 10, 1) + + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + # Assigned group is skipped, so 2 findings + assert len(result) == 2 + statuses = {r.resource_id: r.status for r in result} + assert statuses["g-good"] == "PASS" + assert statuses["g-bad"] == "FAIL" + + def test_fail_unknown_processing_state(self): + """When processing state is None, it shows as 'Unknown'.""" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-unknown-state", + name="Unknown State Group", + groupTypes=["DynamicMembership"], + membershipRule="user.memberof -any (group.objectId -in ['aabbccdd-0000-1111-2222-333344445555'])", + membership_rule_processing_state=None, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + mock.patch( + f"{CHECK_MODULE}.date", + wraps=date, + ) as mock_date, + ): + mock_date.today.return_value = date(2026, 10, 1) + + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "processing: Unknown" in result[0].status_extended + + def test_duplicate_guids_deduplicated(self): + """Duplicate GUIDs in the rule are deduplicated in status_extended.""" + guid = "aabbccdd-1122-3344-5566-778899001122" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-dup-guid", + name="Dup GUID Group", + groupTypes=["DynamicMembership"], + membershipRule=f"user.memberof -any (group.objectId -in ['{guid}','{guid}'])", + membership_rule_processing_state="On", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + mock.patch( + f"{CHECK_MODULE}.date", + wraps=date, + ) as mock_date, + ): + mock_date.today.return_value = date(2026, 10, 1) + + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + # GUID should appear only once in the source group list + assert result[0].status_extended.count(guid) == 1 diff --git a/tests/providers/m365/services/entra/entra_policy_admin_self_service_password_reset_disabled/entra_policy_admin_self_service_password_reset_disabled_test.py b/tests/providers/m365/services/entra/entra_policy_admin_self_service_password_reset_disabled/entra_policy_admin_self_service_password_reset_disabled_test.py new file mode 100644 index 0000000000..9b007de452 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_policy_admin_self_service_password_reset_disabled/entra_policy_admin_self_service_password_reset_disabled_test.py @@ -0,0 +1,155 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + AuthorizationPolicy, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + + +class Test_entra_policy_admin_self_service_password_reset_disabled: + def test_no_auth_policy(self): + """When authorization_policy is None, the check returns a MANUAL finding.""" + entra_client = mock.MagicMock() + entra_client.authorization_policy = None + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_policy_admin_self_service_password_reset_disabled.entra_policy_admin_self_service_password_reset_disabled.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_policy_admin_self_service_password_reset_disabled.entra_policy_admin_self_service_password_reset_disabled import ( + entra_policy_admin_self_service_password_reset_disabled, + ) + + check = entra_policy_admin_self_service_password_reset_disabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "Cannot evaluate" in result[0].status_extended + assert "could not be retrieved" in result[0].status_extended + assert result[0].resource_name == "Authorization Policy" + assert result[0].resource_id == "authorizationPolicy" + + def test_auth_policy_sspr_none(self): + """When allowed_to_use_sspr is None, the check returns a MANUAL finding.""" + entra_client = mock.MagicMock() + entra_client.authorization_policy = AuthorizationPolicy( + id="authorizationPolicy", + name="Authorization Policy", + description="", + default_user_role_permissions=None, + guest_invite_settings=None, + guest_user_role_id=None, + allowed_to_use_sspr=None, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_policy_admin_self_service_password_reset_disabled.entra_policy_admin_self_service_password_reset_disabled.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_policy_admin_self_service_password_reset_disabled.entra_policy_admin_self_service_password_reset_disabled import ( + entra_policy_admin_self_service_password_reset_disabled, + ) + + check = entra_policy_admin_self_service_password_reset_disabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "Cannot evaluate" in result[0].status_extended + assert result[0].resource_name == "Authorization Policy" + assert result[0].resource_id == "authorizationPolicy" + + def test_sspr_enabled_fail(self): + """When allowed_to_use_sspr is True, the check returns FAIL.""" + entra_client = mock.MagicMock() + entra_client.authorization_policy = AuthorizationPolicy( + id="authorizationPolicy", + name="Authorization Policy", + description="", + default_user_role_permissions=None, + guest_invite_settings=None, + guest_user_role_id=None, + allowed_to_use_sspr=True, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_policy_admin_self_service_password_reset_disabled.entra_policy_admin_self_service_password_reset_disabled.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_policy_admin_self_service_password_reset_disabled.entra_policy_admin_self_service_password_reset_disabled import ( + entra_policy_admin_self_service_password_reset_disabled, + ) + + check = entra_policy_admin_self_service_password_reset_disabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Self-Service Password Reset (SSPR) is enabled for administrators " + "(allowedToUseSSPR is true in the authorization policy)." + ) + assert result[0].resource_id == "authorizationPolicy" + assert result[0].resource_name == "Authorization Policy" + assert result[0].location == "global" + + def test_sspr_disabled_pass(self): + """When allowed_to_use_sspr is False, the check returns PASS.""" + entra_client = mock.MagicMock() + entra_client.authorization_policy = AuthorizationPolicy( + id="authorizationPolicy", + name="Authorization Policy", + description="", + default_user_role_permissions=None, + guest_invite_settings=None, + guest_user_role_id=None, + allowed_to_use_sspr=False, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_policy_admin_self_service_password_reset_disabled.entra_policy_admin_self_service_password_reset_disabled.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_policy_admin_self_service_password_reset_disabled.entra_policy_admin_self_service_password_reset_disabled import ( + entra_policy_admin_self_service_password_reset_disabled, + ) + + check = entra_policy_admin_self_service_password_reset_disabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Self-Service Password Reset (SSPR) is disabled for administrators " + "(allowedToUseSSPR is false in the authorization policy)." + ) + assert result[0].resource_id == "authorizationPolicy" + assert result[0].resource_name == "Authorization Policy" + assert result[0].location == "global" diff --git a/tests/providers/m365/services/entra/entra_policy_msol_powershell_blocked/entra_policy_msol_powershell_blocked_test.py b/tests/providers/m365/services/entra/entra_policy_msol_powershell_blocked/entra_policy_msol_powershell_blocked_test.py new file mode 100644 index 0000000000..d6be165b04 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_policy_msol_powershell_blocked/entra_policy_msol_powershell_blocked_test.py @@ -0,0 +1,147 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + AuthorizationPolicy, + DefaultUserRolePermissions, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + + +class Test_entra_policy_msol_powershell_blocked: + """Tests for the entra_policy_msol_powershell_blocked check.""" + + def test_authorization_policy_none(self): + """MANUAL when the authorization policy could not be retrieved.""" + entra_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_policy_msol_powershell_blocked.entra_policy_msol_powershell_blocked.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_policy_msol_powershell_blocked.entra_policy_msol_powershell_blocked import ( + entra_policy_msol_powershell_blocked, + ) + + entra_client.authorization_policy = None + + result = entra_policy_msol_powershell_blocked().execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].resource_id == "authorizationPolicy" + assert result[0].resource_name == "Authorization Policy" + assert "could not be retrieved" in result[0].status_extended + + def test_block_msol_powershell_none(self): + """MANUAL when blockMsolPowerShell is missing/null in the response.""" + entra_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_policy_msol_powershell_blocked.entra_policy_msol_powershell_blocked.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_policy_msol_powershell_blocked.entra_policy_msol_powershell_blocked import ( + entra_policy_msol_powershell_blocked, + ) + + entra_client.authorization_policy = AuthorizationPolicy( + id="authorizationPolicy", + name="Authorization Policy", + description="", + default_user_role_permissions=DefaultUserRolePermissions(), + block_msol_powershell=None, + ) + + result = entra_policy_msol_powershell_blocked().execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].resource_id == "authorizationPolicy" + assert result[0].resource_name == "Authorization Policy" + assert "could not be determined" in result[0].status_extended + + def test_block_msol_powershell_true(self): + """PASS when blockMsolPowerShell is true.""" + entra_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_policy_msol_powershell_blocked.entra_policy_msol_powershell_blocked.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_policy_msol_powershell_blocked.entra_policy_msol_powershell_blocked import ( + entra_policy_msol_powershell_blocked, + ) + + entra_client.authorization_policy = AuthorizationPolicy( + id="authorizationPolicy", + name="Authorization Policy", + description="", + default_user_role_permissions=DefaultUserRolePermissions(), + block_msol_powershell=True, + ) + + result = entra_policy_msol_powershell_blocked().execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "authorizationPolicy" + assert result[0].resource_name == "Authorization Policy" + assert ( + result[0].status_extended + == "Legacy MSOnline (MSOL) PowerShell is blocked in the tenant authorization policy." + ) + + def test_block_msol_powershell_false(self): + """FAIL when blockMsolPowerShell is false.""" + entra_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_policy_msol_powershell_blocked.entra_policy_msol_powershell_blocked.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_policy_msol_powershell_blocked.entra_policy_msol_powershell_blocked import ( + entra_policy_msol_powershell_blocked, + ) + + entra_client.authorization_policy = AuthorizationPolicy( + id="authorizationPolicy", + name="Authorization Policy", + description="", + default_user_role_permissions=DefaultUserRolePermissions(), + block_msol_powershell=False, + ) + + result = entra_policy_msol_powershell_blocked().execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "authorizationPolicy" + assert result[0].resource_name == "Authorization Policy" + assert ( + result[0].status_extended + == "Legacy MSOnline (MSOL) PowerShell is not blocked in the tenant authorization policy." + ) diff --git a/tests/providers/m365/services/entra/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..febaee7e30 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,292 @@ 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 + + +class TestAuthorizationPolicyMsolMapping: + """``blockMsolPowerShell`` from the Graph SDK model reaches ``AuthorizationPolicy``.""" + + @pytest.mark.parametrize("value", [True, False, None]) + def test_block_msol_powershell_is_mapped(self, value): + from msgraph.generated.models.authorization_policy import ( + AuthorizationPolicy as GraphAuthorizationPolicy, + ) + + graph_policy = GraphAuthorizationPolicy( + id="authorizationPolicy", + display_name="Authorization Policy", + description="Used to manage authorization related settings across the company.", + block_msol_power_shell=value, + ) + service = Entra.__new__(Entra) + service.client = SimpleNamespace( + policies=SimpleNamespace( + authorization_policy=SimpleNamespace( + get=AsyncMock(return_value=graph_policy) + ) + ) + ) + + policy = asyncio.run(service._get_authorization_policy()) + + assert policy.block_msol_powershell is value + + +class TestAppRegistrationRedirectUris: + def test_redirect_uris_are_read_for_every_platform(self): + app = SimpleNamespace( + id="obj-1", + app_id="app-1", + display_name="Portal", + password_credentials=[], + key_credentials=[], + web=SimpleNamespace(redirect_uris=["https://portal.contoso.com/signin"]), + spa=SimpleNamespace(redirect_uris=["http://localhost:3000"]), + public_client=SimpleNamespace(redirect_uris=["msalapp-1://auth"]), + ) + service = Entra.__new__(Entra) + service.client = SimpleNamespace( + applications=SimpleNamespace( + get=AsyncMock( + return_value=SimpleNamespace(value=[app], odata_next_link=None) + ) + ) + ) + + apps = asyncio.run(service._get_app_registrations()) + + assert apps["obj-1"].web_redirect_uris == ["https://portal.contoso.com/signin"] + assert apps["obj-1"].spa_redirect_uris == ["http://localhost:3000"] + assert apps["obj-1"].public_client_redirect_uris == ["msalapp-1://auth"] + + def test_missing_platforms_give_empty_lists(self): + app = SimpleNamespace( + id="obj-2", + app_id="app-2", + display_name="Daemon", + password_credentials=[], + key_credentials=[], + web=None, + spa=None, + public_client=None, + ) + service = Entra.__new__(Entra) + service.client = SimpleNamespace( + applications=SimpleNamespace( + get=AsyncMock( + return_value=SimpleNamespace(value=[app], odata_next_link=None) + ) + ) + ) + + apps = asyncio.run(service._get_app_registrations()) + + assert apps["obj-2"].web_redirect_uris == [] + assert apps["obj-2"].spa_redirect_uris == [] + assert apps["obj-2"].public_client_redirect_uris == [] + + +class TestGetGroups: + @staticmethod + def _graph_group(group_id, rule=None, processing_state=None): + return SimpleNamespace( + id=group_id, + display_name=f"Group {group_id}", + group_types=["DynamicMembership"] if rule else None, + membership_rule=rule, + membership_rule_processing_state=processing_state, + is_assignable_to_role=None, + is_management_restricted=None, + ) + + def test_maps_dynamic_rule_fields_across_pages(self): + entra_service = Entra.__new__(Entra) + entra_service.groups_error = None + page_one = SimpleNamespace( + value=[ + self._graph_group( + "g-1", + rule="user.memberof -any (group.objectId -in ['x'])", + processing_state="Paused", + ) + ], + odata_next_link="next-link", + ) + page_two = SimpleNamespace( + value=[self._graph_group("g-2")], odata_next_link=None + ) + next_page_builder = SimpleNamespace(get=AsyncMock(return_value=page_two)) + with_url_mock = MagicMock(return_value=next_page_builder) + entra_service.client = SimpleNamespace( + groups=SimpleNamespace( + get=AsyncMock(return_value=page_one), with_url=with_url_mock + ) + ) + + groups = asyncio.run(entra_service._get_groups()) + + with_url_mock.assert_called_once_with("next-link") + assert entra_service.groups_error is None + assert [group.id for group in groups] == ["g-1", "g-2"] + assert groups[0].groupTypes == ["DynamicMembership"] + assert groups[0].membershipRule.startswith("user.memberof") + assert groups[0].membership_rule_processing_state == "Paused" + assert groups[1].groupTypes == [] + assert groups[1].membershipRule is None + assert groups[1].membership_rule_processing_state is None + assert groups[1].is_assignable_to_role is False + + def test_pagination_error_keeps_partial_groups_and_sets_error(self): + entra_service = Entra.__new__(Entra) + entra_service.groups_error = None + page_one = SimpleNamespace( + value=[self._graph_group("g-1")], odata_next_link="next-link" + ) + failing_builder = SimpleNamespace( + get=AsyncMock(side_effect=RuntimeError("throttled")) + ) + entra_service.client = SimpleNamespace( + groups=SimpleNamespace( + get=AsyncMock(return_value=page_one), + with_url=MagicMock(return_value=failing_builder), + ) + ) + + groups = asyncio.run(entra_service._get_groups()) + + assert [group.id for group in groups] == ["g-1"] + assert "pagination error: RuntimeError" in entra_service.groups_error + + def test_initial_error_returns_no_groups_and_sets_error(self): + entra_service = Entra.__new__(Entra) + entra_service.groups_error = None + entra_service.client = SimpleNamespace( + groups=SimpleNamespace( + get=AsyncMock(side_effect=PermissionError("forbidden")) + ) + ) + + groups = asyncio.run(entra_service._get_groups()) + + assert groups == [] + assert entra_service.groups_error == ( + "Unable to retrieve groups from Microsoft Graph (PermissionError)" + ) + + +class TestCrossTenantAccessDefault: + """The Graph SDK default policy is mapped to ``CrossTenantAccessDefault``.""" + + def test_maps_outbound_settings_from_the_sdk_model(self): + from msgraph.generated.models.cross_tenant_access_policy_b2_b_setting import ( + CrossTenantAccessPolicyB2BSetting, + ) + from msgraph.generated.models.cross_tenant_access_policy_configuration_default import ( + CrossTenantAccessPolicyConfigurationDefault, + ) + from msgraph.generated.models.cross_tenant_access_policy_target import ( + CrossTenantAccessPolicyTarget, + ) + from msgraph.generated.models.cross_tenant_access_policy_target_configuration import ( + CrossTenantAccessPolicyTargetConfiguration, + ) + from msgraph.generated.models.cross_tenant_access_policy_target_configuration_access_type import ( + CrossTenantAccessPolicyTargetConfigurationAccessType as AccessType, + ) + from msgraph.generated.models.cross_tenant_access_policy_target_type import ( + CrossTenantAccessPolicyTargetType as TargetType, + ) + + def config(access, target, target_type): + return CrossTenantAccessPolicyTargetConfiguration( + access_type=access, + targets=[ + CrossTenantAccessPolicyTarget( + target=target, target_type=target_type + ) + ], + ) + + graph_policy = CrossTenantAccessPolicyConfigurationDefault( + b2b_collaboration_outbound=CrossTenantAccessPolicyB2BSetting( + users_and_groups=config( + AccessType.Blocked, "group-1", TargetType.Group + ), + applications=config( + AccessType.Allowed, "AllApplications", TargetType.Application + ), + ), + b2b_direct_connect_outbound=None, + ) + service = Entra.__new__(Entra) + service.client = SimpleNamespace( + policies=SimpleNamespace( + cross_tenant_access_policy=SimpleNamespace( + default=SimpleNamespace(get=AsyncMock(return_value=graph_policy)) + ) + ) + ) + + policy = asyncio.run(service._get_cross_tenant_access_default()) + + users = policy.b2b_collaboration_outbound.users_and_groups + apps = policy.b2b_collaboration_outbound.applications + assert users.access_type == "blocked" + assert users.targets[0].target == "group-1" + assert users.targets[0].target_type == "group" + assert apps.access_type == "allowed" + assert apps.targets[0].target == "AllApplications" + assert policy.b2b_direct_connect_outbound is None + + def test_read_error_returns_none(self): + service = Entra.__new__(Entra) + service.client = SimpleNamespace( + policies=SimpleNamespace( + cross_tenant_access_policy=SimpleNamespace( + default=SimpleNamespace( + get=AsyncMock(side_effect=RuntimeError("403")) + ) + ) + ) + ) + + assert asyncio.run(service._get_cross_tenant_access_default()) is None diff --git a/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/tests/providers/oraclecloud/oraclecloud_provider_test.py b/tests/providers/oraclecloud/oraclecloud_provider_test.py index 7deb649d8e..dce02eeb78 100644 --- a/tests/providers/oraclecloud/oraclecloud_provider_test.py +++ b/tests/providers/oraclecloud/oraclecloud_provider_test.py @@ -543,6 +543,58 @@ class TestOraclecloudProviderInit: assert mock_get_regions_to_audit.call_args_list[0].args == (None,) assert provider.regions == all_subscribed_regions + def test_init_with_home_region_bootstraps_there_without_scan_filter(self): + mock_session = OCISession( + config={"region": "me-abudhabi-1"}, signer=None, profile=None + ) + mock_identity = OCIIdentityInfo( + tenancy_id="ocid1.tenancy.oc1..aaaaaaaexample", + tenancy_name="test-tenancy", + user_id="ocid1.user.oc1..aaaaaaaexample", + region="me-abudhabi-1", + profile=None, + audited_regions=set(), + audited_compartments=[], + ) + all_subscribed_regions = [ + OCIRegion(key="me-abudhabi-1", name="me-abudhabi-1", is_home_region=True), + OCIRegion(key="me-dubai-1", name="me-dubai-1", is_home_region=False), + ] + + with ( + patch( + "prowler.providers.oraclecloud.oraclecloud_provider.OraclecloudProvider.setup_session", + return_value=mock_session, + ) as mock_setup_session, + patch( + "prowler.providers.oraclecloud.oraclecloud_provider.OraclecloudProvider.set_identity", + return_value=mock_identity, + ), + patch( + "prowler.providers.oraclecloud.oraclecloud_provider.OraclecloudProvider.get_regions_to_audit", + return_value=all_subscribed_regions, + ) as mock_get_regions_to_audit, + patch( + "prowler.providers.oraclecloud.oraclecloud_provider.OraclecloudProvider.get_compartments_to_audit", + return_value=["ocid1.compartment.oc1..aaaaaaaexample"], + ), + patch("prowler.providers.common.provider.Provider.set_global_provider"), + ): + provider = OraclecloudProvider( + user="ocid1.user.oc1..aaaaaaaexample", + fingerprint="aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99", + key_content="fake-base64-key-content", + tenancy="ocid1.tenancy.oc1..aaaaaaaexample", + home_region="me-abudhabi-1", + config_content={"dummy": True}, + mutelist_content={"Accounts": {}}, + ) + + assert mock_setup_session.call_args.kwargs["region"] == "me-abudhabi-1" + assert mock_get_regions_to_audit.call_args_list[0].args == (None,) + assert provider.regions == all_subscribed_regions + assert provider.home_region == "me-abudhabi-1" + def test_init_with_config_file_auth_without_region_uses_session_config_region_for_identity( self, ): diff --git a/tests/providers/oraclecloud/ui_regions_sync_test.py b/tests/providers/oraclecloud/ui_regions_sync_test.py new file mode 100644 index 0000000000..3e38914f24 --- /dev/null +++ b/tests/providers/oraclecloud/ui_regions_sync_test.py @@ -0,0 +1,20 @@ +import re +from pathlib import Path + +from prowler.providers.oraclecloud.config import OCI_REGIONS + +UI_REGIONS_FILE = ( + Path(__file__).resolve().parents[3] + / "ui" + / "lib" + / "provider-credentials" + / "oci-regions.ts" +) + + +def test_ui_home_region_list_matches_sdk_regions(): + ui_regions = set( + re.findall(r'"([a-z]{2,3}-[a-z-]+-\d+)"', UI_REGIONS_FILE.read_text()) + ) + + assert ui_regions == set(OCI_REGIONS) diff --git a/tests/providers/stackit/services/ske/ske_cluster_no_public_endpoint/ske_cluster_no_public_endpoint_test.py b/tests/providers/stackit/services/ske/ske_cluster_no_public_endpoint/ske_cluster_no_public_endpoint_test.py new file mode 100644 index 0000000000..b7cd01b576 --- /dev/null +++ b/tests/providers/stackit/services/ske/ske_cluster_no_public_endpoint/ske_cluster_no_public_endpoint_test.py @@ -0,0 +1,149 @@ +from unittest import mock + +from prowler.providers.stackit.services.ske.ske_service import Cluster +from tests.providers.stackit.stackit_fixtures import ( + STACKIT_PROJECT_ID, + set_mocked_stackit_provider, +) + + +class Test_ske_cluster_no_public_endpoint: + def _run_check(self, ske_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.ske.ske_service.SKEService", + new=ske_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.ske.ske_client.ske_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.ske.ske_cluster_no_public_endpoint.ske_cluster_no_public_endpoint import ( + ske_cluster_no_public_endpoint, + ) + + check = ske_cluster_no_public_endpoint() + return check.execute() + + def _cluster(self, name="test-cluster", **kwargs): + defaults = { + "id": name, + "name": name, + "project_id": STACKIT_PROJECT_ID, + "region": "eu01", + } + defaults.update(kwargs) + return Cluster(**defaults) + + def test_no_clusters(self): + ske_client = mock.MagicMock + ske_client.clusters = [] + + result = self._run_check(ske_client) + assert len(result) == 0 + + def test_cluster_without_acl_is_public(self): + ske_client = mock.MagicMock + ske_client.clusters = [self._cluster(name="open-cluster", acl_enabled=False)] + + result = self._run_check(ske_client) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "SKE cluster open-cluster exposes its Kubernetes API endpoint to the " + "internet because the ACL extension is not enabled." + ) + assert result[0].resource_id == "open-cluster" + assert result[0].resource_name == "open-cluster" + assert result[0].project_id == STACKIT_PROJECT_ID + assert result[0].location == "eu01" + + def test_cluster_with_unrestricted_ipv4_cidr_is_public(self): + ske_client = mock.MagicMock + ske_client.clusters = [ + self._cluster( + name="wide-cluster", + acl_enabled=True, + allowed_cidrs=["10.0.0.0/8", "0.0.0.0/0"], + ) + ] + + result = self._run_check(ske_client) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "SKE cluster wide-cluster exposes its Kubernetes API endpoint to the " + "internet because its ACL allows unrestricted access from 0.0.0.0/0." + ) + + def test_cluster_with_unrestricted_ipv6_cidr_is_public(self): + ske_client = mock.MagicMock + ske_client.clusters = [ + self._cluster(name="v6-cluster", acl_enabled=True, allowed_cidrs=["::/0"]) + ] + + result = self._run_check(ske_client) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "unrestricted access from ::/0." in result[0].status_extended + + def test_cluster_with_restricted_acl_passes(self): + ske_client = mock.MagicMock + ske_client.clusters = [ + self._cluster( + name="locked-cluster", + access_scope="PUBLIC", + acl_enabled=True, + allowed_cidrs=["10.0.0.0/8", "192.0.2.0/24"], + ) + ] + + result = self._run_check(ske_client) + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "SKE cluster locked-cluster restricts access to its Kubernetes API " + "endpoint to 2 allowed CIDR(s)." + ) + + def test_cluster_with_private_control_plane_passes_without_acl(self): + ske_client = mock.MagicMock + ske_client.clusters = [ + self._cluster(name="sna-cluster", access_scope="SNA", acl_enabled=False) + ] + + result = self._run_check(ske_client) + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "SKE cluster sna-cluster has a private control plane and its Kubernetes " + "API endpoint is not reachable from the internet." + ) + + def test_mixed_clusters_report_independently(self): + ske_client = mock.MagicMock + ske_client.clusters = [ + self._cluster(name="open-cluster", acl_enabled=False), + self._cluster( + name="locked-cluster", acl_enabled=True, allowed_cidrs=["10.0.0.0/8"] + ), + self._cluster(name="sna-cluster", access_scope="SNA"), + ] + + result = self._run_check(ske_client) + assert len(result) == 3 + assert [report.status for report in result] == ["FAIL", "PASS", "PASS"] + assert [report.resource_name for report in result] == [ + "open-cluster", + "locked-cluster", + "sna-cluster", + ] diff --git a/tests/providers/stackit/services/ske/ske_service_test.py b/tests/providers/stackit/services/ske/ske_service_test.py new file mode 100644 index 0000000000..bb7407bf4e --- /dev/null +++ b/tests/providers/stackit/services/ske/ske_service_test.py @@ -0,0 +1,526 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from prowler.providers.stackit.exceptions.exceptions import StackITInvalidTokenError +from prowler.providers.stackit.services.ske.ske_service import Cluster, SKEService +from tests.providers.stackit.stackit_fixtures import ( + STACKIT_PROJECT_ID, + set_mocked_stackit_provider, +) + + +def mock_ske_fetch_all_regions(_): + """Mock the _fetch_all_regions method to avoid real API calls.""" + + +@patch( + "prowler.providers.stackit.services.ske.ske_service.SKEService._fetch_all_regions", + new=mock_ske_fetch_all_regions, +) +class Test_SKE_Service: + def test_service_initialization(self): + """Test that the SKE service initializes correctly.""" + ske_service = SKEService(set_mocked_stackit_provider()) + + assert ske_service.project_id == STACKIT_PROJECT_ID + assert ske_service.service_account_key_path is not None + assert isinstance(ske_service.clusters, list) + + def test_service_service_account_key_path(self): + """Test that the service correctly extracts the SA key path from provider.""" + custom_path = "/tmp/custom-sa.json" + provider = set_mocked_stackit_provider(service_account_key_path=custom_path) + ske_service = SKEService(provider) + assert ske_service.service_account_key_path == custom_path + + def test_service_requests_ske_regional_clients(self): + """Test that the service asks the provider for SKE regional clients.""" + provider = set_mocked_stackit_provider() + SKEService(provider) + provider.generate_regional_clients.assert_called_once_with("ske") + + +class Test_SKE_Service_ExtractItems: + """Tests for the _extract_items response normalization helper.""" + + def test_dict_response(self): + assert SKEService._extract_items({"items": [1, 2]}, "list_clusters") == [1, 2] + + def test_dict_response_without_items_key(self): + assert SKEService._extract_items({}, "list_clusters") == [] + + def test_list_response(self): + assert SKEService._extract_items(["cluster"], "list_clusters") == ["cluster"] + + def test_model_with_items_attribute(self): + response = MagicMock(spec=["items"]) + response.items = ["cluster"] + assert SKEService._extract_items(response, "list_clusters") == ["cluster"] + + def test_model_with_callable_items_is_rejected(self): + # A bare MagicMock exposes ``items`` as a callable, which must not be + # mistaken for the items list. + assert SKEService._extract_items(MagicMock(), "list_clusters") == [] + + def test_unexpected_response_type(self): + assert SKEService._extract_items(object(), "list_clusters") == [] + + +class Test_SKE_Service_GetField: + """Tests for the _get_field dict/model accessor.""" + + def test_none_item_returns_default(self): + assert SKEService._get_field(None, "enabled", default="fallback") == "fallback" + + def test_dict_alias_key_is_matched(self): + item = {"allowedCidrs": ["10.0.0.0/8"]} + assert SKEService._get_field(item, "allowed_cidrs", "allowedCidrs") == [ + "10.0.0.0/8" + ] + + def test_dict_missing_keys_returns_default(self): + assert SKEService._get_field({}, "enabled", default=[]) == [] + + def test_dict_false_value_is_returned_not_treated_as_missing(self): + assert ( + SKEService._get_field({"enabled": False}, "enabled", default=True) is False + ) + + def test_model_attribute_is_read(self): + item = MagicMock(spec=["enabled"]) + item.enabled = True + assert SKEService._get_field(item, "enabled") is True + + def test_model_false_attribute_is_returned(self): + item = MagicMock(spec=["enabled"]) + item.enabled = False + assert SKEService._get_field(item, "enabled", default=True) is False + + def test_model_missing_attribute_returns_default(self): + item = MagicMock(spec=["other"]) + assert SKEService._get_field(item, "enabled", default="fallback") == "fallback" + + +class Test_SKE_Service_ParseAccessScope: + """Tests for _parse_access_scope.""" + + def test_missing_network_returns_none(self): + assert SKEService._parse_access_scope({}) is None + + def test_missing_control_plane_returns_none(self): + assert SKEService._parse_access_scope({"network": {"id": "net"}}) is None + + def test_missing_access_scope_returns_none(self): + assert SKEService._parse_access_scope({"network": {"controlPlane": {}}}) is None + + def test_camel_case_dict_scope(self): + cluster_data = {"network": {"controlPlane": {"accessScope": "SNA"}}} + assert SKEService._parse_access_scope(cluster_data) == "SNA" + + def test_snake_case_dict_scope(self): + cluster_data = {"network": {"control_plane": {"access_scope": "PUBLIC"}}} + assert SKEService._parse_access_scope(cluster_data) == "PUBLIC" + + def test_sdk_enum_is_normalized_to_wire_value(self): + # ``AccessScope`` is a ``str`` Enum whose ``str()`` renders as + # "AccessScope.SNA"; the parser must yield the "SNA" wire value. + from stackit.ske.models.access_scope import AccessScope + + cluster_data = {"network": {"controlPlane": {"accessScope": AccessScope.SNA}}} + assert SKEService._parse_access_scope(cluster_data) == "SNA" + + +class Test_SKE_Service_ParseAcl: + """Tests for _parse_acl.""" + + def test_missing_extensions_reports_no_acl(self): + assert SKEService._parse_acl({}) == (False, []) + + def test_extensions_without_acl_reports_no_acl(self): + assert SKEService._parse_acl({"extensions": {"dns": {}}}) == (False, []) + + def test_disabled_acl_keeps_its_cidrs(self): + cluster_data = { + "extensions": {"acl": {"enabled": False, "allowedCidrs": ["10.0.0.0/8"]}} + } + assert SKEService._parse_acl(cluster_data) == (False, ["10.0.0.0/8"]) + + def test_enabled_acl_camel_case_cidrs(self): + cluster_data = { + "extensions": {"acl": {"enabled": True, "allowedCidrs": ["10.0.0.0/8"]}} + } + assert SKEService._parse_acl(cluster_data) == (True, ["10.0.0.0/8"]) + + def test_enabled_acl_snake_case_cidrs(self): + cluster_data = { + "extensions": {"acl": {"enabled": True, "allowed_cidrs": ["10.0.0.0/8"]}} + } + assert SKEService._parse_acl(cluster_data) == (True, ["10.0.0.0/8"]) + + def test_enabled_acl_without_cidrs(self): + cluster_data = {"extensions": {"acl": {"enabled": True}}} + assert SKEService._parse_acl(cluster_data) == (True, []) + + +class Test_SKE_Service_HandleApiCall: + """Tests for the centralized _handle_api_call wrapper.""" + + def _service(self): + service = object.__new__(SKEService) + service.provider = MagicMock() + return service + + def test_returns_api_response(self): + service = self._service() + api_function = MagicMock(return_value={"items": []}) + + assert service._handle_api_call(api_function, project_id="p") == {"items": []} + api_function.assert_called_once_with(project_id="p") + + def test_delegates_errors_to_provider_and_reraises(self): + service = self._service() + error = ValueError("boom") + api_function = MagicMock(side_effect=error) + + with pytest.raises(ValueError): + service._handle_api_call(api_function) + + service.provider.handle_api_error.assert_called_once_with(error) + + +class Test_SKE_Service_ListClusters: + """Tests for _list_clusters.""" + + def _service(self): + service = object.__new__(SKEService) + service.provider = MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.clusters = [] + return service + + def test_list_clusters_without_client_is_noop(self): + """A missing regional client is logged and skipped, not fatal.""" + service = self._service() + + service._list_clusters(None, "eu01") + + assert service.clusters == [] + + def test_list_clusters_populates_clusters(self): + service = self._service() + client = MagicMock() + client.list_clusters.return_value = { + "items": [ + {"name": "open-cluster"}, + { + "name": "locked-cluster", + "extensions": { + "acl": {"enabled": True, "allowedCidrs": ["10.0.0.0/8"]} + }, + "network": {"controlPlane": {"accessScope": "PUBLIC"}}, + }, + ] + } + + service._list_clusters(client, "eu01") + + assert [cluster.name for cluster in service.clusters] == [ + "open-cluster", + "locked-cluster", + ] + # SKE has no separate cluster id; the name doubles as the identifier. + assert service.clusters[0].id == "open-cluster" + assert service.clusters[0].acl_enabled is False + assert service.clusters[0].access_scope is None + assert service.clusters[1].acl_enabled is True + assert service.clusters[1].allowed_cidrs == ["10.0.0.0/8"] + assert service.clusters[1].access_scope == "PUBLIC" + assert all( + cluster.project_id == STACKIT_PROJECT_ID and cluster.region == "eu01" + for cluster in service.clusters + ) + + def test_cluster_processing_error_is_skipped(self): + """A cluster that raises while being read is skipped, not fatal.""" + + class MalformedCluster: + @property + def name(self): + raise ValueError("malformed cluster") + + service = self._service() + client = MagicMock() + client.list_clusters.return_value = {"items": [MalformedCluster()]} + + service._list_clusters(client, "eu01") + + assert service.clusters == [] + + +class Test_SKE_Service_FetchAllRegions: + """Tests for the region fetch loop and its error semantics.""" + + class _NotFound(Exception): + status = 404 + + class _Unauthorized(Exception): + status = 401 + + class _ServerError(Exception): + status = 500 + + class _ServiceNotEnabled(Exception): + # Body copied from a live SKE 403 for a region the project never enabled. + status = 403 + body = ( + '{"timestamp":"2026-08-20T09:51:33Z","status":403,' + '"error":"Forbidden","message":"Service not enabled"}' + ) + + class _Forbidden(Exception): + status = 403 + body = '{"status":403,"error":"Forbidden","message":"Access denied"}' + + def _service(self, regional_clients): + from prowler.providers.stackit.stackit_provider import StackitProvider + + service = object.__new__(SKEService) + service.provider = MagicMock() + # Reuse the real centralized error handler so 401/403/404 semantics + # match production. + service.provider.handle_api_error = StackitProvider.handle_api_error + service.project_id = STACKIT_PROJECT_ID + service.regional_clients = regional_clients + service.clusters = [] + return service + + def _good_client(self, cluster_name="cluster-eu01"): + client = MagicMock() + client.list_clusters.return_value = {"items": [{"name": cluster_name}]} + return client + + def _failing_client(self, error): + client = MagicMock() + client.list_clusters.side_effect = error + return client + + def test_skips_region_where_project_is_absent(self): + service = self._service( + { + "eu01": self._good_client(), + "eu02": self._failing_client(self._NotFound()), + } + ) + + service._fetch_all_regions() + + # eu01 cluster is collected; the eu02 404 is skipped silently. + assert [cluster.name for cluster in service.clusters] == ["cluster-eu01"] + + def test_skips_region_where_ske_is_not_enabled(self): + service = self._service( + { + "eu01": self._good_client(), + "eu02": self._failing_client(self._ServiceNotEnabled()), + } + ) + + service._fetch_all_regions() + + # A project may enable SKE per region; the eu02 403 "Service not + # enabled" is a skip, not the credentials failure a bare 403 implies. + assert [cluster.name for cluster in service.clusters] == ["cluster-eu01"] + + def test_permission_denied_still_aborts_the_scan(self): + service = self._service({"eu01": self._failing_client(self._Forbidden())}) + + with pytest.raises(StackITInvalidTokenError): + service._fetch_all_regions() + + def test_invalid_token_aborts_the_scan(self): + service = self._service({"eu01": self._failing_client(self._Unauthorized())}) + + with pytest.raises(StackITInvalidTokenError): + service._fetch_all_regions() + + def test_unexpected_error_propagates(self): + service = self._service({"eu01": self._failing_client(self._ServerError())}) + + with pytest.raises(self._ServerError): + service._fetch_all_regions() + + +class Test_SKE_Service_SdkModelShapes: + """Regression coverage for object-shaped (SDK model) API responses. + + ``_extract_items`` accepts both raw dicts and SDK models, so the cluster + parsing has to read either shape. Reading only dict keys would silently drop + the ACL of an SDK-model cluster and report a restricted cluster as exposed, + or worse, miss the ACL of an exposed one. + """ + + @staticmethod + def _sdk_cluster(name, acl=None, access_scope=None): + """Build a real ``stackit.ske`` Cluster model with optional ACL and scope.""" + from stackit.ske.models.cluster import Cluster as SDKCluster + from stackit.ske.models.extension import Extension + from stackit.ske.models.image import Image + from stackit.ske.models.kubernetes import Kubernetes + from stackit.ske.models.machine import Machine + from stackit.ske.models.network import Network + from stackit.ske.models.nodepool import Nodepool + from stackit.ske.models.v2_control_plane_network import V2ControlPlaneNetwork + from stackit.ske.models.volume import Volume + + nodepool = Nodepool( + name="np", + availabilityZones=["eu01-1"], + maximum=1, + minimum=1, + machine=Machine(image=Image(name="flatcar", version="1.0"), type="g1.2"), + volume=Volume(size=20), + ) + return SDKCluster( + name=name, + kubernetes=Kubernetes(version="1.31.0"), + nodepools=[nodepool], + extensions=Extension(acl=acl) if acl is not None else None, + network=( + Network(controlPlane=V2ControlPlaneNetwork(accessScope=access_scope)) + if access_scope is not None + else None + ), + ) + + @staticmethod + def _sdk_acl(allowed_cidrs, enabled=True): + """Build a real ``stackit.ske`` ACL extension model.""" + from stackit.ske.models.acl import ACL + + return ACL(allowedCidrs=allowed_cidrs, enabled=enabled) + + def test_sdk_model_acl_is_parsed(self): + cluster = self._sdk_cluster("locked-clst", acl=self._sdk_acl(["10.0.0.0/8"])) + + assert SKEService._parse_acl(cluster) == (True, ["10.0.0.0/8"]) + + def test_sdk_model_without_extensions_reports_no_acl(self): + cluster = self._sdk_cluster("open-clst") + + assert SKEService._parse_acl(cluster) == (False, []) + assert SKEService._parse_access_scope(cluster) is None + + def test_sdk_model_access_scope_enum_is_normalized(self): + cluster = self._sdk_cluster("sna-clst", access_scope="SNA") + + assert SKEService._parse_access_scope(cluster) == "SNA" + + def test_list_clusters_with_sdk_response_and_model_items(self): + from stackit.ske.models.list_clusters_response import ListClustersResponse + + service = object.__new__(SKEService) + service.provider = MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.clusters = [] + + client = MagicMock() + client.list_clusters.return_value = ListClustersResponse( + items=[ + self._sdk_cluster("open-clst"), + self._sdk_cluster("locked-clst", acl=self._sdk_acl(["10.0.0.0/8"])), + ] + ) + + service._list_clusters(client, "eu01") + + assert [cluster.name for cluster in service.clusters] == [ + "open-clst", + "locked-clst", + ] + # An object-shaped ACL must not be dropped: the restricted cluster is + # not reported as internet-exposed, and the open one still is. + assert service.clusters[0].has_public_endpoint() is True + assert service.clusters[1].acl_enabled is True + assert service.clusters[1].allowed_cidrs == ["10.0.0.0/8"] + assert service.clusters[1].has_public_endpoint() is False + + def test_dict_and_sdk_model_shapes_parse_identically(self): + """The same cluster expressed as a dict or an SDK model must agree.""" + sdk_cluster = self._sdk_cluster( + "locked-clst", + acl=self._sdk_acl(["10.0.0.0/8"]), + access_scope="PUBLIC", + ) + dict_cluster = { + "name": "locked-clst", + "extensions": {"acl": {"enabled": True, "allowedCidrs": ["10.0.0.0/8"]}}, + "network": {"controlPlane": {"accessScope": "PUBLIC"}}, + } + + assert SKEService._parse_acl(sdk_cluster) == SKEService._parse_acl(dict_cluster) + assert SKEService._parse_access_scope( + sdk_cluster + ) == SKEService._parse_access_scope(dict_cluster) + + +class Test_SKE_Cluster_Model: + """Tests for the Cluster public-endpoint logic.""" + + def _cluster(self, **kwargs): + defaults = { + "id": "test-cluster", + "name": "test-cluster", + "project_id": STACKIT_PROJECT_ID, + "region": "eu01", + } + defaults.update(kwargs) + return Cluster(**defaults) + + def test_private_control_plane_passes_regardless_of_acl(self): + cluster = self._cluster(access_scope="SNA", acl_enabled=False) + + assert cluster.has_private_control_plane() is True + assert cluster.has_public_endpoint() is False + + def test_disabled_acl_is_publicly_reachable(self): + cluster = self._cluster(acl_enabled=False) + + assert cluster.has_private_control_plane() is False + assert cluster.has_public_endpoint() is True + + def test_public_scope_with_restricted_acl_is_not_publicly_reachable(self): + cluster = self._cluster( + access_scope="PUBLIC", acl_enabled=True, allowed_cidrs=["10.0.0.0/8"] + ) + + assert cluster.unrestricted_cidrs() == [] + assert cluster.has_public_endpoint() is False + + @pytest.mark.parametrize("unrestricted_cidr", ["0.0.0.0/0", "::/0"]) + def test_unrestricted_cidr_in_allowlist_is_publicly_reachable( + self, unrestricted_cidr + ): + cluster = self._cluster( + acl_enabled=True, allowed_cidrs=["10.0.0.0/8", unrestricted_cidr] + ) + + assert cluster.unrestricted_cidrs() == [unrestricted_cidr] + assert cluster.has_public_endpoint() is True + + def test_enabled_acl_with_empty_allowlist_is_not_publicly_reachable(self): + cluster = self._cluster(acl_enabled=True, allowed_cidrs=[]) + + assert cluster.has_public_endpoint() is False + + def test_unset_access_scope_falls_back_to_the_acl(self): + assert ( + self._cluster( + access_scope=None, acl_enabled=True, allowed_cidrs=["10.0.0.0/8"] + ).has_public_endpoint() + is False + ) + assert ( + self._cluster(access_scope=None, acl_enabled=False).has_public_endpoint() + is True + ) diff --git a/tests/providers/stackit/stackit_provider_test.py b/tests/providers/stackit/stackit_provider_test.py index 13bc7f4698..8ac643c01b 100644 --- a/tests/providers/stackit/stackit_provider_test.py +++ b/tests/providers/stackit/stackit_provider_test.py @@ -276,6 +276,40 @@ class TestStackITProviderTestConnection: (None, self.KEY_CONTENT, self.PROJECT_ID) ] + def test_connection_resource_manager_403_does_not_fail_service_discovery( + self, fake_stackit_resourcemanager + ): + class Http403Error(Exception): + status = 403 + + fake_stackit_resourcemanager.error = Http403Error() + + with patch.object(stackit_provider_module.logger, "warning") as warning: + connection = StackitProvider.test_connection( + project_id=self.PROJECT_ID, + service_account_key_path=self.KEY_PATH, + ) + + assert connection == Connection(is_connected=True) + warning.assert_called_once_with( + "StackIT test_connection: Resource Manager access could not be verified " + "(403). Service permissions will be checked during discovery." + ) + + def test_connection_resource_manager_401_still_fails( + self, fake_stackit_resourcemanager + ): + class Http401Error(Exception): + status = 401 + + fake_stackit_resourcemanager.error = Http401Error() + + with pytest.raises(StackITInvalidTokenError): + StackitProvider.test_connection( + project_id=self.PROJECT_ID, + service_account_key_path=self.KEY_PATH, + ) + def test_connection_returns_error_when_raise_on_exception_is_false( self, fake_stackit_resourcemanager ): diff --git a/tests/util/check_yanked_pins_test.py b/tests/util/check_yanked_pins_test.py new file mode 100644 index 0000000000..1d7e354cab --- /dev/null +++ b/tests/util/check_yanked_pins_test.py @@ -0,0 +1,202 @@ +from pathlib import Path + +import pytest + +from util.check_yanked_pins import ( + Pin, + collect_pins, + evaluate, + main, + normalize, + pins_from_pyproject, + pins_from_uv_lock, +) + +PYPROJECT = """ +[project] +name = "demo" +dependencies = [ + "cryptography==48.0.1", + "alibabacloud_tea_openapi==0.4.5", + "Requests[security]==2.34.2 ; python_version >= '3.10'", + "boto3>=1.40", +] + +[project.optional-dependencies] +extra = ["okta==3.4.2"] + +[dependency-groups] +dev = ["pytest==9.0.3", {include-group = "lint"}] +lint = ["flake8==7.1.2"] + +[tool.uv] +constraint-dependencies = ["zstd==1.5.7.3"] +override-dependencies = ["okta==3.4.2"] +""" + +UV_LOCK = """ +version = 1 + +[[package]] +name = "zstd" +version = "1.5.7.3" +source = { registry = "https://pypi.org/simple" } + +[[package]] +name = "Cryptography" +version = "48.0.1" +source = { registry = "https://pypi.org/simple" } + +[[package]] +name = "prowler" +version = "5.40.0" +source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#abc" } + +[[package]] +name = "demo" +version = "0.1.0" +source = { editable = "." } +""" + + +class TestNormalize: + """normalize() applies PEP 503 so spellings of one project compare equal.""" + + def test_pep503_equivalence(self): + """Underscores, dots and case collapse to the canonical dashed lowercase form.""" + assert normalize("alibabacloud_tea_openapi") == "alibabacloud-tea-openapi" + assert normalize("Requests") == "requests" + assert normalize("zope.interface") == "zope-interface" + + +class TestPinsFromPyproject: + """pins_from_pyproject() reads exact pins from every dependency-bearing table.""" + + def test_collects_exact_pins_from_every_table(self): + """Dependencies, extras, dependency groups and both [tool.uv] lists are covered.""" + pins = pins_from_pyproject(PYPROJECT, "") + assert {(p.name, p.version) for p in pins} == { + ("cryptography", "48.0.1"), + ("alibabacloud-tea-openapi", "0.4.5"), + ("requests", "2.34.2"), + ("okta", "3.4.2"), + ("pytest", "9.0.3"), + ("flake8", "7.1.2"), + ("zstd", "1.5.7.3"), + } + + def test_ignores_ranges_and_records_source_table(self): + """Non-exact specifiers are skipped and each pin remembers its table.""" + pins = pins_from_pyproject(PYPROJECT, "api/") + names = {p.name for p in pins} + assert "boto3" not in names + zstd = next(p for p in pins if p.name == "zstd") + assert zstd.source == "api/pyproject.toml [tool.uv.constraint-dependencies]" + + def test_same_pin_in_two_tables_keeps_both_sources(self): + """The same version in two tables yields two pins, one per source.""" + okta = { + p.source for p in pins_from_pyproject(PYPROJECT, "") if p.name == "okta" + } + assert okta == { + "pyproject.toml [project.optional-dependencies.extra]", + "pyproject.toml [tool.uv.override-dependencies]", + } + + +class TestPinsFromUvLock: + """pins_from_uv_lock() reads locked versions that live on a registry.""" + + def test_only_registry_packages(self): + """git, path and editable sources are not on PyPI and are skipped.""" + pins = pins_from_uv_lock(UV_LOCK, "") + assert {(p.name, p.version) for p in pins} == { + ("zstd", "1.5.7.3"), + ("cryptography", "48.0.1"), + } + assert all(p.source == "uv.lock" for p in pins) + + +class TestCollectPins: + """collect_pins() merges a project's pyproject.toml and uv.lock.""" + + def test_missing_files_raise(self, tmp_path: Path): + """A directory with neither file is a caller error, not an empty result.""" + with pytest.raises(FileNotFoundError): + collect_pins(tmp_path) + + def test_merges_pyproject_and_lock(self, tmp_path: Path): + """Pins from both files are returned with the directory as source prefix.""" + (tmp_path / "pyproject.toml").write_text(PYPROJECT) + (tmp_path / "uv.lock").write_text(UV_LOCK) + sources = {p.source for p in collect_pins(tmp_path)} + prefix = f"{tmp_path.as_posix()}/" + assert f"{prefix}uv.lock" in sources + assert f"{prefix}pyproject.toml [project.dependencies]" in sources + + +class TestEvaluate: + """evaluate() queries PyPI once per release and reports per pin.""" + + def test_queries_each_release_once_and_fans_out_to_every_source(self): + """One fetch per (name, version); its verdict reaches every source of that pin.""" + calls = [] + + def fake_fetch(name, version): + """Stand-in for fetch_release() that records calls and returns fixed verdicts.""" + calls.append((name, version)) + if (name, version) == ("zstd", "1.5.7.3"): + return "yanked", "buggy - not thread safe" + if (name, version) == ("gone", "0.0.1"): + return "missing", "not found on PyPI" + return "ok", "" + + pins = { + Pin("zstd", "1.5.7.3", "pyproject.toml [tool.uv.constraint-dependencies]"), + Pin("zstd", "1.5.7.3", "uv.lock"), + Pin("cryptography", "48.0.1", "uv.lock"), + Pin("gone", "0.0.1", "uv.lock"), + } + verdicts = evaluate(pins, fetch=fake_fetch, workers=2) + + assert sorted(calls) == [ + ("cryptography", "48.0.1"), + ("gone", "0.0.1"), + ("zstd", "1.5.7.3"), + ] + by_status = {} + for verdict in verdicts: + by_status.setdefault(verdict.status, []).append(verdict.pin) + assert len(by_status["yanked"]) == 2 + assert {p.source for p in by_status["yanked"]} == { + "pyproject.toml [tool.uv.constraint-dependencies]", + "uv.lock", + } + assert by_status["missing"] == [Pin("gone", "0.0.1", "uv.lock")] + assert by_status["ok"] == [Pin("cryptography", "48.0.1", "uv.lock")] + + +class TestMain: + """main() turns verdicts into a process exit code and annotations.""" + + def test_exit_code_reflects_verdicts(self, tmp_path: Path, monkeypatch, capsys): + """0 when every pin is ok, 1 plus a ::error:: line when one is yanked.""" + (tmp_path / "pyproject.toml").write_text( + '[project]\ndependencies = ["zstd==1.5.7.3"]\n' + ) + + monkeypatch.setattr( + "util.check_yanked_pins.fetch_release", + lambda name, version, retries=3: ("ok", ""), + ) + assert main([str(tmp_path)]) == 0 + + monkeypatch.setattr( + "util.check_yanked_pins.fetch_release", + lambda name, version, retries=3: ("yanked", "buggy - not thread safe"), + ) + assert main([str(tmp_path)]) == 1 + assert ( + "::error::zstd==1.5.7.3 is yanked (buggy - not thread safe)" + in capsys.readouterr().out + ) diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 8f59e42a59..5d62b9da6f 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -4,6 +4,131 @@ All notable changes to the **Prowler UI** are documented in this file. +## [1.44.0] (Prowler v5.44.0) + +### 🚀 Added + +- Sidebar action reads Add Provider while the tenant has no providers [(#12852)](https://github.com/prowler-cloud/prowler/pull/12852) + +### 🔄 Changed + +- AWS accounts are connected in a single wizard step: the account is read from the role ARN, or typed for access keys, the role is assumed with Prowler's own credentials, and the credentials are stored and tested with the account [(#12852)](https://github.com/prowler-cloud/prowler/pull/12852) +- New tenants without providers land on the Add Provider wizard on first sign-in instead of a welcome modal [(#12852)](https://github.com/prowler-cloud/prowler/pull/12852) +- Findings page paints a skeleton at once and streams the table before the filters; the "Finding Group" options load in a single request when the dropdown opens [(#12891)](https://github.com/prowler-cloud/prowler/pull/12891) + +### 🐞 Fixed + +- Mute rule creation errors show the API error message instead of the raw JSON:API response body [(#12853)](https://github.com/prowler-cloud/prowler/pull/12853) +- Provider connection test no longer reports `Max retries exceeded` for checks that take longer than 30 seconds, such as networks where some AWS endpoints are unreachable; the wait now covers the backend task's full time limit and falls back to the provider's current connection state if it is still exhausted [(#12869)](https://github.com/prowler-cloud/prowler/pull/12869) +- Sidebar no longer throws a React hydration error on full page loads for users who last used the chat mode [(#12873)](https://github.com/prowler-cloud/prowler/pull/12873) +- Icons now ship in the UI bundle instead of being fetched from `api.iconify.design`, so pages render correctly in air-gapped deployments [(#12892)](https://github.com/prowler-cloud/prowler/pull/12892) + +--- + +## [1.43.0] (Prowler v5.43.0) + +### 🚀 Added + +- 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 [(#12494)](https://github.com/prowler-cloud/prowler/pull/12494) +- AWS Marketplace button variant with outlined styling for light and dark themes [(#12803)](https://github.com/prowler-cloud/prowler/pull/12803) +- `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** [(#12815)](https://github.com/prowler-cloud/prowler/pull/12815) +- "Invite your team" step offered once after the first provider is connected, before the onboarding checkpoint, reusing the invitation form tagged with `source=onboarding` [(#12819)](https://github.com/prowler-cloud/prowler/pull/12819) + +### 🐞 Fixed + +- Automatic onboarding stays hidden on billing pages and remains available after leaving billing [(#12803)](https://github.com/prowler-cloud/prowler/pull/12803) +- Per-provider breakdown and OCSF download for FedRAMP 20x KSI and Class C FRR in the cross-provider compliance view [(#12810)](https://github.com/prowler-cloud/prowler/pull/12810) +- Edit and Revoke actions are disabled for expired and revoked invitations [(#12831)](https://github.com/prowler-cloud/prowler/pull/12831) +- Cloudflare API token links in the provider wizard request the SSL and Certificates, Bot Management and Zone WAF read permissions the scan needs [(#12842)](https://github.com/prowler-cloud/prowler/pull/12842) + +--- + +## [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 + +- NCSC Cyber Essentials 3.3 compliance support with its dedicated mapper, details panel, and icon [(#11588)](https://github.com/prowler-cloud/prowler/pull/11588) +- Thumbs-up and thumbs-down feedback form for Lighthouse assistant answers with optional details [(#12419)](https://github.com/prowler-cloud/prowler/pull/12419) +- Display the default one-scan free trial and trial expiration in the existing sidebar banner [(#12420)](https://github.com/prowler-cloud/prowler/pull/12420) +- Slack integration: connect a Slack workspace from the Integrations page (Prowler Cloud only) [(#12435)](https://github.com/prowler-cloud/prowler/pull/12435) +- Prowler Cloud indicator for providers created via Import Findings alongside every connection status [(#12447)](https://github.com/prowler-cloud/prowler/pull/12447) +- Slack integration: authorize several destination channels at once — the connection check confirms each authorized channel with a one-time message and names the one Slack refuses [(#12491)](https://github.com/prowler-cloud/prowler/pull/12491) +- Slack channels confirmed on the Slack integration as alert rule destinations, selectable in the alert modal alongside email recipients [(#12492)](https://github.com/prowler-cloud/prowler/pull/12492) +- Cancelled-subscription variant in the sidebar trial banner (Prowler Cloud only) [(#12538)](https://github.com/prowler-cloud/prowler/pull/12538) + +### 🔄 Changed + +- Alerts list Recipients column becomes Destinations, summarizing a rule's email recipients and Slack channels at a glance [(#12493)](https://github.com/prowler-cloud/prowler/pull/12493) + +### 🐞 Fixed + +- Scan auto-refresh no longer overlaps slow client refreshes and now signals when scan execution settles [(#12455)](https://github.com/prowler-cloud/prowler/pull/12455) +- The compliance "Across providers" section builds its framework list from the API catalog instead of a hardcoded set of ids, so a universal framework registered by an installed package renders like a shipped one [(#12536)](https://github.com/prowler-cloud/prowler/pull/12536) +- The compliance "Across providers" section reports a failed catalog request instead of rendering the "no data yet" empty state [(#12536)](https://github.com/prowler-cloud/prowler/pull/12536) +- Returning from Slack after approving the install now reliably lands on the Slack integration page instead of getting stuck on the callback screen (Prowler Cloud only) [(#12572)](https://github.com/prowler-cloud/prowler/pull/12572) + +### 🔐 Security + +- `libcrypto3` and `libssl3` upgraded to 3.5.8-r0 in the UI container image, patching seven high OpenSSL CVEs [(#12549)](https://github.com/prowler-cloud/prowler/pull/12549) + +--- + +## [1.39.0] (Prowler v5.39.0) + +### 🚀 Added + +- Manual verification workflow for `MANUAL` findings with evidence, effective `PASS` status, and expiration details [(#12253)](https://github.com/prowler-cloud/prowler/pull/12253) +- Surface pre-configured credential creation links in the add-provider wizard. Cloudflare exposes the User API Token template and an Account-Owned template pinned to the Cloudflare Account ID entered in the wizard, GitHub exposes the personal-repositories template and an organization-scanning template pinned to the identifier entered in the wizard [(#12349)](https://github.com/prowler-cloud/prowler/pull/12349) +- Attack Paths graph groups resources by class into expandable nodes and marks the query outcome as the terminal node, with the clicked resource highlighted while its findings are expanded (Prowler Cloud only) [(#12381)](https://github.com/prowler-cloud/prowler/pull/12381) +- Azure Management Group onboarding: add every subscription in a tenant at once (Prowler Cloud only) [(#12386)](https://github.com/prowler-cloud/prowler/pull/12386) +- Manage Lighthouse AI role permission in the role forms and role details, so permission to change the Lighthouse AI configuration can be granted or restricted independently of other permissions (Prowler Cloud only) [(#12412)](https://github.com/prowler-cloud/prowler/pull/12412) +- CMMC 2.0 universal compliance framework rendering: dedicated icon, Domain/Level requirement mapper and cross-provider catalog tile [(#12414)](https://github.com/prowler-cloud/prowler/pull/12414) + +### 🐞 Fixed + +- Organization discovery describes a too-deep hierarchy in each provider's own vocabulary: AWS organizational units, Azure Management Groups, Google Cloud folders [(#12386)](https://github.com/prowler-cloud/prowler/pull/12386) +- `View Findings` on the Scans page no longer opens an empty list for users outside the UTC timezone [(#12411)](https://github.com/prowler-cloud/prowler/pull/12411) + +--- + ## [1.38.0] (Prowler v5.38.0) ### 🚀 Added diff --git a/ui/Dockerfile b/ui/Dockerfile index 8669f801f4..22149a9fa9 100644 --- a/ui/Dockerfile +++ b/ui/Dockerfile @@ -5,10 +5,27 @@ LABEL maintainer="https://github.com/prowler-cloud" # The build uses pnpm via corepack, so npm is unused — remove it (and npx) to drop # the bundled-npm CVE surface from every stage, incl. prod. -# No apk upgrade: it resolves against Alpine's live repo, so the digest pin above -# would not make the image reproducible. Move the digest forward instead. +# No blanket apk upgrade: it resolves against Alpine's live repo, so the digest pin +# above would not make the image reproducible. Move the digest forward instead, or +# take a named package as the targeted exception below. RUN corepack enable && rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx +# High CVEs fixed in Alpine 3.24 but not yet in the pinned base image: +# libcrypto3/libssl3 3.5.8-r0 CVE-2026-14456, CVE-2026-14457, CVE-2026-18798, +# CVE-2026-54874, CVE-2026-63072, CVE-2026-63075, +# CVE-2026-63076 (image ships 3.5.7-r0) +# The base image pins node 24.18.1, which has not been rebuilt since that package +# was published, so the upgrade is taken here rather than by moving the pin -- the +# newest published node:24-alpine (24.19.0, built 2026-08-03) predates the +# 2026-08-13 advisory and carries the same vulnerable version. libcrypto3 and +# libssl3 are both built from openssl and are flagged separately, so both are named. +# `>=` rather than `=`: Alpine keeps only the newest build of a package in a +# branch's index, so an exact pin breaks this build the day 3.5.8-r0 is superseded. +# Drop this once the base image ships 3.5.8-r0 or later. +RUN apk add --no-cache --upgrade \ + "libcrypto3>=3.5.8-r0" \ + "libssl3>=3.5.8-r0" + # Install dependencies only when needed FROM base AS deps # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. @@ -83,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__/browser-harness.integration.test.ts b/ui/__tests__/browser-harness.integration.test.ts index a1befbc1ec..19005d0c08 100644 --- a/ui/__tests__/browser-harness.integration.test.ts +++ b/ui/__tests__/browser-harness.integration.test.ts @@ -8,6 +8,8 @@ import { describe, expect, it } from "vitest"; import { BrowserHarness } from "./browser-harness"; +const QUIET_MS = 50; + /** Exposes the protected waiting helpers; no fixture or DOM is involved. */ class WaitingHarness extends BrowserHarness { constructor() { @@ -21,6 +23,10 @@ class WaitingHarness extends BrowserHarness { probeOrNull(fn: () => T | null | undefined | false): Promise { return this.waitForOrNull(fn, 200, "probe"); } + + probeStable(read: () => T): Promise { + return this.waitForStable(read, QUIET_MS, 1000, "probe"); + } } describe("BrowserHarness waiting helpers", () => { @@ -58,4 +64,20 @@ describe("BrowserHarness waiting helpers", () => { }), ).resolves.toBe("ready"); }); + + it("resolves with a value only once it has held for the quiet window", async () => { + const harness = new WaitingHarness(); + let reads = 0; + let settledAt = 0; + + // Changes on each of the first reads, then holds at 4. + const settled = await harness.probeStable(() => { + reads += 1; + if (reads === 4) settledAt = performance.now(); + return Math.min(reads, 4); + }); + + expect(settled).toBe(4); + expect(performance.now() - settledAt).toBeGreaterThanOrEqual(QUIET_MS); + }); }); diff --git a/ui/__tests__/browser-harness.ts b/ui/__tests__/browser-harness.ts index 1aa44e2946..8c4345ecf1 100644 --- a/ui/__tests__/browser-harness.ts +++ b/ui/__tests__/browser-harness.ts @@ -211,6 +211,31 @@ export abstract class BrowserHarness { } } + /** Wait until `read` returns the same value for `quietMs`, and return it. */ + protected async waitForStable( + read: () => T, + quietMs: number, + timeoutMs = 5000, + label?: string, + ): Promise { + let value = read(); + let since = performance.now(); + const settled = await this.waitFor( + () => { + const next = read(); + if (!Object.is(next, value)) { + value = next; + since = performance.now(); + return null; + } + return performance.now() - since >= quietMs ? { value } : null; + }, + timeoutMs, + label ?? `a value stable for ${quietMs}ms`, + ); + return settled.value; + } + protected async waitForText( pattern: RegExp, timeoutMs = 5000, diff --git a/ui/__tests__/msw/handlers/alerts.fixtures.ts b/ui/__tests__/msw/handlers/alerts.fixtures.ts new file mode 100644 index 0000000000..9b44b8cee7 --- /dev/null +++ b/ui/__tests__/msw/handlers/alerts.fixtures.ts @@ -0,0 +1,241 @@ +/** + * Fixture data for the alerts handlers. The Slack-channel shapes follow the + * signed contract (`openspec/changes/add-slack-alert-channels/contract/`). + * + * The disabled/empty channel states are driven by what the fixture OMITS + * (no integration, no configured channels, no confirmations), never by + * handing the UI a pre-disabled state (design D9). + */ + +export interface AlertsSlackChannelFixture { + id: string; + name: string; + isPrivate: boolean; + /** + * Null until the connection check posts its confirmation; a same-workspace + * reinstall resets every timestamp. Only a confirmed channel is eligible. + */ + confirmationSentAt: string | null; +} + +/** `slackIntegration: null` is a tenant with no workspace connected at all. */ +export interface AlertsSlackIntegrationFixture { + id: string; + workspaceName: string; + /** + * Null until a connection check has run; a reinstall or a changed channel + * set resets it. `true` therefore implies a non-empty set with every channel + * on it confirmed (contract, Connection). + */ + connected: boolean | null; + channels: AlertsSlackChannelFixture[]; +} + +export const ALERT_RULE_TRIGGERS = { + AFTER_SCAN: "after_scan", + DAILY: "daily", + BOTH: "both", +} as const; + +export type AlertRuleTriggerFixture = + (typeof ALERT_RULE_TRIGGERS)[keyof typeof ALERT_RULE_TRIGGERS]; + +export interface AlertRuleFixture { + id: string; + name: string; + description: string; + enabled: boolean; + trigger: AlertRuleTriggerFixture; + /** The condition DSL travels through the UI opaquely. */ + condition: Record; + recipientEmails: string[]; + /** Ids only; the read enriches them from the integration. */ + slackChannelIds: string[]; +} + +export const ALERT_RECIPIENT_STATUSES = { + PENDING: "pending", + CONFIRMED: "confirmed", + UNSUBSCRIBED: "unsubscribed", + BOUNCED: "bounced", +} as const; + +export type AlertRecipientStatusFixture = + (typeof ALERT_RECIPIENT_STATUSES)[keyof typeof ALERT_RECIPIENT_STATUSES]; + +export interface AlertRecipientFixture { + email: string; + status: AlertRecipientStatusFixture; +} + +export interface AlertsFixture { + rules: AlertRuleFixture[]; + recipients: AlertRecipientFixture[]; + slackIntegration: AlertsSlackIntegrationFixture | null; + /** The rules list read answers `500`. */ + listServerError: boolean; + /** + * A failure status for `GET /alerts/slack-channels`, or null to serve it. A + * refused read is the only route to an empty pool on a connected workspace: + * served, a `connected: true` install always holds its confirmed set. + */ + channelsReadError: number | null; + /** + * A failure status for `GET /integrations`, or null to serve it. `403` is + * the everyday one: the endpoint gates even reads behind + * `MANAGE_INTEGRATIONS`, off by default, while `/alerts` is not gated — a + * non-admin editing an alert rule always lands here. + */ + integrationsReadError: number | null; + /** Whether `GET /integrations` rejects at the transport layer. */ + integrationsNetworkError: boolean; +} + +export const ALERTS_SLACK_INTEGRATION_ID = + "7c9e6a1b-2d3f-4e5a-8b6c-9d0e1f2a3b4c"; +export const ALERT_RULE_ID = "1f6d3c2b-8a4e-4b7d-9c5f-0e1a2b3c4d5e"; + +const CONFIRMED_AT = "2026-08-18T10:15:00Z"; + +/** + * Ids and names match the Slack fixtures' workspace, deliberately without + * importing from `slack.fixtures.ts` (the integrations lane owns that file). + */ +export const ALERTS_PUBLIC_CHANNEL: AlertsSlackChannelFixture = { + id: "C0123AB", + name: "security", + isPrivate: false, + confirmationSentAt: CONFIRMED_AT, +}; + +export const ALERTS_PRIVATE_CHANNEL: AlertsSlackChannelFixture = { + id: "C0456CD", + name: "security-alerts", + isPrivate: true, + confirmationSentAt: CONFIRMED_AT, +}; + +export const ALERTS_CONFIGURED_CHANNELS: AlertsSlackChannelFixture[] = [ + ALERTS_PUBLIC_CHANNEL, + ALERTS_PRIVATE_CHANNEL, +]; + +/** + * One code, and a 400, for every ineligible condition (contract section 6.1) — + * only the human `detail` says which one refused. Code and details are spelled + * out here rather than imported from the UI: a rename on our side must fail + * these tests. + */ +export const ALERTS_SLACK_CHANNEL_NOT_ELIGIBLE_CODE = + "slack_channel_not_eligible"; + +export const ALERTS_SLACK_NOT_CONNECTED_DETAIL = + "Slack must be connected before an alert rule can name channel destinations."; + +export const alertsChannelNotAuthorizedDetail = (channelId: string): string => + `Channel ${channelId} is not configured on the Slack integration.`; + +export const alertsChannelNotConfirmedDetail = (channelId: string): string => + `Channel ${channelId} has not been confirmed yet. Run the Slack connection check first.`; + +export const ALERTS_LIST_SERVER_ERROR_DETAIL = "A server error occurred."; + +/** DRF's own refusal body. */ +export const ALERTS_READ_FORBIDDEN_DETAIL = + "You do not have permission to perform this action."; + +export const alertRuleFixture = ( + overrides: Partial = {}, +): AlertRuleFixture => ({ + id: ALERT_RULE_ID, + name: "Critical findings", + description: "Notify security when critical findings land.", + enabled: true, + trigger: ALERT_RULE_TRIGGERS.AFTER_SCAN, + condition: { + op: "count_gte", + filter: { severity: ["critical"] }, + value: 1, + }, + recipientEmails: ["security@example.com"], + slackChannelIds: [], + ...overrides, +}); + +/** + * The baseline tenant: a connected workspace with two confirmed channels and + * one email-only rule. + */ +export const alertsFixture = ( + overrides: Partial = {}, +): AlertsFixture => ({ + rules: [alertRuleFixture()], + recipients: [ + { + email: "security@example.com", + status: ALERT_RECIPIENT_STATUSES.CONFIRMED, + }, + { email: "ops@example.com", status: ALERT_RECIPIENT_STATUSES.CONFIRMED }, + ], + slackIntegration: { + id: ALERTS_SLACK_INTEGRATION_ID, + workspaceName: "Prowler HQ", + connected: true, + channels: ALERTS_CONFIGURED_CHANNELS.map((channel) => ({ ...channel })), + }, + listServerError: false, + channelsReadError: null, + integrationsReadError: null, + integrationsNetworkError: false, + ...overrides, +}); + +/** No Slack workspace connected: the channel destination must say why (D9). */ +export const noSlackAlertsFixture = ( + overrides: Partial = {}, +): AlertsFixture => alertsFixture({ slackIntegration: null, ...overrides }); + +/** + * A workspace approved with nothing authorized on it. `connected` is `null`, + * never `true`: a check needs at least one configured channel, so none can + * have run, and clearing the set resets the connection state anyway + * (contract, Connection and PATCH). + */ +export const noAuthorizedChannelsAlertsFixture = ( + overrides: Partial = {}, +): AlertsFixture => + alertsFixture({ + slackIntegration: { + id: ALERTS_SLACK_INTEGRATION_ID, + workspaceName: "Prowler HQ", + connected: null, + channels: [], + }, + ...overrides, + }); + +/** + * A same-workspace reinstall: the channels and the rules' mappings survive, + * every confirmation and the connection state are reset — the only way the API + * can hand the form a stored channel it does not offer. + */ +export const reinstalledWorkspaceAlertsFixture = ( + overrides: Partial = {}, +): AlertsFixture => + alertsFixture({ + slackIntegration: { + id: ALERTS_SLACK_INTEGRATION_ID, + workspaceName: "Prowler HQ", + connected: null, + channels: ALERTS_CONFIGURED_CHANNELS.map((channel) => ({ + ...channel, + confirmationSentAt: null, + })), + }, + rules: [ + alertRuleFixture({ + slackChannelIds: [ALERTS_PUBLIC_CHANNEL.id, ALERTS_PRIVATE_CHANNEL.id], + }), + ], + ...overrides, + }); diff --git a/ui/__tests__/msw/handlers/alerts.ts b/ui/__tests__/msw/handlers/alerts.ts new file mode 100644 index 0000000000..760d168369 --- /dev/null +++ b/ui/__tests__/msw/handlers/alerts.ts @@ -0,0 +1,408 @@ +/** + * MSW handlers for the alerts pages. The Slack shapes follow the signed + * contract, section 2 and the section 6 addendum + * (`openspec/changes/add-slack-alert-channels/contract/slack-alerts-api.md`). + * + * State is per-call: a create is visible to the next rules read. Wire them + * per test via `worker.use(...handlersForAlerts(fx))`. + */ + +import { http, HttpResponse } from "msw"; + +import { + ALERTS_LIST_SERVER_ERROR_DETAIL, + ALERTS_READ_FORBIDDEN_DETAIL, + ALERTS_SLACK_CHANNEL_NOT_ELIGIBLE_CODE, + ALERTS_SLACK_NOT_CONNECTED_DETAIL, + alertsChannelNotAuthorizedDetail, + alertsChannelNotConfirmedDetail, +} from "./alerts.fixtures"; +import type { + AlertRuleFixture, + AlertsFixture, + AlertsSlackChannelFixture, +} from "./alerts.fixtures"; + +const API = process.env.UI_API_BASE_URL; +const TS = "2026-08-20T09:00:00Z"; + +/** `status` is a string, per the JSON:API spec. */ +const errorBody = (detail: string, status: number, code?: string) => ({ + errors: [ + { + status: String(status), + ...(code ? { code } : {}), + detail, + source: { pointer: "/data" }, + }, + ], +}); + +/** `5xx` throws out of `handleApiResponse`; `4xx` returns an error payload. */ +const readFailure = (status: number): Response => + HttpResponse.json( + errorBody( + status >= 500 + ? ALERTS_LIST_SERVER_ERROR_DETAIL + : ALERTS_READ_FORBIDDEN_DETAIL, + status, + ), + { status }, + ); + +const collection = (data: unknown[]) => ({ + data, + meta: { + version: "v1", + pagination: { page: 1, pages: 1, count: data.length }, + }, +}); + +const storedChannelAttribute = (channel: AlertsSlackChannelFixture) => ({ + id: channel.id, + name: channel.name, + is_private: channel.isPrivate, +}); + +interface RuleWriteAttributes { + name?: string; + description?: string; + enabled?: boolean; + trigger?: AlertRuleFixture["trigger"]; + condition?: AlertRuleFixture["condition"]; + recipient_emails?: string[]; + /** Ids only; name and privacy are server-derived. */ + slack_channels?: { id: string }[]; +} + +const parseRuleAttributes = async ( + request: Request, +): Promise => { + const body = (await request.json().catch(() => null)) as { + data?: { attributes?: RuleWriteAttributes }; + } | null; + return body?.data?.attributes ?? {}; +}; + +export const handlersForAlerts = (fx: AlertsFixture) => { + // Mutable copies: writes must not reach through to the caller's fixture. + const rules: AlertRuleFixture[] = fx.rules.map((rule) => ({ + ...rule, + recipientEmails: [...rule.recipientEmails], + slackChannelIds: [...rule.slackChannelIds], + })); + let createdCount = 0; + + const isConnected = fx.slackIntegration?.connected === true; + + const configuredChannel = ( + channelId: string, + ): AlertsSlackChannelFixture | undefined => + fx.slackIntegration?.channels.find( + (candidate) => candidate.id === channelId, + ); + + /** + * Only the confirmed channels of the enabled and connected integration + * (section 6.2). + */ + const eligibleChannels = (): AlertsSlackChannelFixture[] => + isConnected + ? (fx.slackIntegration?.channels ?? []).filter( + (channel) => channel.confirmationSentAt !== null, + ) + : []; + + /** Every ineligible condition answers the one signed pair (section 6.1). */ + const notEligible = (detail: string): Response => + HttpResponse.json( + errorBody(detail, 400, ALERTS_SLACK_CHANNEL_NOT_ELIGIBLE_CODE), + { status: 400 }, + ); + + /** + * An added channel needs an enabled and connected integration, the channel + * configured on it, and a non-null `confirmation_sent_at`. Only NEWLY added + * ids are validated (section 6.3) — retained ids never block an edit — and + * the refusal is answered before any write lands. + */ + const refuseInvalidChannels = ( + channelIds: string[] | undefined, + retainedIds: readonly string[] = [], + ): Response | null => { + const added = (channelIds ?? []).filter( + (channelId) => !retainedIds.includes(channelId), + ); + if (added.length === 0) return null; + + if (!isConnected) return notEligible(ALERTS_SLACK_NOT_CONNECTED_DETAIL); + + for (const channelId of added) { + const channel = configuredChannel(channelId); + if (!channel) { + return notEligible(alertsChannelNotAuthorizedDetail(channelId)); + } + if (channel.confirmationSentAt === null) { + return notEligible(alertsChannelNotConfirmedDetail(channelId)); + } + } + + return null; + }; + + /** Deduplicated, as the contract requires of every Slack id list. */ + const storedIds = (written: { id: string }[]): string[] => + Array.from(new Set(written.map((channel) => channel.id))); + + /** + * The mapping table holds ids only, so the read enriches them from the + * integration — a channel the workspace no longer carries is simply absent. + */ + const ruleResource = (rule: AlertRuleFixture) => ({ + id: rule.id, + type: "alert-rules", + attributes: { + name: rule.name, + description: rule.description, + enabled: rule.enabled, + trigger: rule.trigger, + condition: rule.condition, + schema_version: 1, + recipient_emails: rule.recipientEmails, + slack_channels: rule.slackChannelIds.flatMap((channelId) => { + const channel = configuredChannel(channelId); + return channel ? [storedChannelAttribute(channel)] : []; + }), + inserted_at: TS, + updated_at: TS, + }, + }); + + const integrationResource = ( + integration: NonNullable, + ) => ({ + id: integration.id, + type: "integrations", + attributes: { + inserted_at: TS, + updated_at: TS, + enabled: true, + connected: integration.connected, + connection_last_checked_at: integration.connected === null ? null : TS, + integration_type: "slack", + configuration: { + team_id: "T01PROWLER", + team_name: integration.workspaceName, + bot_user_id: "U01PROWLERBOT", + channels: integration.channels.map((channel) => ({ + ...storedChannelAttribute(channel), + confirmation_sent_at: channel.confirmationSentAt, + })), + verification: { + task_id: null, + started_at: null, + finished_at: integration.connected === null ? null : TS, + }, + }, + }, + links: { self: `${API}/integrations/${integration.id}` }, + }); + + return [ + http.get(`${API}/alerts/rules`, () => { + if (fx.listServerError) { + return HttpResponse.json( + errorBody(ALERTS_LIST_SERVER_ERROR_DETAIL, 500), + { status: 500 }, + ); + } + return HttpResponse.json(collection(rules.map(ruleResource))); + }), + + /** Registered before the `:id` routes so the literal paths win. */ + http.post(`${API}/alerts/rules/seed`, async ({ request }) => { + const body = (await request.json().catch(() => null)) as { + data?: { attributes?: { filter_bag?: Record } }; + } | null; + const bag = body?.data?.attributes?.filter_bag ?? {}; + const severity = bag["filter[severity__in]"]; + const severityValues = Array.isArray(severity) + ? severity + : typeof severity === "string" + ? severity.split(",") + : ["critical"]; + + return HttpResponse.json({ + data: { + id: "seeded-rule", + type: "alert-rule-seedings", + attributes: { + condition: { + op: "count_gte", + filter: { severity: severityValues }, + value: 1, + }, + }, + }, + }); + }), + + http.post(`${API}/alerts/rules/preview`, () => + HttpResponse.json({ + data: { + id: "preview", + type: "alert-rule-previews", + attributes: { + summary: { finding_count_total: 3, top_severity: "critical" }, + evaluation_failed: false, + }, + }, + }), + ), + + http.post(`${API}/alerts/rules`, async ({ request }) => { + const attributes = await parseRuleAttributes(request); + const written = storedIds(attributes.slack_channels ?? []); + // A create retains nothing: everything supplied is newly added. + const refusal = refuseInvalidChannels(written); + if (refusal) return refusal; + + createdCount += 1; + const created: AlertRuleFixture = { + id: `created-rule-${createdCount}`, + name: attributes.name ?? "", + description: attributes.description ?? "", + enabled: attributes.enabled ?? true, + trigger: attributes.trigger ?? "after_scan", + condition: attributes.condition ?? {}, + recipientEmails: attributes.recipient_emails ?? [], + slackChannelIds: written, + }; + rules.push(created); + + return HttpResponse.json( + { data: ruleResource(created) }, + { status: 201 }, + ); + }), + + http.get<{ id: string }>(`${API}/alerts/rules/:id`, ({ params }) => { + const rule = rules.find((candidate) => candidate.id === params.id); + if (!rule) { + return HttpResponse.json(errorBody("Not found.", 404), { status: 404 }); + } + return HttpResponse.json({ data: ruleResource(rule) }); + }), + + http.patch<{ id: string }>( + `${API}/alerts/rules/:id`, + async ({ params, request }) => { + const rule = rules.find((candidate) => candidate.id === params.id); + if (!rule) { + return HttpResponse.json(errorBody("Not found.", 404), { + status: 404, + }); + } + + const attributes = await parseRuleAttributes(request); + const written = attributes.slack_channels + ? storedIds(attributes.slack_channels) + : undefined; + const refusal = refuseInvalidChannels(written, rule.slackChannelIds); + if (refusal) return refusal; + + if (attributes.name !== undefined) rule.name = attributes.name; + if (attributes.description !== undefined) { + rule.description = attributes.description; + } + if (attributes.enabled !== undefined) rule.enabled = attributes.enabled; + if (attributes.trigger !== undefined) rule.trigger = attributes.trigger; + if (attributes.condition !== undefined) { + rule.condition = attributes.condition; + } + if (attributes.recipient_emails !== undefined) { + rule.recipientEmails = attributes.recipient_emails; + } + // A supplied list replaces the whole selection; omitting the key + // leaves it untouched. + if (written !== undefined) { + rule.slackChannelIds = written; + } + + return HttpResponse.json({ data: ruleResource(rule) }); + }, + ), + + http.delete<{ id: string }>(`${API}/alerts/rules/:id`, ({ params }) => { + const index = rules.findIndex((candidate) => candidate.id === params.id); + if (index === -1) { + return HttpResponse.json(errorBody("Not found.", 404), { status: 404 }); + } + rules.splice(index, 1); + return new HttpResponse(null, { status: 204 }); + }), + + http.get(`${API}/alerts/slack-channels`, () => { + if (fx.channelsReadError) return readFailure(fx.channelsReadError); + return HttpResponse.json({ + data: eligibleChannels().map((channel) => ({ + type: "slack-channels", + id: channel.id, + attributes: { name: channel.name, is_private: channel.isPrivate }, + })), + }); + }), + + http.get(`${API}/alerts/recipients`, () => + HttpResponse.json( + collection( + fx.recipients.map((recipient, index) => ({ + id: `recipient-${index + 1}`, + type: "alert-recipients", + attributes: { + email: recipient.email, + status: recipient.status, + inserted_at: TS, + updated_at: TS, + }, + })), + ), + ), + ), + + // Read so the form can tell "no workspace" from "no eligible channels". + http.get(`${API}/integrations`, ({ request }) => { + if (fx.integrationsNetworkError) return HttpResponse.error(); + if (fx.integrationsReadError) + return readFailure(fx.integrationsReadError); + const type = new URL(request.url).searchParams.get( + "filter[integration_type]", + ); + // An unfiltered read would pull every type into the alert form. + const install = + type === "slack" && fx.slackIntegration ? fx.slackIntegration : null; + return HttpResponse.json( + collection(install ? [integrationResource(install)] : []), + ); + }), + + // Sibling reads the alerts page issues on mount. + http.get(`${API}/providers`, () => HttpResponse.json(collection([]))), + http.get(`${API}/scans`, () => HttpResponse.json(collection([]))), + http.get(`${API}/findings/metadata/latest`, () => + HttpResponse.json({ + data: { + id: "latest", + type: "findings-metadata", + attributes: { + regions: [], + services: [], + resource_types: [], + categories: [], + groups: [], + }, + }, + }), + ), + ]; +}; 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/organizations.fixtures.ts b/ui/__tests__/msw/handlers/organizations.fixtures.ts index 80c16ba8df..516c522754 100644 --- a/ui/__tests__/msw/handlers/organizations.fixtures.ts +++ b/ui/__tests__/msw/handlers/organizations.fixtures.ts @@ -15,6 +15,8 @@ import { ORGANIZATION_TYPE } from "@/types/organizations"; import type { + AzureDiscoveredSubscription, + AzureDiscoveryResult, GcpDiscoveredProject, GcpDiscoveryResult, } from "@/types/organizations"; @@ -29,10 +31,14 @@ export const DISCOVERY_STATUS_VALUE = { export type DiscoveryStatusValue = (typeof DISCOVERY_STATUS_VALUE)[keyof typeof DISCOVERY_STATUS_VALUE]; -/** Canonical node kinds (AWS organizational unit, GCP folder). */ +/** + * Canonical node kinds (AWS organizational unit, GCP folder, Azure management + * group). All kebab-case: `toNodeKind` rejects any other spelling. + */ export const NODE_KIND = { ORGANIZATIONAL_UNIT: "organizational-unit", FOLDER: "folder", + MANAGEMENT_GROUP: "management-group", } as const; export type NodeKind = (typeof NODE_KIND)[keyof typeof NODE_KIND]; @@ -133,9 +139,12 @@ export interface FixtureApplyOutcome { export interface FixtureDiscovery { id: string; status: DiscoveryStatusValue; - /** Raw AWS or GCP discovery result served on the discovery poll. */ + /** Raw AWS, Azure or GCP discovery result served on the discovery poll. */ result: unknown; + /** Machine error code — never user copy. */ error: string | null; + /** Sanitized human message the server sends alongside the code, when it has one. */ + errorMessage?: string | null; } export interface FixtureScheduleBulkOutcome { @@ -156,7 +165,10 @@ export interface OrgFixture { providers: FixtureProvider[]; discovery: FixtureDiscovery | null; apply: FixtureApplyOutcome; - /** Connection outcomes keyed by provider uid (AWS account id / GCP project). */ + /** + * Connection outcomes keyed by provider uid (AWS account id, GCP project id, + * Azure subscription id). + */ connectionByUid: Record; /** POST /organization-secrets returns 409 (duplicate). */ duplicateSecret: boolean; @@ -426,6 +438,189 @@ export const buildGcpDiscoveryResult = ({ }; }; +// --- Azure discovery result ------------------------------------------------ + +/** Canonical Management Group resource ID, the only identity Azure parents on. */ +const azureGroupId = (name: string) => + `/providers/Microsoft.Management/managementGroups/${name}`; + +export const AZURE_TENANT_ID = "11111111-1111-4111-8111-111111111111"; +/** Tenant root group — the target the setup form defaults to. */ +export const AZURE_ROOT_GROUP = azureGroupId(AZURE_TENANT_ID); +export const AZURE_GROUP_ENGINEERING = azureGroupId("engineering"); +export const AZURE_GROUP_PLATFORM = azureGroupId("platform"); +/** + * The two kinds of Management Group with nothing selectable in them: no + * subscriptions at all, and only blocked ones. Neither changes the selectable + * count, and both must still expand. + */ +export const AZURE_EMPTY_GROUP = azureGroupId("holding"); +export const AZURE_EMPTY_GROUP_NAME = "Holding"; +export const AZURE_BLOCKED_GROUP = azureGroupId("archived"); +export const AZURE_BLOCKED_GROUP_NAME = "Archived"; + +export const AZURE_SUBSCRIPTION_PROD_EU = + "22222222-2222-4222-8222-222222222222"; +export const AZURE_SUBSCRIPTION_PROD_US = + "33333333-3333-4333-8333-333333333333"; +/** Blocked, and hanging directly off the root group (`not_applicable` node relation). */ +export const AZURE_SUBSCRIPTION_LEGACY = "55555555-5555-4555-8555-555555555555"; +export const AZURE_BLOCKED_GROUP_SUBSCRIPTION = + "44444444-4444-4444-8444-444444444444"; +/** + * Blocked purely because it is not enabled — no provider exists and no linkage + * conflict applies, the one blocked class Azure can raise on its own. + */ +export const AZURE_SUBSCRIPTION_DISABLED = + "66666666-6666-4666-8666-666666666666"; + +/** + * Blocked reasons Azure discovery reports: GCP's `*_conflict` vocabulary for the + * three linkage/type conflicts, plus its own `subscription_not_enabled`, raised + * whenever `state != "Enabled"`. + */ +export const AZURE_BLOCKED_REASON = { + ORGANIZATION: "organization_conflict", + ORGANIZATION_NODE: "organization_node_conflict", + PROVIDER_TYPE: "provider_type_conflict", + NOT_ENABLED: "subscription_not_enabled", +} as const; + +interface AzureResultOverrides { + /** Subscription ids whose registration reports `will_replace`. */ + replaceSubscriptionIds?: string[]; +} + +/** Pinned to the app's wire interfaces, for the same reason `GcpFixtureDiscoveryResult` is. */ +type AzureFixtureDiscoveryResult = Omit< + AzureDiscoveryResult, + "subscriptions" +> & { + subscriptions: (Omit & { + registration: FixtureRegistration; + })[]; +}; + +/** + * The Azure discovery result as the API shapes it: groups carry canonical resource + * IDs in `id`/`parent_id`, subscriptions are identified by their UUID. Those UUIDs + * are long by nature, so the id-column overflow case needs no special candidate. + */ +export const buildAzureDiscoveryResult = ({ + replaceSubscriptionIds = [], +}: AzureResultOverrides = {}): AzureFixtureDiscoveryResult => { + const subscription = ( + subscriptionId: string, + displayName: string, + parentId: string, + registration: FixtureRegistration, + state = "Enabled", + ) => ({ + subscription_id: subscriptionId, + display_name: displayName, + state, + parent_id: parentId, + registration, + }); + + const readyRegFor = (subscriptionId: string): FixtureRegistration => + replaceSubscriptionIds.includes(subscriptionId) + ? readyRegistration({ + provider_exists: true, + provider_id: `provider-existing-${subscriptionId}`, + provider_secret_state: PROVIDER_SECRET_STATE.WILL_REPLACE, + }) + : readyRegistration(); + + return { + root_management_group: { + id: AZURE_ROOT_GROUP, + name: AZURE_TENANT_ID, + display_name: "Tenant Root Group", + tenant_id: AZURE_TENANT_ID, + }, + management_groups: [ + { + id: AZURE_GROUP_ENGINEERING, + name: "engineering", + display_name: "Engineering", + parent_id: AZURE_ROOT_GROUP, + }, + { + id: AZURE_GROUP_PLATFORM, + name: "platform", + display_name: "Platform", + parent_id: AZURE_GROUP_ENGINEERING, + }, + { + id: AZURE_EMPTY_GROUP, + name: "holding", + display_name: AZURE_EMPTY_GROUP_NAME, + parent_id: AZURE_ROOT_GROUP, + }, + { + id: AZURE_BLOCKED_GROUP, + name: "archived", + display_name: AZURE_BLOCKED_GROUP_NAME, + parent_id: AZURE_ROOT_GROUP, + }, + ], + subscriptions: [ + subscription( + AZURE_SUBSCRIPTION_PROD_EU, + "Production EU", + AZURE_GROUP_ENGINEERING, + readyRegFor(AZURE_SUBSCRIPTION_PROD_EU), + ), + subscription( + AZURE_SUBSCRIPTION_PROD_US, + "Production US", + AZURE_GROUP_PLATFORM, + readyRegFor(AZURE_SUBSCRIPTION_PROD_US), + ), + subscription( + AZURE_SUBSCRIPTION_LEGACY, + "Legacy Sandbox", + AZURE_ROOT_GROUP, + blockedRegistration([AZURE_BLOCKED_REASON.ORGANIZATION], { + provider_exists: true, + provider_id: `provider-existing-${AZURE_SUBSCRIPTION_LEGACY}`, + organization_relation: "linked_to_other_organization", + organization_node_relation: "not_applicable", + provider_secret_state: PROVIDER_SECRET_STATE.WILL_REPLACE, + }), + ), + subscription( + AZURE_SUBSCRIPTION_DISABLED, + "Dormant Sandbox", + AZURE_ROOT_GROUP, + blockedRegistration([AZURE_BLOCKED_REASON.NOT_ENABLED], { + organization_node_relation: "not_applicable", + }), + "Disabled", + ), + subscription( + AZURE_BLOCKED_GROUP_SUBSCRIPTION, + "Archived Legacy", + AZURE_BLOCKED_GROUP, + blockedRegistration( + [ + AZURE_BLOCKED_REASON.ORGANIZATION, + AZURE_BLOCKED_REASON.ORGANIZATION_NODE, + ], + { + provider_exists: true, + provider_id: `provider-existing-${AZURE_BLOCKED_GROUP_SUBSCRIPTION}`, + organization_relation: "linked_to_other_organization", + organization_node_relation: "linked_to_other_node", + provider_secret_state: PROVIDER_SECRET_STATE.WILL_REPLACE, + }, + ), + ), + ], + }; +}; + // --- Fixture builders ------------------------------------------------------ /** @@ -441,6 +636,10 @@ export const GCP_CREATED_PROVIDER_IDS = [ "bbbbbbb1-1111-4111-8111-111111111111", "bbbbbbb2-2222-4222-8222-222222222222", ]; +export const AZURE_CREATED_PROVIDER_IDS = [ + "ccccccc1-1111-4111-8111-111111111111", + "ccccccc2-2222-4222-8222-222222222222", +]; const emptyApply = (): FixtureApplyOutcome => ({ createdProviderIds: [], @@ -535,6 +734,51 @@ export const gcpOnboardingFixture = ( }; }; +/** + * A fresh Azure organization onboarding world (management groups + + * subscriptions). Selection defaults to the two ready subscriptions; the + * remaining three are blocked, one of them inside an otherwise empty Management + * Group. + */ +export const azureOnboardingFixture = ( + overrides: Partial = {}, +): OrgFixture => { + const createdProviderIds = AZURE_CREATED_PROVIDER_IDS; + return { + ...baseFixture(), + discovery: { + id: "disc-azure-1", + status: DISCOVERY_STATUS_VALUE.SUCCEEDED, + result: buildAzureDiscoveryResult(), + error: null, + }, + apply: { + ...emptyApply(), + createdProviderIds, + providersCreatedCount: 2, + nodesCreatedCount: 2, + candidateProviderIds: [ + { + candidateId: AZURE_SUBSCRIPTION_PROD_EU, + providerId: createdProviderIds[0], + }, + { + candidateId: AZURE_SUBSCRIPTION_PROD_US, + providerId: createdProviderIds[1], + }, + ], + }, + connectionByUid: { + [AZURE_SUBSCRIPTION_PROD_EU]: { connected: true }, + [AZURE_SUBSCRIPTION_PROD_US]: { connected: true }, + }, + ...overrides, + }; +}; + +/** The AWS organization identifier of `awsHierarchyFixture`. */ +export const AWS_HIERARCHY_ORG_EXTERNAL_ID = "o-aws0abcdef"; + /** * A providers-page hierarchy world with a fully onboarded AWS organization * (two OUs, three providers). Used for the providers-table grouping tests. @@ -593,7 +837,7 @@ export const awsHierarchyFixture = ( id: orgId, orgType: ORGANIZATION_TYPE.AWS, name: "My AWS Organization", - externalId: "o-aws0abcdef", + externalId: AWS_HIERARCHY_ORG_EXTERNAL_ID, rootExternalId: AWS_ROOT_ID, providerIds: [], nodeIds: nodes.map((n) => n.id), @@ -606,11 +850,63 @@ export const awsHierarchyFixture = ( }; }; -/** AWS + GCP organizations side by side (mixed-hierarchy display test). */ +/** + * Management Groups of an already-onboarded Azure organization. Named apart from + * the discovery fixture's groups (and from the AWS/GCP containers) so a row + * lookup by label can only resolve to one hierarchy. + */ +const AZURE_HIERARCHY_GROUP = azureGroupId("landing-zones"); +export const AZURE_HIERARCHY_GROUP_NAME = "Landing Zones"; +const AZURE_HIERARCHY_CHILD_GROUP = azureGroupId("decommissioned"); +export const AZURE_HIERARCHY_CHILD_GROUP_NAME = "Decommissioned"; +/** The Azure organization of `mixedHierarchyFixture`, and its Management Group node. */ +export const AZURE_ORG_NAME = "My Azure Organization"; +export const AZURE_GROUP_NODE_ID = "node-azure-lz"; + +/** AWS + Azure + GCP organizations side by side (mixed-hierarchy display test). */ export const mixedHierarchyFixture = ( overrides: Partial = {}, ): OrgFixture => { const aws = awsHierarchyFixture(); + const azureOrgId = "org-azure-1"; + const azureProviders: FixtureProvider[] = [ + { + id: "azp-1", + provider: "azure", + uid: AZURE_SUBSCRIPTION_PROD_EU, + alias: "Contoso EU", + connected: true, + }, + { + id: "azp-2", + provider: "azure", + uid: AZURE_SUBSCRIPTION_PROD_US, + alias: "Contoso US", + connected: true, + }, + ]; + const azureNodes: FixtureNode[] = [ + { + id: AZURE_GROUP_NODE_ID, + kind: NODE_KIND.MANAGEMENT_GROUP, + name: AZURE_HIERARCHY_GROUP_NAME, + externalId: AZURE_HIERARCHY_GROUP, + // The tenant-root Management Group is never persisted as a node, so this + // parent id deliberately resolves to no node row. + parentExternalId: AZURE_ROOT_GROUP, + organizationId: azureOrgId, + providerIds: ["azp-1"], + }, + { + id: "node-azure-decommissioned", + kind: NODE_KIND.MANAGEMENT_GROUP, + name: AZURE_HIERARCHY_CHILD_GROUP_NAME, + externalId: AZURE_HIERARCHY_CHILD_GROUP, + parentExternalId: AZURE_HIERARCHY_GROUP, + organizationId: azureOrgId, + providerIds: ["azp-2"], + }, + ]; const gcpOrgId = "org-gcp-1"; const gcpProviders: FixtureProvider[] = [ { @@ -652,6 +948,18 @@ export const mixedHierarchyFixture = ( ...baseFixture(), organizations: [ ...aws.organizations, + { + id: azureOrgId, + orgType: ORGANIZATION_TYPE.AZURE, + name: AZURE_ORG_NAME, + externalId: AZURE_TENANT_ID, + // Azure is the one type that writes its own root: the Management Group + // the organization is scoped to. + rootExternalId: AZURE_ROOT_GROUP, + providerIds: [], + nodeIds: azureNodes.map((n) => n.id), + secretId: "secret-azure-1", + }, { id: gcpOrgId, orgType: ORGANIZATION_TYPE.GCP, @@ -665,12 +973,22 @@ export const mixedHierarchyFixture = ( secretId: "secret-gcp-1", }, ], - nodes: [...aws.nodes, ...gcpNodes], - providers: [...aws.providers, ...gcpProviders], + nodes: [...aws.nodes, ...azureNodes, ...gcpNodes], + providers: [...aws.providers, ...azureProviders, ...gcpProviders], ...overrides, }; }; +/** + * An `org_type` this build has no onboarding flow for. Every `ORGANIZATION_TYPE` + * value is onboardable now, so this has to come from outside the enum — which + * mirrors the real case, the enum tracking a server-side one. `oraclecloud` is a + * real provider type, so its provider rows still render coherently. + */ +export const DISPLAY_ONLY_ORG_TYPE = "oraclecloud"; +export const DISPLAY_ONLY_ORG_NAME = "My Oracle Cloud Tenancy"; +export const DISPLAY_ONLY_PROVIDER_ALIAS = "oci-prod"; + /** * An organization of a type the wizard cannot onboard (display-only): it is still * grouped and labelled from its own `org_type`, but offers no wizard re-entry. @@ -678,17 +996,17 @@ export const mixedHierarchyFixture = ( export const displayOnlyOrgHierarchyFixture = ( overrides: Partial = {}, ): OrgFixture => { - const orgId = "org-azure-1"; + const orgId = "org-display-only-1"; return { ...baseFixture(), organizations: [ { id: orgId, - orgType: ORGANIZATION_TYPE.AZURE, - name: "Contoso Tenant", - externalId: "11111111-2222-3333-4444-555555555555", + orgType: DISPLAY_ONLY_ORG_TYPE, + name: DISPLAY_ONLY_ORG_NAME, + externalId: "ocid1.tenancy.oc1..aaaa1111", rootExternalId: null, - providerIds: ["ap-1"], + providerIds: ["op-1"], nodeIds: [], secretId: null, }, @@ -696,10 +1014,10 @@ export const displayOnlyOrgHierarchyFixture = ( nodes: [], providers: [ { - id: "ap-1", - provider: "azure", - uid: "99999999-8888-7777-6666-555555555555", - alias: "contoso-prod", + id: "op-1", + provider: DISPLAY_ONLY_ORG_TYPE, + uid: "ocid1.compartment.oc1..bbbb2222", + alias: DISPLAY_ONLY_PROVIDER_ALIAS, connected: true, }, ], @@ -709,6 +1027,7 @@ export const displayOnlyOrgHierarchyFixture = ( export const fixtures = { awsOnboarding: awsOnboardingFixture, + azureOnboarding: azureOnboardingFixture, gcpOnboarding: gcpOnboardingFixture, awsHierarchy: awsHierarchyFixture, mixedHierarchy: mixedHierarchyFixture, diff --git a/ui/__tests__/msw/handlers/organizations.ts b/ui/__tests__/msw/handlers/organizations.ts index fcab07f0ff..d1750a9fd5 100644 --- a/ui/__tests__/msw/handlers/organizations.ts +++ b/ui/__tests__/msw/handlers/organizations.ts @@ -302,6 +302,7 @@ export const handlersForOrganizations = ( organizations.map((o) => o.secretId).filter((id): id is string => !!id), ); let orgSeq = 0; + let providerSeq = 0; let secretSeq = 0; /** Reads per connection task, so `executingPolls` can hold one task running. */ const connectionTaskReads = new Map(); @@ -347,7 +348,11 @@ export const handlersForOrganizations = ( orgType: String(attrs.org_type ?? "aws"), name: String(attrs.name ?? ""), externalId: String(attrs.external_id ?? ""), - rootExternalId: null, + // Writable on POST for Azure (the target Management Group); AWS and GCP + // leave it to discovery and send nothing. + rootExternalId: attrs.root_external_id + ? String(attrs.root_external_id) + : null, providerIds: [], nodeIds: [], secretId: null, @@ -503,6 +508,8 @@ export const handlersForOrganizations = ( result: fx.discovery.status === "succeeded" ? fx.discovery.result : {}, error: fx.discovery.error, + // Machine code and human message are separate fields on the wire. + error_message: fx.discovery.errorMessage ?? null, inserted_at: TS, updated_at: TS, }, @@ -559,6 +566,37 @@ export const handlersForOrganizations = ( HttpResponse.json({ data: [], meta: collectionMeta(0) }), ), + // --- single-account connect (AWS one-step form) ----------------------- + http.post(`${API}/providers`, async ({ request }) => { + const body = (await request.json()) as { + data: { attributes: { provider: string; uid: string; alias?: string } }; + }; + providerSeq += 1; + return HttpResponse.json( + { + data: { + id: `provider-created-${providerSeq}`, + type: "providers", + attributes: { + ...body.data.attributes, + connection: { connected: false, last_checked_at: null }, + }, + }, + }, + { status: 201 }, + ); + }), + + http.post(`${API}/providers/secrets`, () => { + secretSeq += 1; + return HttpResponse.json( + { + data: { id: `secret-created-${secretSeq}`, type: "provider-secrets" }, + }, + { status: 201 }, + ); + }), + // --- providers (uid resolution) + connection testing ----------------- http.get<{ id: string }>(`${API}/providers/:id`, ({ params }) => { const provider = fx.providers.find((p) => p.id === params.id); diff --git a/ui/__tests__/msw/handlers/registry.ts b/ui/__tests__/msw/handlers/registry.ts new file mode 100644 index 0000000000..1cbc2c5b22 --- /dev/null +++ b/ui/__tests__/msw/handlers/registry.ts @@ -0,0 +1,17 @@ +import { http, HttpResponse } from "msw"; + +// A 1×1 transparent PNG. +const PNG = Uint8Array.from( + atob( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=", + ), + (char) => char.charCodeAt(0), +); + +/** Serves a registry logo image, so a test never reaches the real registry. */ +export function handleRegistryLogo(url: string) { + return http.get( + url, + () => new HttpResponse(PNG, { headers: { "Content-Type": "image/png" } }), + ); +} diff --git a/ui/__tests__/msw/handlers/slack.fixtures.ts b/ui/__tests__/msw/handlers/slack.fixtures.ts new file mode 100644 index 0000000000..ec267fed5b --- /dev/null +++ b/ui/__tests__/msw/handlers/slack.fixtures.ts @@ -0,0 +1,575 @@ +/** + * Fixture data for the Slack handlers. Shapes follow the signed API contract in + * `openspec/changes/add-slack-alert-channels/contract/slack-alerts-api.md`. + */ + +export interface SlackWorkspaceFixture { + teamId: string; + teamName: string; + botUserId: string; + /** An empty array, never an omitted key (contract, OAuth and reads). */ + authorizedChannels?: SlackAuthorizedChannelFixture[]; +} + +/** Every field is null until a check is queued (contract, OAuth and reads). */ +export interface SlackVerificationFixture { + taskId: string | null; + startedAt: string | null; + finishedAt: string | null; +} + +export const NO_VERIFICATION: SlackVerificationFixture = { + taskId: null, + startedAt: null, + finishedAt: null, +}; + +export interface SlackInstallFixture { + id: string; + /** `null` until the first connection check runs. */ + connected: boolean | null; + connectionLastCheckedAt: string | null; + workspace: SlackWorkspaceFixture; + verification?: SlackVerificationFixture; +} + +export const SLACK_EXCHANGE_OUTCOME = { + CREATED: "created", + /** Same workspace re-installed: the existing row keeps its id. */ + REINSTALLED: "reinstalled", + REFUSED_STATE: "refused-state", + /** + * The same refusal, but naming `invalid_oauth_state` in `code` — the + * contract words it code-less, the deployed API names it; the UI maps both + * to `expired`. + */ + REFUSED_STATE_CODED: "refused-state-coded", + SLACK_REFUSED: "slack-refused", + /** A `409` named by its `code`: one workspace per tenant. */ + DIFFERENT_WORKSPACE: "different-workspace", + /** + * The three below are `2xx`: the install happened, but the answer is + * unreadable, so nothing on the failure path sees them. + */ + UNREADABLE_NO_CONTENT: "unreadable-no-content", + UNREADABLE_HTML: "unreadable-html", + UNREADABLE_NO_DATA: "unreadable-no-data", +} as const; + +export type SlackExchangeOutcome = + (typeof SLACK_EXCHANGE_OUTCOME)[keyof typeof SLACK_EXCHANGE_OUTCOME]; + +export interface SlackConnectionFixture { + connected: boolean; + error: string | null; + /** + * Set only for a channel-level failure; absent for a credential-level one, + * which is about the workspace as a whole (contract, Connection). + */ + failedChannelId?: string | null; +} + +/** A channel the listing endpoint offers for the picker. */ +export interface SlackChannelFixture { + id: string; + name: string; + /** Private channels are listed only where `@Prowler Cloud` has been invited. */ + isPrivate: boolean; +} + +/** + * `confirmationSentAt` is null until a check posts there, and a check never + * posts to a channel that already has one (contract, Connection). + */ +export interface SlackAuthorizedChannelFixture extends SlackChannelFixture { + confirmationSentAt: string | null; +} + +/** + * A refusal as the API sends one: the machine-readable reason in `code`, human + * copy in `detail`, and — for a `429` — the wait in `Retry-After`. + */ +export interface SlackRefusalFixture { + status: number; + /** Slack's stable reason. `null` for the failures classified by status. */ + code: string | null; + detail: string; + /** Seconds `Retry-After` asked for; only a `429` carries one. */ + retryAfterSeconds: number | null; +} + +/** + * What `DELETE /integrations/{id}` reports about revoking the token at Slack. + * Revocation is best-effort: the row goes either way, and the outcome travels in + * JSON:API `meta`. + * + * One boolean is the whole of it: the API sends no reason for a revocation that + * did not happen, so modelling one would let a test prove copy the real + * deployment can never produce. + */ +export interface SlackRevocationFixture { + /** + * Slack confirmed the token no longer grants Prowler anything. `null` when the + * answer reports nothing at all — the plain `204` a deployment without a + * `destroy` override sends, which is what the UI meets today. + */ + revoked: boolean | null; +} + +export interface SlackFixture { + /** + * The deployment has `SLACK_CLIENT_ID` / `SLACK_CLIENT_SECRET` / + * `SLACK_REDIRECT_URI`. Without them every Slack OAuth call answers `503`. + */ + appConfigured: boolean; + install: SlackInstallFixture | null; + exchangeWorkspace: SlackWorkspaceFixture; + exchangeOutcome: SlackExchangeOutcome; + connection: SlackConnectionFixture; + /** The Slack OAuth calls answer `429` with a `Retry-After`. */ + rateLimited: boolean; + /** + * The shared `GET /integrations` read answers `500`, which the UI's own + * helper turns into a thrown error rather than a result. + */ + listServerError: boolean; + /** The consent-URL call answers `200` with a proxy's HTML page, not JSON. */ + authorizeUrlUnreadable: boolean; + /** + * Both Slack OAuth calls answer `502`, the contract's status for upstream and + * transport failures. Distinct from `appConfigured: false`, which is a `503`. + */ + oauthUpstreamError: boolean; + channels: SlackChannelFixture[]; + /** + * Small on purpose: the default workspace spans two pages, so a UI that + * stopped at `data` instead of following `links.next` would lose channels. + */ + channelsPageSize: number; + /** Slack refused the listing outright, with the reason named in `code`. */ + channelsRefusal: SlackRefusalFixture | null; + /** + * The cursor the refusal starts at. Absent, the whole read fails; a page + * size serves the first page and refuses the second — the partial read. + */ + channelsRefusalFromCursor?: number; + /** + * Slack refused the chosen channel when the `PATCH` validated it — the + * listing itself answered fine. + */ + channelSaveRefusal: SlackRefusalFixture | null; + /** + * The API refused the disconnect itself, so the row is still there + * afterwards. Distinct from `revocation`, which is about a removal that + * happened. + */ + disconnectRefusal: SlackRefusalFixture | null; + revocation: SlackRevocationFixture; +} + +/** + * A UUID, as the API's ids are: it travels in the URL of every Slack call and + * the actions accept no other shape. + */ +export const SLACK_INTEGRATION_ID = "7c9e6a1b-2d3f-4e5a-8b6c-9d0e1f2a3b4c"; + +/** The scopes the channel picker and the posting need (design D2). */ +export const SLACK_BOT_SCOPES = [ + "chat:write", + "chat:write.public", + "channels:read", + "groups:read", +] as const; + +export const SLACK_REDIRECT_URI = + "https://cloud.prowler.com/integrations/slack/callback"; + +/** Server-minted, single-use, bound to the tenant and user (design D5). */ +export const SLACK_OAUTH_STATE = "st-2f1c9d7a"; +export const SLACK_OAUTH_CODE = "slack-code-1f4a"; + +export const SLACK_AUTHORIZE_URL = + "https://slack.com/oauth/v2/authorize" + + "?client_id=1234567890.0987654321" + + `&scope=${encodeURIComponent(SLACK_BOT_SCOPES.join(","))}` + + `&state=${SLACK_OAUTH_STATE}` + + `&redirect_uri=${encodeURIComponent(SLACK_REDIRECT_URI)}`; + +/** + * The `detail` strings the implementation sends. Human copy; the + * machine-readable reason travels in `code`, which is what the UI maps. + */ +export const SLACK_UNCONFIGURED_DETAIL = + "Slack integration is not configured or temporarily unavailable."; +export const SLACK_REFUSED_STATE_DETAIL = + "OAuth state is invalid, expired, or already consumed."; +export const SLACK_INVALID_CODE_DETAIL = "The Slack OAuth code is invalid."; +export const SLACK_DIFFERENT_WORKSPACE_DETAIL = + "This tenant is already connected to a different Slack workspace."; +export const SLACK_UPSTREAM_DETAIL = "Slack is temporarily unavailable."; +/** + * The `code` on the contract's `502`. The UI maps no copy of its own to it, so + * the `detail` is what reaches the user. + */ +export const SLACK_UPSTREAM_ERROR_CODE = "service_unavailable"; +/** + * Raised as a `ValidationError({"channel_id": ...})` that still points at + * `/data` rather than at the attribute. + */ +export const SLACK_NO_CHANNEL_DETAIL = + "This Slack integration has no channel configured."; +export const SLACK_RATE_LIMITED_DETAIL = + "Slack is rate limiting requests from Prowler."; +/** + * What a `500` from the shared `GET /integrations` read carries. Nothing here + * is for the user to act on, so the UI answers a server error in its own words. + */ +export const INTEGRATIONS_SERVER_ERROR_DETAIL = "A server error occurred."; +export const SLACK_MISSING_SCOPE_DETAIL = + "Slack refused the request: missing_scope."; +/** + * Names the raw reason, as the missing-scope wording does: what lets a test tell + * copy the UI mapped from `code` apart from an echoed `detail`. + */ +export const SLACK_TOKEN_EXPIRED_DETAIL = + "Slack refused the request: token_expired."; +/** + * The same sentence for "it is gone" and "the app was removed from it": only + * `code` separates them, which is why a client must read `code`. + */ +export const SLACK_UNKNOWN_CHANNEL_DETAIL = + "That channel is not one Prowler can post to."; + +/** + * A `403` from the shared destroy route: the role can read the integration but + * not remove it. Names no Slack reason, so the API's own wording is what the + * user is shown. + */ +export const SLACK_DISCONNECT_FORBIDDEN_DETAIL = + "You do not have permission to disconnect this integration."; + +/** + * A `200` challenge page from a proxy or WAF that took the call instead of the + * API. V8 truncates the parser message for this body before the word `html`, so + * the UI's own detection (`HTML_ERROR_PATTERN`) cannot recognise it either. + */ +export const PROXY_CHALLENGE_PAGE = [ + "", + "Attention Required", + "

    Checking your browser before you proceed.

    ", +].join("\n"); + +/** + * The `code` values the refusals below are named by. Wire values, spelled out + * rather than imported from the UI's own mapping: a rename on our side must + * fail these tests, not quietly agree with itself. + */ +export const SLACK_WORKSPACE_CONFLICT_CODE = "slack_workspace_conflict"; +export const SLACK_MISSING_SCOPE_CODE = "missing_scope"; +export const SLACK_CHANNEL_NOT_FOUND_CODE = "channel_not_found"; +export const SLACK_NOT_IN_CHANNEL_CODE = "not_in_channel"; +/** + * A reason Slack really sends that the UI's mapping does not cover — the set is + * open-ended, so having no copy for one is the ordinary case. + */ +export const SLACK_UNMAPPED_REASON_CODE = "is_archived"; +/** + * Two of the four dead-grant codes the contract lists. Whichever call surfaces + * one, the integration is disconnected and the only way out is connecting the + * workspace again (contract, Cross-cutting). + */ +export const SLACK_TOKEN_REVOKED_CODE = "token_revoked"; +export const SLACK_TOKEN_EXPIRED_CODE = "token_expired"; + +export const SLACK_RETRY_AFTER_SECONDS = 30; + +/** The install never granted a scope the call needs: actionable, so a `400`. */ +export const SLACK_MISSING_SCOPE_REFUSAL: SlackRefusalFixture = { + status: 400, + code: SLACK_MISSING_SCOPE_CODE, + detail: SLACK_MISSING_SCOPE_DETAIL, + retryAfterSeconds: null, +}; + +/** + * Where this really happens is the channel listing: `conversations.list` is + * tier 2 and paginated. + */ +export const SLACK_RATE_LIMITED_REFUSAL: SlackRefusalFixture = { + status: 429, + code: null, + detail: SLACK_RATE_LIMITED_DETAIL, + retryAfterSeconds: SLACK_RETRY_AFTER_SECONDS, +}; + +/** + * The stored grant is no longer usable: a `400` like any other actionable + * refusal, deliberately not the `401` that would read as an expired Prowler + * session (contract, Errors). + */ +export const SLACK_TOKEN_EXPIRED_REFUSAL: SlackRefusalFixture = { + status: 400, + code: SLACK_TOKEN_EXPIRED_CODE, + detail: SLACK_TOKEN_EXPIRED_DETAIL, + retryAfterSeconds: null, +}; + +/** Slack-side or transport failure — a `502` naming no reason at all. */ +export const SLACK_UPSTREAM_REFUSAL: SlackRefusalFixture = { + status: 502, + code: null, + detail: SLACK_UPSTREAM_DETAIL, + retryAfterSeconds: null, +}; + +/** The chosen channel is archived, deleted, or was never in the workspace. */ +export const SLACK_CHANNEL_NOT_FOUND_REFUSAL: SlackRefusalFixture = { + status: 400, + code: SLACK_CHANNEL_NOT_FOUND_CODE, + detail: SLACK_UNKNOWN_CHANNEL_DETAIL, + retryAfterSeconds: null, +}; + +/** + * The channel is fine, the Prowler app is simply not in it — fixed with + * `/invite @Prowler Cloud`. Identical `detail` to the refusal above, deliberately. + */ +export const SLACK_NOT_IN_CHANNEL_REFUSAL: SlackRefusalFixture = { + status: 400, + code: SLACK_NOT_IN_CHANNEL_CODE, + detail: SLACK_UNKNOWN_CHANNEL_DETAIL, + retryAfterSeconds: null, +}; + +/** The disconnect is refused before Slack is asked anything: a `403`. */ +export const SLACK_DISCONNECT_FORBIDDEN_REFUSAL: SlackRefusalFixture = { + status: 403, + code: null, + detail: SLACK_DISCONNECT_FORBIDDEN_DETAIL, + retryAfterSeconds: null, +}; + +/** + * Two public channels and one private the Prowler app was invited to, ordered + * so the private one lands on the second cursor page. + */ +export const SLACK_PUBLIC_CHANNEL: SlackChannelFixture = { + id: "C0123AB", + name: "security", + isPrivate: false, +}; + +export const SLACK_SECOND_PUBLIC_CHANNEL: SlackChannelFixture = { + id: "C0789EF", + name: "platform", + isPrivate: false, +}; + +export const SLACK_PRIVATE_CHANNEL: SlackChannelFixture = { + id: "C0456CD", + name: "security-alerts", + isPrivate: true, +}; + +export const SLACK_CHANNELS: SlackChannelFixture[] = [ + SLACK_PUBLIC_CHANNEL, + SLACK_SECOND_PUBLIC_CHANNEL, + SLACK_PRIVATE_CHANNEL, +]; + +/** Two channels per page, so `SLACK_CHANNELS` spans exactly two pages. */ +export const SLACK_CHANNELS_PAGE_SIZE = 2; + +const PROWLER_HQ: SlackWorkspaceFixture = { + teamId: "T01PROWLER", + teamName: "Prowler HQ", + botUserId: "U01PROWLERBOT", +}; + +export const slackFixture = ( + overrides: Partial = {}, +): SlackFixture => ({ + appConfigured: true, + install: null, + exchangeWorkspace: { ...PROWLER_HQ }, + exchangeOutcome: SLACK_EXCHANGE_OUTCOME.CREATED, + connection: { connected: true, error: null }, + rateLimited: false, + listServerError: false, + authorizeUrlUnreadable: false, + oauthUpstreamError: false, + channels: SLACK_CHANNELS.map((channel) => ({ ...channel })), + channelsPageSize: SLACK_CHANNELS_PAGE_SIZE, + channelsRefusal: null, + channelSaveRefusal: null, + disconnectRefusal: null, + revocation: { revoked: true }, + ...overrides, +}); + +/** + * A workspace approved with no destination channel yet. `connected` is `null`, + * not `true`: the check runs against the channel, so it has never run + * (design.md, "Connection state, in order"). + */ +export const connectedSlackFixture = ( + overrides: Partial = {}, +): SlackFixture => + slackFixture({ + install: { + id: SLACK_INTEGRATION_ID, + connected: null, + connectionLastCheckedAt: null, + workspace: { ...PROWLER_HQ, authorizedChannels: [] }, + verification: { ...NO_VERIFICATION }, + }, + exchangeOutcome: SLACK_EXCHANGE_OUTCOME.REINSTALLED, + ...overrides, + }); + +export const SLACK_CONFIRMED_AT = "2026-08-10T09:30:00Z"; + +const SETTLED_VERIFICATION: SlackVerificationFixture = { + taskId: "5d408881-3e9e-4195-a281-a8d1be849472", + startedAt: "2026-08-10T09:29:58Z", + finishedAt: SLACK_CONFIRMED_AT, +}; + +/** + * Confirmed by default: an install reporting itself connected has had a check + * post to every channel on it. + */ +export const authorizedChannel = ( + channel: SlackChannelFixture, + confirmationSentAt: string | null = SLACK_CONFIRMED_AT, +): SlackAuthorizedChannelFixture => ({ ...channel, confirmationSentAt }); + +const configuredInstall = ( + channels: SlackChannelFixture[] = [SLACK_PUBLIC_CHANNEL], +): SlackInstallFixture => ({ + id: SLACK_INTEGRATION_ID, + connected: true, + connectionLastCheckedAt: SLACK_CONFIRMED_AT, + workspace: { + ...PROWLER_HQ, + authorizedChannels: channels.map((channel) => authorizedChannel(channel)), + }, + verification: { ...SETTLED_VERIFICATION }, +}); + +/** + * The same tenant with destination channels already authorized and confirmed: + * the state a second visit starts from. + */ +export const slackFixtureWithAuthorizedChannels = ( + channels: SlackChannelFixture[] = [SLACK_PUBLIC_CHANNEL], + overrides: Partial = {}, +): SlackFixture => + connectedSlackFixture({ install: configuredInstall(channels), ...overrides }); + +/** + * What a same-workspace reinstall leaves behind: the set kept, every + * confirmation and the verification reset (contract, OAuth and reads). + */ +export const unconfirmedChannelsSlackFixture = ( + channels: SlackChannelFixture[] = [SLACK_PUBLIC_CHANNEL], + overrides: Partial = {}, +): SlackFixture => + connectedSlackFixture({ + install: { + id: SLACK_INTEGRATION_ID, + connected: null, + connectionLastCheckedAt: null, + workspace: { + ...PROWLER_HQ, + authorizedChannels: channels.map((channel) => + authorizedChannel(channel, null), + ), + }, + verification: { ...NO_VERIFICATION }, + }, + ...overrides, + }); + +/** + * The same finished setup, with a check time no parser can read: a zero date + * from a bad write or a serializer change. The contract types the attribute as + * a string and rules nothing else out. + */ +export const unreadableCheckTimeSlackFixture = (): SlackFixture => + connectedSlackFixture({ + install: { + ...configuredInstall(), + connectionLastCheckedAt: "0000-00-00T00:00:00Z", + }, + }); + +/** + * The first cursor page is served and Slack rate limits the second: what is + * already read stays usable, the refusal only says why the list is short. + */ +export const partiallyReadSlackFixture = ( + overrides: Partial = {}, +): SlackFixture => + slackFixtureWithAuthorizedChannels([SLACK_PUBLIC_CHANNEL], { + channelsRefusal: SLACK_RATE_LIMITED_REFUSAL, + channelsRefusalFromCursor: SLACK_CHANNELS_PAGE_SIZE, + ...overrides, + }); + +/** + * A workspace connected *and* channels authorized. Anything the API refuses + * while the set is empty (the connection check) needs this fixture. + */ +export const configuredSlackFixture = ( + overrides: Partial = {}, +): SlackFixture => + slackFixtureWithAuthorizedChannels([SLACK_PUBLIC_CHANNEL], overrides); + +/** + * A connected tenant whose disconnect removes the row but cannot revoke at + * Slack — the outcome the user has to finish by hand in the workspace. + */ +export const revokeFailureSlackFixture = ( + overrides: Partial = {}, +): SlackFixture => + connectedSlackFixture({ + revocation: { revoked: false }, + ...overrides, + }); + +/** + * A connected tenant whose disconnect answers a plain `204` with no body: the + * row is gone and the revocation is unreported. + */ +export const unreportedRevocationSlackFixture = ( + overrides: Partial = {}, +): SlackFixture => + connectedSlackFixture({ + revocation: { revoked: null }, + ...overrides, + }); + +/** + * A connected tenant whose disconnect the API refuses: nothing is removed and + * nothing is revoked, so the workspace is still connected afterwards. + */ +export const disconnectRefusalSlackFixture = ( + overrides: Partial = {}, +): SlackFixture => + configuredSlackFixture({ + disconnectRefusal: SLACK_DISCONNECT_FORBIDDEN_REFUSAL, + ...overrides, + }); + +/** + * A connected tenant whose token has been revoked at Slack: the row still says + * connected until a check runs, and the check is what surfaces it. + */ +export const revokedTokenSlackFixture = ( + overrides: Partial = {}, +): SlackFixture => + configuredSlackFixture({ + connection: { connected: false, error: SLACK_TOKEN_REVOKED_CODE }, + ...overrides, + }); diff --git a/ui/__tests__/msw/handlers/slack.test.ts b/ui/__tests__/msw/handlers/slack.test.ts new file mode 100644 index 0000000000..29819206a3 --- /dev/null +++ b/ui/__tests__/msw/handlers/slack.test.ts @@ -0,0 +1,121 @@ +/** + * The Slack double's own writes, driven over HTTP because the page cannot + * navigate to every state a connection check leaves behind. A double serving a + * state the API cannot produce lets a UI test prove copy no deployment shows. + */ + +import { setupServer } from "msw/node"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; + +import { handlersForSlack } from "./slack"; +import { + SLACK_INTEGRATION_ID, + SLACK_NOT_IN_CHANNEL_CODE, + SLACK_PRIVATE_CHANNEL, + SLACK_PUBLIC_CHANNEL, + SLACK_TOKEN_REVOKED_CODE, + unconfirmedChannelsSlackFixture, +} from "./slack.fixtures"; +import type { SlackConnectionFixture } from "./slack.fixtures"; + +const API = process.env.UI_API_BASE_URL; + +/** Re-declared, not imported from product code, so a rename fails here. */ +interface ChannelBody { + id: string; + confirmation_sent_at: string | null; +} + +const server = setupServer(); + +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +/** The poll is what writes, so the read has to follow it. */ +const runConnectionCheck = async (connection: SlackConnectionFixture) => { + server.use( + ...handlersForSlack( + unconfirmedChannelsSlackFixture( + [SLACK_PUBLIC_CHANNEL, SLACK_PRIVATE_CHANNEL], + { connection }, + ), + ), + ); + + const queued = await fetch( + `${API}/integrations/${SLACK_INTEGRATION_ID}/connection`, + { method: "POST" }, + ); + expect(queued.status).toBe(202); + const { data: task } = (await queued.json()) as { data: { id: string } }; + + const polled = await fetch(`${API}/tasks/${task.id}`); + expect(polled.status).toBe(200); + + const listed = await fetch( + `${API}/integrations?filter[integration_type]=slack`, + ); + const { data } = (await listed.json()) as { + data: { + attributes: { + connected: boolean | null; + configuration: { channels: ChannelBody[] }; + }; + }[]; + }; + + const { connected: recorded, configuration } = data[0].attributes; + return { + connected: recorded, + confirmedAt: new Map( + configuration.channels.map((channel) => [ + channel.id, + channel.confirmation_sent_at, + ]), + ), + }; +}; + +describe("the Slack double's connection check", () => { + it("confirms no channel at all when the failure is about the workspace", async () => { + // Given/When — a revoked grant, which `auth.test` finds before the + // per-channel loop, so the failure names no channel. + const { connected, confirmedAt } = await runConnectionCheck({ + connected: false, + error: SLACK_TOKEN_REVOKED_CODE, + }); + + // Then + expect(connected).toBe(false); + expect(confirmedAt.get(SLACK_PUBLIC_CHANNEL.id)).toBeNull(); + expect(confirmedAt.get(SLACK_PRIVATE_CHANNEL.id)).toBeNull(); + }); + + it("confirms every channel but the one a channel-level failure names", async () => { + // Given/When — the token is fine and Slack refuses one channel of the set. + const { connected, confirmedAt } = await runConnectionCheck({ + connected: false, + error: SLACK_NOT_IN_CHANNEL_CODE, + failedChannelId: SLACK_PRIVATE_CHANNEL.id, + }); + + // Then — the refused channel is the retry's remaining work. + expect(connected).toBe(false); + expect(confirmedAt.get(SLACK_PUBLIC_CHANNEL.id)).not.toBeNull(); + expect(confirmedAt.get(SLACK_PRIVATE_CHANNEL.id)).toBeNull(); + }); + + it("confirms the whole set when the check succeeds", async () => { + // Given/When + const { connected, confirmedAt } = await runConnectionCheck({ + connected: true, + error: null, + }); + + // Then + expect(connected).toBe(true); + expect(confirmedAt.get(SLACK_PUBLIC_CHANNEL.id)).not.toBeNull(); + expect(confirmedAt.get(SLACK_PRIVATE_CHANNEL.id)).not.toBeNull(); + }); +}); diff --git a/ui/__tests__/msw/handlers/slack.ts b/ui/__tests__/msw/handlers/slack.ts new file mode 100644 index 0000000000..d7deda6673 --- /dev/null +++ b/ui/__tests__/msw/handlers/slack.ts @@ -0,0 +1,471 @@ +/** + * MSW handlers for the Slack integration, derived from the signed API contract + * in `openspec/changes/add-slack-alert-channels/contract/slack-alerts-api.md` + * (the API itself lives in the cloud repository). State is per-call: an + * exchange creates the install the subsequent `GET /integrations` returns, and + * a save or a connection check writes to it. + * + * Wire them per test via `worker.use(...handlersForSlack(fx))`. + */ + +import { http, HttpResponse } from "msw"; + +import { + INTEGRATIONS_SERVER_ERROR_DETAIL, + NO_VERIFICATION, + PROXY_CHALLENGE_PAGE, + SLACK_AUTHORIZE_URL, + SLACK_DIFFERENT_WORKSPACE_DETAIL, + SLACK_EXCHANGE_OUTCOME, + SLACK_INTEGRATION_ID, + SLACK_INVALID_CODE_DETAIL, + SLACK_NO_CHANNEL_DETAIL, + SLACK_RATE_LIMITED_REFUSAL, + SLACK_REFUSED_STATE_DETAIL, + SLACK_UNCONFIGURED_DETAIL, + SLACK_UNKNOWN_CHANNEL_DETAIL, + SLACK_UPSTREAM_DETAIL, + SLACK_UPSTREAM_ERROR_CODE, + SLACK_WORKSPACE_CONFLICT_CODE, +} from "./slack.fixtures"; +import type { + SlackAuthorizedChannelFixture, + SlackExchangeOutcome, + SlackFixture, + SlackInstallFixture, + SlackRefusalFixture, +} from "./slack.fixtures"; + +const API = process.env.UI_API_BASE_URL; +const TS = "2026-08-10T09:00:00Z"; +/** The time a check records, distinct from `TS` so a test can tell one ran. */ +const CHECK_TS = "2026-08-10T10:15:00Z"; + +const CONNECTION_TASK_PREFIX = "slack-conn-task-"; + +/** Order-insensitive: a reorder is not a changed set (contract, PATCH). */ +const sameChannelIds = (a: string[], b: string[]) => + a.length === b.length && new Set([...a, ...b]).size === a.length; + +/** Opaque to the UI, which only ever follows `links.next` (design D6). */ +const CHANNEL_CURSOR_PARAM = "page[cursor]"; + +/** + * `status` is a string, per the JSON:API spec. `source.pointer` is `/data` even + * for a field-shaped `ValidationError`: the errors are about the request. + */ +const errorBody = (detail: string, status: number, code?: string) => ({ + errors: [ + { + status: String(status), + ...(code ? { code } : {}), + detail, + source: { pointer: "/data" }, + }, + ], +}); + +/** + * Answer a fixture's refusal as the API would: its own status, its `code` + * when it names one, and `Retry-After` only where the status carries a wait. + */ +const refuse = (refusal: SlackRefusalFixture) => + HttpResponse.json( + errorBody(refusal.detail, refusal.status, refusal.code ?? undefined), + { + status: refusal.status, + ...(refusal.retryAfterSeconds === null + ? {} + : { headers: { "Retry-After": String(refusal.retryAfterSeconds) } }), + }, + ); + +const configuration = (install: SlackInstallFixture) => { + const verification = install.verification ?? NO_VERIFICATION; + return { + team_id: install.workspace.teamId, + team_name: install.workspace.teamName, + bot_user_id: install.workspace.botUserId, + // Always an array, empty rather than omitted (contract, OAuth and reads). + channels: (install.workspace.authorizedChannels ?? []).map((channel) => ({ + id: channel.id, + name: channel.name, + is_private: channel.isPrivate, + confirmation_sent_at: channel.confirmationSentAt, + })), + verification: { + task_id: verification.taskId, + started_at: verification.startedAt, + finished_at: verification.finishedAt, + }, + }; +}; + +const integrationResource = (install: SlackInstallFixture) => ({ + id: install.id, + type: "integrations", + attributes: { + inserted_at: TS, + updated_at: TS, + enabled: true, + connected: install.connected, + connection_last_checked_at: install.connectionLastCheckedAt, + integration_type: "slack", + // No credentials: the bot token is encrypted at rest and never serialized. + configuration: configuration(install), + }, + links: { self: `${API}/integrations/${install.id}` }, +}); + +const collection = (install: SlackInstallFixture | null) => ({ + data: install ? [integrationResource(install)] : [], + meta: { + version: "v1", + pagination: { + page: 1, + pages: 1, + count: install ? 1 : 0, + }, + }, +}); + +const taskResource = (id: string, state: string, result: unknown) => ({ + data: { id, type: "tasks", attributes: { state, result } }, +}); + +/** Stamps only channels this run posted to, never one already confirmed. */ +const confirmedByThisRun = + (connected: boolean, failedChannelId: string | null) => + (channel: SlackAuthorizedChannelFixture): SlackAuthorizedChannelFixture => + // A failure naming no channel posted nothing: `auth.test` runs once, before + // the per-channel loop (contract, Connection). + (connected || failedChannelId !== null) && + channel.confirmationSentAt === null && + channel.id !== failedChannelId + ? { ...channel, confirmationSentAt: CHECK_TS } + : channel; + +/** + * All three are `2xx`: the first two make `response.json()` throw, the third + * parses into a body that names no resource. + */ +const unreadableExchange = (outcome: SlackExchangeOutcome): Response => { + switch (outcome) { + case SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_CONTENT: + return new HttpResponse(null, { status: 204 }); + case SLACK_EXCHANGE_OUTCOME.UNREADABLE_HTML: + return HttpResponse.html(PROXY_CHALLENGE_PAGE); + default: + return HttpResponse.json({ meta: { version: "v1" } }); + } +}; + +export const handlersForSlack = (fx: SlackFixture) => { + // Mutable copy: the exchange must not write through to the caller's fixture. + let install: SlackInstallFixture | null = fx.install + ? { ...fx.install, workspace: { ...fx.install.workspace } } + : null; + + const freshInstall = (id?: string): SlackInstallFixture => ({ + id: id ?? SLACK_INTEGRATION_ID, + connected: null, + connectionLastCheckedAt: null, + workspace: { ...fx.exchangeWorkspace, authorizedChannels: [] }, + verification: { ...NO_VERIFICATION }, + }); + + const unconfigured = () => + HttpResponse.json(errorBody(SLACK_UNCONFIGURED_DETAIL, 503), { + status: 503, + }); + + const rateLimited = () => refuse(SLACK_RATE_LIMITED_REFUSAL); + + /** A `502` per the contract's taxonomy: a server fault, not a Slack state. */ + const upstreamError = () => + HttpResponse.json( + errorBody(SLACK_UPSTREAM_DETAIL, 502, SLACK_UPSTREAM_ERROR_CODE), + { status: 502, statusText: "Bad Gateway" }, + ); + + return [ + // --- OAuth ------------------------------------------------------------ + http.post(`${API}/integrations/slack/oauth/authorize-url`, () => { + if (!fx.appConfigured) return unconfigured(); + if (fx.rateLimited) return rateLimited(); + if (fx.oauthUpstreamError) return upstreamError(); + if (fx.authorizeUrlUnreadable) { + return HttpResponse.html(PROXY_CHALLENGE_PAGE); + } + // The URL travels in `meta`; the call creates nothing. + return HttpResponse.json({ + meta: { authorize_url: SLACK_AUTHORIZE_URL }, + }); + }), + + http.post(`${API}/integrations/slack/oauth/exchange`, () => { + if (!fx.appConfigured) return unconfigured(); + if (fx.rateLimited) return rateLimited(); + if (fx.oauthUpstreamError) return upstreamError(); + + switch (fx.exchangeOutcome) { + case SLACK_EXCHANGE_OUTCOME.REFUSED_STATE: + return HttpResponse.json(errorBody(SLACK_REFUSED_STATE_DETAIL, 400), { + status: 400, + }); + case SLACK_EXCHANGE_OUTCOME.REFUSED_STATE_CODED: + return HttpResponse.json( + errorBody(SLACK_REFUSED_STATE_DETAIL, 400, "invalid_oauth_state"), + { status: 400 }, + ); + case SLACK_EXCHANGE_OUTCOME.SLACK_REFUSED: + return HttpResponse.json(errorBody(SLACK_INVALID_CODE_DETAIL, 400), { + status: 400, + }); + case SLACK_EXCHANGE_OUTCOME.DIFFERENT_WORKSPACE: + return HttpResponse.json( + errorBody( + SLACK_DIFFERENT_WORKSPACE_DETAIL, + 409, + SLACK_WORKSPACE_CONFLICT_CODE, + ), + { status: 409 }, + ); + case SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_CONTENT: + case SLACK_EXCHANGE_OUTCOME.UNREADABLE_HTML: + case SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_DATA: + // The install still happened: the API upserts before it answers. + install = freshInstall(); + return unreadableExchange(fx.exchangeOutcome); + case SLACK_EXCHANGE_OUTCOME.REINSTALLED: + install = { + ...freshInstall(install?.id), + workspace: { + ...fx.exchangeWorkspace, + // A reinstall keeps the set and resets every confirmation, with the + // connection and verification state (contract, OAuth and reads). + authorizedChannels: ( + install?.workspace.authorizedChannels ?? [] + ).map((channel) => ({ ...channel, confirmationSentAt: null })), + }, + }; + return HttpResponse.json({ data: integrationResource(install) }); + default: + install = freshInstall(); + return HttpResponse.json( + { data: integrationResource(install) }, + { status: 201 }, + ); + } + }), + + // --- Generic integration endpoints the Slack UI reuses ----------------- + http.get(`${API}/integrations`, ({ request }) => { + if (fx.listServerError) { + return HttpResponse.json( + errorBody(INTEGRATIONS_SERVER_ERROR_DETAIL, 500), + { status: 500 }, + ); + } + + const type = new URL(request.url).searchParams.get( + "filter[integration_type]", + ); + // An unfiltered read would pull every type into the Slack page. + return HttpResponse.json(collection(type === "slack" ? install : null)); + }), + + http.post<{ id: string }>( + `${API}/integrations/:id/connection`, + ({ params }) => { + // A check posts to every authorized channel, so the API requires at least + // one (contract, Connection). + if (!install?.workspace.authorizedChannels?.length) { + return HttpResponse.json(errorBody(SLACK_NO_CHANNEL_DETAIL, 400), { + status: 400, + }); + } + + // Id stored before the task is published; the worker stamps `started_at`. + const taskId = `${CONNECTION_TASK_PREFIX}${params.id}`; + install.verification = { + taskId, + startedAt: null, + finishedAt: null, + }; + + return HttpResponse.json(taskResource(taskId, "executing", null), { + status: 202, + }); + }, + ), + + http.get<{ taskId: string }>(`${API}/tasks/:taskId`, ({ params }) => { + const { connected, error, failedChannelId } = fx.connection; + // Only the task whose id still matches may write: a late poll must not + // overwrite a newer check (contract, Connection). + if ( + install && + params.taskId.startsWith(CONNECTION_TASK_PREFIX) && + install.verification?.taskId === params.taskId + ) { + install.connected = connected; + install.connectionLastCheckedAt = CHECK_TS; + install.verification = { + taskId: params.taskId, + startedAt: CHECK_TS, + finishedAt: CHECK_TS, + }; + install.workspace.authorizedChannels = ( + install.workspace.authorizedChannels ?? [] + ).map(confirmedByThisRun(connected, failedChannelId ?? null)); + } + return HttpResponse.json( + // `channel` carries the failing channel's id, not an object. + taskResource(params.taskId, "completed", { + connected, + error, + channel: failedChannelId ?? null, + }), + ); + }), + + // --- Channels ---------------------------------------------------------- + http.get<{ id: string }>( + `${API}/integrations/:id/slack/channels`, + ({ params, request }) => { + // The UI follows `links.next` opaquely, so the cursor's shape is this + // fixture's business alone. Read first: the page decides the refusal. + const cursor = Number( + new URL(request.url).searchParams.get(CHANNEL_CURSOR_PARAM) ?? "0", + ); + + // An endpoint-specific refusal wins over the blanket rate limiting, + // and applies from the named cursor, so a partial read is expressible. + if ( + fx.channelsRefusal && + cursor >= (fx.channelsRefusalFromCursor ?? 0) + ) { + return refuse(fx.channelsRefusal); + } + if (fx.rateLimited) return rateLimited(); + + const nextCursor = cursor + fx.channelsPageSize; + const page = fx.channels.slice(cursor, nextCursor); + const hasMore = nextCursor < fx.channels.length; + + return HttpResponse.json({ + data: page.map((channel) => ({ + type: "slack-channels", + id: channel.id, + attributes: { name: channel.name, is_private: channel.isPrivate }, + })), + links: { + next: hasMore + ? `${API}/integrations/${params.id}/slack/channels` + + `?${CHANNEL_CURSOR_PARAM}=${nextCursor}` + : null, + }, + }); + }, + ), + + /** + * The generic PATCH. The write names only `id`; name and privacy are derived + * here, as the API derives them from Slack (contract, PATCH). + */ + http.patch(`${API}/integrations/:id`, async ({ request }) => { + const body = (await request.json().catch(() => null)) as { + data?: { attributes?: Record }; + } | null; + const attributes = body?.data?.attributes ?? {}; + const configurationPatch = attributes.configuration as + | { channels?: { id: string }[] } + | undefined; + const requested = configurationPatch?.channels; + + if (!install) { + return HttpResponse.json(errorBody("Not found.", 404), { status: 404 }); + } + // The API's write serializer names the attributes it will not take and + // refuses the whole save, rather than quietly dropping the extra one: + // sending `integration_type` here refused every channel save. + const refusedAttributes = Object.keys(attributes).filter( + (attribute) => attribute !== "configuration", + ); + if (refusedAttributes.length > 0) { + const named = refusedAttributes + .map((attribute) => `'${attribute}'`) + .join(", "); + return HttpResponse.json(errorBody(`Invalid fields: {${named}}`, 400), { + status: 400, + }); + } + // Omitted leaves the set alone; empty clears it (contract, PATCH). + if (requested === undefined) { + return HttpResponse.json({ data: integrationResource(install) }); + } + + const channelIds = Array.from( + new Set(requested.map((channel) => channel.id)), + ); + const matched = channelIds + .map((channelId) => + fx.channels.find((channel) => channel.id === channelId), + ) + .filter((channel) => channel !== undefined); + + // Checked before the id lookup: the picker did offer these channels, and + // Slack refused one anyway when the API validated the set. + if (fx.channelSaveRefusal) return refuse(fx.channelSaveRefusal); + // One unknown id refuses the whole save: the set is validated together. + if (matched.length !== channelIds.length) { + return HttpResponse.json(errorBody(SLACK_UNKNOWN_CHANNEL_DETAIL, 400), { + status: 400, + }); + } + + const previous = install.workspace.authorizedChannels ?? []; + const confirmedAt = new Map( + previous.map((channel) => [channel.id, channel.confirmationSentAt]), + ); + install.workspace.authorizedChannels = matched.map((channel) => ({ + ...channel, + // Retained ids keep their confirmation; new ones start without one. + confirmationSentAt: confirmedAt.get(channel.id) ?? null, + })); + + // A changed id set resets the connection and verification state, so the + // record never claims a check covered a channel it never saw. + if ( + !sameChannelIds( + previous.map((channel) => channel.id), + channelIds, + ) + ) { + install.connected = null; + install.connectionLastCheckedAt = null; + install.verification = { ...NO_VERIFICATION }; + } + + return HttpResponse.json({ data: integrationResource(install) }); + }), + + // Disconnect. Revocation at Slack is best-effort: the row is removed either + // way and the outcome travels in `meta` — or nowhere at all, in the plain + // `204` a deployment with no `destroy` override sends. + http.delete<{ id: string }>(`${API}/integrations/:id`, ({ params }) => { + if (!install || install.id !== params.id) { + return HttpResponse.json(errorBody("Not found.", 404), { status: 404 }); + } + // Checked first: a refused disconnect removes nothing. + if (fx.disconnectRefusal) return refuse(fx.disconnectRefusal); + + install = null; + if (fx.revocation.revoked === null) { + return new HttpResponse(null, { status: 204 }); + } + return HttpResponse.json({ meta: { revoked: fx.revocation.revoked } }); + }), + ]; +}; diff --git a/ui/actions/auth/auth.test.ts b/ui/actions/auth/auth.test.ts index f9fd0f5b64..adf04ebcf1 100644 --- a/ui/actions/auth/auth.test.ts +++ b/ui/actions/auth/auth.test.ts @@ -21,7 +21,36 @@ vi.mock("@/lib/sentry-breadcrumbs", () => ({ addAuthEvent: vi.fn(), })); -import { createNewUser } from "./auth"; +import { createNewUser, getUserByMe } from "./auth"; + +const userMeResponse = (roleAttributes: Record) => ({ + data: { + type: "users", + id: "019b1234-5678-7abc-9def-0123456789ab", + attributes: { + name: "Jane Doe", + email: "jane@example.com", + company_name: "Prowler", + date_joined: "2026-01-01T00:00:00.000Z", + }, + }, + included: [ + { + type: "roles", + id: "role-1", + attributes: { name: "Cloud admin", ...roleAttributes }, + }, + ], +}); + +const mockUserMe = (roleAttributes: Record) => { + fetchMock.mockResolvedValue( + new Response(JSON.stringify(userMeResponse(roleAttributes)), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); +}; describe("auth actions", () => { beforeEach(() => { @@ -102,4 +131,166 @@ describe("auth actions", () => { expect(requestUrl.searchParams.get("promo_code")).toBe("black-hat-2026"); 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 }); + + // When + const result = await getUserByMe("access-token"); + + // Then + expect(result.permissions.manage_lighthouse_ai_configuration).toBe(true); + }); + + it("should default manage_lighthouse_ai_configuration to false when the role omits it", async () => { + // Given + mockUserMe({ manage_users: true }); + + // When + const result = await getUserByMe("access-token"); + + // Then + 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 37eb3f6b5c..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,60 +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, - 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/compliance-watchlist/compliance-watchlist.test.ts b/ui/actions/compliance-watchlist/compliance-watchlist.test.ts index 8e72d0bdc5..a2b3f57c8c 100644 --- a/ui/actions/compliance-watchlist/compliance-watchlist.test.ts +++ b/ui/actions/compliance-watchlist/compliance-watchlist.test.ts @@ -93,6 +93,7 @@ describe("getComplianceCatalog", () => { watchlistCount: 0, eligibleProviderTypes: [], }, + unavailable: true, }); expect(fetchMock).not.toHaveBeenCalled(); }); @@ -163,7 +164,7 @@ describe("getComplianceCatalog", () => { ); }); - it("degrades to an empty catalog when the request fails", async () => { + it("flags an empty catalog as unavailable when the request fails", async () => { fetchMock.mockResolvedValue(jsonResponse({ errors: [] }, 500)); const catalog = await getComplianceCatalog(); @@ -175,9 +176,21 @@ describe("getComplianceCatalog", () => { watchlistCount: 0, eligibleProviderTypes: [], }, + unavailable: true, }); }); + it("does not flag a catalog that is legitimately empty", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ data: [], meta: { pagination: { page: 1, pages: 1 } } }), + ); + + const catalog = await getComplianceCatalog(); + + expect(catalog.entries).toEqual([]); + expect(catalog.unavailable).toBe(false); + }); + it("degrades to an empty catalog when fetch throws", async () => { fetchMock.mockRejectedValue(new Error("network down")); @@ -186,7 +199,8 @@ describe("getComplianceCatalog", () => { expect(catalog.entries).toEqual([]); }); - it("keeps the rest of the catalog when a single page fails", async () => { + it("flags the catalog as unavailable when a single page fails", async () => { + // Given - one of three catalog pages times out fetchMock .mockResolvedValueOnce( jsonResponse(catalogPage("cis_1.4_aws", { page: 1, pages: 3 })), @@ -196,12 +210,15 @@ describe("getComplianceCatalog", () => { jsonResponse(catalogPage("iso27001_aws", { page: 3, pages: 3 })), ); + // When - the catalog keeps the pages it could read const catalog = await getComplianceCatalog(); + // Then - consumers know the merged result is incomplete expect(catalog.entries.map((entry) => entry.complianceId)).toEqual([ "cis_1.4_aws", "iso27001_aws", ]); + expect(catalog.unavailable).toBe(true); }); it("bounds how many pages it requests at once", async () => { diff --git a/ui/actions/compliance-watchlist/compliance-watchlist.ts b/ui/actions/compliance-watchlist/compliance-watchlist.ts index ebfee79884..0707a5d666 100644 --- a/ui/actions/compliance-watchlist/compliance-watchlist.ts +++ b/ui/actions/compliance-watchlist/compliance-watchlist.ts @@ -12,6 +12,7 @@ import { } from "@/lib/compliance/watchlist"; import type { ComplianceCatalog, + ComplianceCatalogLoad, ComplianceWatchlistActionResult, ComplianceWatchlistBulkDiff, ComplianceWatchlistTarget, @@ -45,6 +46,12 @@ const EMPTY_CATALOG: ComplianceCatalog = { meta: { totalEntries: 0, watchlistCount: 0, eligibleProviderTypes: [] }, }; +// Gave up without an answer: distinct from a legitimately empty catalog. +const UNAVAILABLE_CATALOG: ComplianceCatalogLoad = { + ...EMPTY_CATALOG, + unavailable: true, +}; + const GENERIC_ERROR = "Could not update the compliance watchlist."; const watchlistTargetSchema = z.object({ @@ -97,9 +104,9 @@ const buildCatalogUrl = (page: number, providerTypes?: string[]): string => { export const getComplianceCatalog = async ( input: { providerTypes?: string[] } = {}, -): Promise => { +): Promise => { const parsedInput = complianceCatalogInputSchema.safeParse(input); - if (!parsedInput.success) return EMPTY_CATALOG; + if (!parsedInput.success) return UNAVAILABLE_CATALOG; const { providerTypes } = parsedInput.data; @@ -127,15 +134,16 @@ export const getComplianceCatalog = async ( }; const firstPage = await fetchPage(1); - if (!firstPage) return EMPTY_CATALOG; + if (!firstPage) return UNAVAILABLE_CATALOG; const pageCount = Math.min( Math.max(1, firstPage.pageCount), MAX_CATALOG_PAGES, ); - if (pageCount <= 1) return firstPage.catalog; + if (pageCount <= 1) return { ...firstPage.catalog, unavailable: false }; const rest: ComplianceCatalog[] = []; + let incomplete = firstPage.pageCount > MAX_CATALOG_PAGES; for (let page = 2; page <= pageCount; page += CATALOG_FETCH_CONCURRENCY) { const batch = await Promise.all( Array.from( @@ -143,13 +151,17 @@ export const getComplianceCatalog = async ( (_, index) => fetchPage(page + index), ), ); + if (batch.some((pageResult) => pageResult === null)) incomplete = true; rest.push(...batch.flatMap((page) => (page ? [page.catalog] : []))); } - return mergeCatalogPages([firstPage.catalog, ...rest]); + return { + ...mergeCatalogPages([firstPage.catalog, ...rest]), + unavailable: incomplete, + }; } catch (error) { console.error("Error fetching compliance catalog:", error); - return EMPTY_CATALOG; + return UNAVAILABLE_CATALOG; } }; diff --git a/ui/actions/finding-groups/finding-groups.ts b/ui/actions/finding-groups/finding-groups.ts index faa697cf32..26182e4443 100644 --- a/ui/actions/finding-groups/finding-groups.ts +++ b/ui/actions/finding-groups/finding-groups.ts @@ -14,6 +14,7 @@ import { includesMutedFindings, splitCsvFilterValues, } from "@/lib"; +import { getFindingGroupFilterOptions } from "@/lib/finding-group-filter-options"; import { appendSanitizedProviderFilters } from "@/lib/provider-filters"; import { handleApiResponse } from "@/lib/server-actions-helper"; @@ -151,6 +152,25 @@ export const getLatestFindingGroups = async ( params: FetchFindingGroupsParams = {}, ) => fetchFindingGroupsEndpoint("finding-groups/latest", params); +/** + * Options for the "Finding Group" filter. Walks every finding-group page on the + * server, so the browser issues a single request instead of one per page + * (client-side Server Action calls are dispatched sequentially). + */ +export const getFindingGroupCheckOptions = async ({ + filters, + hasHistoricalData, +}: { + filters: Record; + hasHistoricalData: boolean; +}) => + getFindingGroupFilterOptions({ + fetchFindingGroups: hasHistoricalData + ? getFindingGroups + : getLatestFindingGroups, + filters, + }); + interface FetchFindingGroupResourcesParams { checkId: string; page?: number; diff --git a/ui/actions/findings/findings-by-resource.adapter.test.ts b/ui/actions/findings/findings-by-resource.adapter.test.ts index 24be4cd6ed..2c456a78a1 100644 --- a/ui/actions/findings/findings-by-resource.adapter.test.ts +++ b/ui/actions/findings/findings-by-resource.adapter.test.ts @@ -215,3 +215,72 @@ describe("adaptFindingsByResourceResponse — malformed input", () => { ); }); }); + +describe("adaptFindingsByResourceResponse — provider id", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should carry the provider id resolved through the scan include", () => { + // Given — scan.provider include path, as the drawer requests it + createDictMock.mockImplementation((type: string) => { + if (type === "scans") { + return { + "scan-1": { + id: "scan-1", + attributes: {}, + relationships: { provider: { data: { id: "provider-1" } } }, + }, + }; + } + if (type === "providers") { + return { + "provider-1": { + id: "provider-1", + attributes: { provider: "aws", alias: "prod", uid: "123" }, + }, + }; + } + return {}; + }); + + const input = { + data: { + id: "finding-1", + attributes: { + uid: "uid-1", + check_id: "s3_check", + status: "FAIL", + severity: "high", + check_metadata: {}, + }, + relationships: { + resources: { data: [] }, + scan: { data: { id: "scan-1" } }, + }, + }, + included: [], + }; + + // When + const [finding] = adaptFindingsByResourceResponse(input); + + // Then — the partial-scan request needs the id, not only the uid + expect(finding.providerId).toBe("provider-1"); + expect(finding.providerUid).toBe("123"); + }); + + it("should leave the provider id empty when the scan is not included", () => { + createDictMock.mockReturnValue({}); + + const [finding] = adaptFindingsByResourceResponse({ + data: { + id: "finding-1", + attributes: { uid: "uid-1", check_id: "s3_check", status: "FAIL" }, + relationships: { resources: { data: [] }, scan: { data: null } }, + }, + }); + + expect(finding.providerId).toBe(""); + }); +}); diff --git a/ui/actions/findings/findings-by-resource.adapter.ts b/ui/actions/findings/findings-by-resource.adapter.ts index 3ebae3c6c5..e4cd21abb1 100644 --- a/ui/actions/findings/findings-by-resource.adapter.ts +++ b/ui/actions/findings/findings-by-resource.adapter.ts @@ -64,6 +64,7 @@ export interface ResourceDrawerFinding { resourceDetails: string | null; resourceMetadata: Record | string | null; // Provider + providerId: string; providerType: ProviderType; providerAlias: string; providerUid: string; @@ -280,6 +281,7 @@ export function adaptFindingsByResourceResponse( | null | undefined) ?? null, // Provider + providerId: providerRelId ?? "", providerType: ((providerAttrs.provider as string | undefined) || "aws") as ProviderType, providerAlias: (providerAttrs.alias as string | undefined) || "", diff --git a/ui/actions/findings/findings-triage.adapter.test.ts b/ui/actions/findings/findings-triage.adapter.test.ts index c84500677c..3095ab3cb5 100644 --- a/ui/actions/findings/findings-triage.adapter.test.ts +++ b/ui/actions/findings/findings-triage.adapter.test.ts @@ -160,6 +160,57 @@ describe("adaptLatestFindingTriageNote", () => { }); describe("adaptFindingTriageSummariesResponse", () => { + it("should keep Resolved as triage while exposing effective Pass and manual provenance", () => { + // Given + const input = { + data: [ + { + id: "finding-manual-1", + type: "findings", + attributes: { + uid: "prowler-finding-manual-uid-1", + status: "PASS", + raw_status: "MANUAL", + triage_status: FINDING_TRIAGE_STATUS.RESOLVED, + manual_pass_created_at: "2026-06-03T10:00:00Z", + manual_pass_expires_at: "2026-09-01T10:00:00Z", + }, + }, + ], + }; + + // When + const [summary] = adaptFindingTriageSummariesResponse(input); + + // Then + expect(summary.rawFindingStatus).toBe("MANUAL"); + expect(input.data[0].attributes.status).toBe("PASS"); + expect(summary.label).toBe("Resolved"); + expect(summary.manualPassProvenance).toBe("Manually verified"); + }); + + it("should keep natural Pass free of manual provenance", () => { + const input = { + data: [ + { + id: "finding-natural-pass", + type: "findings", + attributes: { + uid: "natural-pass-uid", + status: "PASS", + raw_status: "PASS", + triage_status: FINDING_TRIAGE_STATUS.RESOLVED, + }, + }, + ], + }; + + const [summary] = adaptFindingTriageSummariesResponse(input); + + expect(summary.rawFindingStatus).toBe("PASS"); + expect(summary.manualPassProvenance).toBeNull(); + }); + it("should return [] when the provisional API response is malformed", () => { // Given const input = { meta: { count: 0 } }; @@ -380,6 +431,67 @@ describe("adaptFindingTriageSummariesResponse", () => { }); describe("adaptFindingTriageDetailResponse", () => { + it("should map expired Manual Pass metadata for provenance presentation", () => { + // Given + const input = { + data: { + id: "triage-expired-1", + type: "finding-triages", + attributes: { + finding_id: "finding-expired-1", + finding_uid: "prowler-finding-expired-uid-1", + status: FINDING_TRIAGE_STATUS.OPEN, + raw_finding_status: "MANUAL", + manual_pass_active: false, + manual_pass_evidence: "The control owner verified the requirement.", + manual_pass_created_by_name: "Alex Security", + manual_pass_created_at: "2026-06-03T10:00:00Z", + manual_pass_expires_at: "2026-06-17T10:00:00Z", + manual_pass_deactivated_at: null, + }, + }, + }; + + // When + const detail = adaptFindingTriageDetailResponse(input); + + // Then + expect(detail).toEqual( + expect.objectContaining({ + status: FINDING_TRIAGE_STATUS.OPEN, + manualPassActive: false, + manualPassEvidence: "The control owner verified the requirement.", + manualPassCreatedByName: "Alex Security", + manualPassCreatedAt: "2026-06-03T10:00:00Z", + manualPassExpiresAt: "2026-06-17T10:00:00Z", + manualPassDeactivatedAt: null, + }), + ); + }); + + it("should normalize missing Manual Pass evidence without inventing content", () => { + // Given + const input = { + data: { + id: "triage-without-evidence", + type: "finding-triages", + attributes: { + status: FINDING_TRIAGE_STATUS.OPEN, + manual_pass_active: false, + manual_pass_created_at: "2026-06-03T10:00:00Z", + manual_pass_expires_at: "2026-06-17T10:00:00Z", + }, + }, + }; + + // When + const detail = adaptFindingTriageDetailResponse(input); + + // Then + expect(detail.manualPassEvidence).toBeNull(); + expect(detail.manualPassDeactivatedAt).toBeNull(); + }); + it("should normalize provisional detail payloads into modal DTOs", () => { // Given const input = findingTriageDetailResponse; @@ -395,6 +507,10 @@ describe("adaptFindingTriageDetailResponse", () => { findingId: "finding-1", findingUid: "prowler-finding-uid-1", status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED, + rawFindingStatus: "MANUAL", + manualPassCreatedByName: "Alex Security", + manualPassCreatedAt: "2026-06-03T10:00:00Z", + manualPassExpiresAt: "2026-06-17T10:00:00Z", label: "Risk Accepted", hasVisibleNote: true, canEdit: true, diff --git a/ui/actions/findings/findings-triage.adapter.ts b/ui/actions/findings/findings-triage.adapter.ts index 2ddfec94e9..a810a83571 100644 --- a/ui/actions/findings/findings-triage.adapter.ts +++ b/ui/actions/findings/findings-triage.adapter.ts @@ -1,13 +1,16 @@ +import { FINDING_STATUS, type FindingStatus } from "@/types/components"; import { FINDING_TRIAGE_BILLING_HREF, FINDING_TRIAGE_NOTE_MAX_LENGTH, FINDING_TRIAGE_STATUS, FINDING_TRIAGE_STATUS_LABELS, + MANUAL_PASS_PROVENANCE, type FindingTriageDetail, type FindingTriageDisabledReason, type FindingTriageLoadedNote, type FindingTriageStatus, type FindingTriageSummary, + type FindingTriageUpdateResult, } from "@/types/findings-triage"; // API/backend triage implementation is external to this UI slice. Keep final @@ -25,6 +28,7 @@ interface FindingTriageAttributes { uid?: string; triage_id?: string; triage_notes_count?: number; + notes_count?: number; triage_status?: unknown; triage_has_note?: boolean; status?: unknown; @@ -34,6 +38,14 @@ interface FindingTriageAttributes { note?: string; has_note?: boolean; note_id?: string; + raw_finding_status?: unknown; + raw_status?: unknown; + manual_pass_active?: unknown; + manual_pass_evidence?: unknown; + manual_pass_created_by_name?: unknown; + manual_pass_created_at?: unknown; + manual_pass_expires_at?: unknown; + manual_pass_deactivated_at?: unknown; } interface JsonApiResource { @@ -59,10 +71,14 @@ const isFindingTriageStatus = (value: unknown): value is FindingTriageStatus => typeof value === "string" && Object.values(FINDING_TRIAGE_STATUS).includes(value as FindingTriageStatus); +const isRawFindingStatus = (value: unknown): value is FindingStatus => + typeof value === "string" && + Object.values(FINDING_STATUS).includes(value as FindingStatus); + const fallbackStatusFromFindingStatus = ( findingStatus: unknown, ): FindingTriageStatus => - findingStatus === "PASS" + findingStatus === FINDING_STATUS.PASS ? FINDING_TRIAGE_STATUS.RESOLVED : FINDING_TRIAGE_STATUS.OPEN; @@ -93,6 +109,13 @@ const createSummary = ( options: FindingTriageAdapterOptions, ): FindingTriageSummary => { const attributes = finding.attributes ?? {}; + const rawFindingStatus = isRawFindingStatus(attributes.raw_finding_status) + ? attributes.raw_finding_status + : isRawFindingStatus(attributes.raw_status) + ? attributes.raw_status + : isRawFindingStatus(attributes.status) + ? attributes.status + : null; const summary: FindingTriageSummary = { findingId: attributes.finding_id || finding.id || "", findingUid: attributes.uid || attributes.finding_uid || "", @@ -105,6 +128,13 @@ const createSummary = ( typeof attributes.muted === "boolean" ? attributes.muted : attributes.status === "MUTED", + rawFindingStatus, + manualPassProvenance: + attributes.status === FINDING_STATUS.PASS && + rawFindingStatus === FINDING_STATUS.MANUAL && + triageFields.status === FINDING_TRIAGE_STATUS.RESOLVED + ? MANUAL_PASS_PROVENANCE + : null, canEdit: options.canEdit ?? false, billingHref: options.billingHref ?? FINDING_TRIAGE_BILLING_HREF, }; @@ -192,19 +222,24 @@ export function adaptFindingTriageDetailResponse( : typeof attributes.note === "string" ? attributes.note : ""; + const notesCount = + attributes.triage_notes_count ?? + attributes.notes_count ?? + (noteBody.length > 0 ? 1 : 0); const summary = createSummary( { id: attributes.finding_id || data?.id || "", attributes: { finding_uid: attributes.finding_uid || "", triage_id: data?.id, - triage_notes_count: - attributes.triage_notes_count ?? (noteBody.length > 0 ? 1 : 0), + triage_notes_count: notesCount, + raw_finding_status: attributes.raw_finding_status, }, }, { status, - hasVisibleNote: attributes.has_note === true || noteBody.length > 0, + hasVisibleNote: + attributes.has_note === true || notesCount > 0 || noteBody.length > 0, }, options, ); @@ -214,5 +249,46 @@ export function adaptFindingTriageDetailResponse( noteId: attributes.note_id || null, noteBody, maxNoteLength: FINDING_TRIAGE_NOTE_MAX_LENGTH, + rawFindingStatus: isRawFindingStatus(attributes.raw_finding_status) + ? attributes.raw_finding_status + : null, + manualPassActive: + typeof attributes.manual_pass_active === "boolean" + ? attributes.manual_pass_active + : null, + manualPassEvidence: + typeof attributes.manual_pass_evidence === "string" + ? attributes.manual_pass_evidence + : null, + manualPassCreatedByName: + typeof attributes.manual_pass_created_by_name === "string" + ? attributes.manual_pass_created_by_name + : null, + manualPassCreatedAt: + typeof attributes.manual_pass_created_at === "string" + ? attributes.manual_pass_created_at + : null, + manualPassExpiresAt: + typeof attributes.manual_pass_expires_at === "string" + ? attributes.manual_pass_expires_at + : null, + manualPassDeactivatedAt: + typeof attributes.manual_pass_deactivated_at === "string" + ? attributes.manual_pass_deactivated_at + : null, + }; +} + +export function adaptFindingTriageUpdateResponse( + apiResponse: unknown, +): FindingTriageUpdateResult { + const data = + isRecord(apiResponse) && isJsonApiResource(apiResponse.data) + ? apiResponse.data + : undefined; + const expiresAt = data?.attributes?.manual_pass_expires_at; + + return { + manualPassExpiresAt: typeof expiresAt === "string" ? expiresAt : null, }; } diff --git a/ui/actions/findings/findings-triage.fixtures.ts b/ui/actions/findings/findings-triage.fixtures.ts index 879564ed8b..5dee77adcf 100644 --- a/ui/actions/findings/findings-triage.fixtures.ts +++ b/ui/actions/findings/findings-triage.fixtures.ts @@ -88,6 +88,10 @@ export const findingTriageDetailResponse = { finding_id: "finding-1", finding_uid: "prowler-finding-uid-1", status: "risk_accepted", + raw_finding_status: "MANUAL", + manual_pass_created_by_name: "Alex Security", + manual_pass_created_at: "2026-06-03T10:00:00Z", + manual_pass_expires_at: "2026-06-17T10:00:00Z", triage_notes_count: 1, has_note: true, note_id: "note-detail-1", diff --git a/ui/actions/findings/findings-triage.test.ts b/ui/actions/findings/findings-triage.test.ts index d494b64be5..ce0d0059b2 100644 --- a/ui/actions/findings/findings-triage.test.ts +++ b/ui/actions/findings/findings-triage.test.ts @@ -7,11 +7,17 @@ const { fetchMock, getAuthHeadersMock, handleApiResponseMock, + revalidatePathMock, } = vi.hoisted(() => ({ createMuteRuleMock: vi.fn(), fetchMock: vi.fn(), getAuthHeadersMock: vi.fn(), handleApiResponseMock: vi.fn(), + revalidatePathMock: vi.fn(), +})); + +vi.mock("next/cache", () => ({ + revalidatePath: revalidatePathMock, })); vi.mock("@/actions/mute-rules", () => ({ @@ -38,6 +44,75 @@ describe("findings triage actions", () => { fetchMock.mockResolvedValue(new Response(null, { status: 200 })); }); + it("should load authoritative manual pass context through the finding UID route", async () => { + // Given + const { loadFindingTriageDetail } = await importActions(); + handleApiResponseMock.mockResolvedValue({ + data: { + id: "triage-1", + type: "finding-triages", + attributes: { + finding_uid: "finding/stable/uid", + status: FINDING_TRIAGE_STATUS.UNDER_REVIEW, + raw_finding_status: "MANUAL", + notes_count: 0, + }, + }, + }); + + // When + const result = await loadFindingTriageDetail({ + findingId: "finding-snapshot-id", + findingUid: "finding/stable/uid", + triageId: "triage-1", + notesCount: 0, + status: FINDING_TRIAGE_STATUS.UNDER_REVIEW, + label: "Under Review", + hasVisibleNote: false, + isMuted: false, + canEdit: true, + billingHref: "https://prowler.com/pricing", + }); + + // Then + expect(fetchMock).toHaveBeenCalledWith( + "https://api.test/api/v1/findings/finding%2Fstable%2Fuid/triage", + expect.any(Object), + ); + expect(result).toEqual( + expect.objectContaining({ + findingId: "finding-snapshot-id", + findingUid: "finding/stable/uid", + rawFindingStatus: "MANUAL", + }), + ); + }); + + it("should reject API errors when loading triage detail", async () => { + // Given + const { loadFindingTriageDetail } = await importActions(); + handleApiResponseMock.mockResolvedValue({ + status: 403, + errors: [{ detail: "Finding triage access denied." }], + }); + + // When / Then + await expect( + loadFindingTriageDetail({ + findingId: "finding-snapshot-id", + findingUid: "finding/stable/uid", + triageId: "triage-1", + notesCount: 0, + status: FINDING_TRIAGE_STATUS.OPEN, + label: "Open", + hasVisibleNote: false, + isMuted: false, + canEdit: true, + billingHref: "https://prowler.com/pricing", + }), + ).rejects.toThrow("Finding triage access denied."); + }); + it("should load notes through the persisted triage route when triageId exists", async () => { // Given const { loadLatestFindingTriageNote } = await importActions(); @@ -185,6 +260,127 @@ describe("findings triage actions", () => { }), }), ); + expect(revalidatePathMock).not.toHaveBeenCalled(); + }); + + it("should send manual pass status and fresh evidence in one triage request", async () => { + // Given + const { updateFindingTriage } = await importActions(); + handleApiResponseMock.mockResolvedValue({ + data: { + id: "triage-1", + attributes: { + manual_pass_expires_at: "2026-10-28T12:00:00Z", + }, + }, + }); + + // When + const result = await updateFindingTriage({ + findingId: "finding-snapshot-id", + findingUid: "finding/stable/uid", + triageId: "triage-1", + notesCount: 1, + noteId: "note-1", + status: FINDING_TRIAGE_STATUS.RESOLVED, + previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW, + manualPassEvidence: "Fresh control-owner evidence.", + }); + + // Then + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + manualPassExpiresAt: "2026-10-28T12:00:00Z", + }); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.test/api/v1/finding-triages/triage-1", + expect.objectContaining({ + method: "PATCH", + body: JSON.stringify({ + data: { + type: "finding-triages", + attributes: { + status: FINDING_TRIAGE_STATUS.RESOLVED, + note: "Fresh control-owner evidence.", + }, + }, + }), + }), + ); + expect(revalidatePathMock).toHaveBeenCalledWith("/findings"); + }); + + it("should accept manual pass evidence at the maximum length", async () => { + // Given + const { updateFindingTriage } = await importActions(); + const manualPassEvidence = "a".repeat(500); + handleApiResponseMock.mockResolvedValue({ data: { id: "triage-1" } }); + + // When + await updateFindingTriage({ + findingId: "finding-snapshot-id", + findingUid: "finding/stable/uid", + triageId: "triage-1", + notesCount: 0, + status: FINDING_TRIAGE_STATUS.RESOLVED, + previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW, + manualPassEvidence, + }); + + // Then + expect(fetchMock).toHaveBeenCalledWith( + "https://api.test/api/v1/finding-triages/triage-1", + expect.objectContaining({ + method: "PATCH", + body: expect.stringContaining(manualPassEvidence), + }), + ); + }); + + it("should reject manual pass evidence over the maximum length", async () => { + // Given + const { updateFindingTriage } = await importActions(); + + // When / Then + await expect( + updateFindingTriage({ + findingId: "finding-snapshot-id", + findingUid: "finding/stable/uid", + triageId: "triage-1", + notesCount: 0, + status: FINDING_TRIAGE_STATUS.RESOLVED, + previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW, + manualPassEvidence: "a".repeat(501), + }), + ).rejects.toThrow("Manual pass evidence cannot exceed 500 characters."); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("should surface JSON:API validation details when the manual pass patch fails", async () => { + // Given + const { updateFindingTriage } = await importActions(); + handleApiResponseMock.mockResolvedValue({ + errors: [ + { + detail: "Manual pass evidence must describe the verification.", + source: { pointer: "/data/attributes/note" }, + }, + ], + }); + + // When / Then + await expect( + updateFindingTriage({ + findingId: "finding-snapshot-id", + findingUid: "finding/stable/uid", + triageId: "triage-1", + notesCount: 1, + noteId: "note-1", + status: FINDING_TRIAGE_STATUS.RESOLVED, + previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW, + manualPassEvidence: "Fresh control-owner evidence.", + }), + ).rejects.toThrow("Manual pass evidence must describe the verification."); }); it("should update an existing note through its note id", async () => { diff --git a/ui/actions/findings/findings-triage.ts b/ui/actions/findings/findings-triage.ts index c08c7a1335..8720a08807 100644 --- a/ui/actions/findings/findings-triage.ts +++ b/ui/actions/findings/findings-triage.ts @@ -1,11 +1,20 @@ "use server"; -import { adaptLatestFindingTriageNote } from "@/actions/findings/findings-triage.adapter"; +import { revalidatePath } from "next/cache"; + +import { + adaptFindingTriageUpdateResponse, + adaptFindingTriageDetailResponse, + adaptLatestFindingTriageNote, +} from "@/actions/findings/findings-triage.adapter"; import { createMuteRule } from "@/actions/mute-rules"; import { apiBaseUrl, getAuthHeaders } from "@/lib"; import { handleApiResponse } from "@/lib/server-actions-helper"; import { FINDING_TRIAGE_STATUS_LABELS, + FINDING_TRIAGE_NOTE_MAX_LENGTH, + FINDING_TRIAGE_STATUS, + type FindingTriageDetail, type FindingTriageLoadedNote, type FindingTriageSummary, isMutelistShortcutStatus, @@ -65,15 +74,28 @@ const throwIfApiError = (result: unknown) => { result && typeof result === "object" && ("error" in result || + ("errors" in result && Array.isArray(result.errors)) || ("status" in result && typeof result.status === "number" && result.status >= 400)) ) { - throw new Error( - "error" in result && typeof result.error === "string" + const jsonApiDetail = + "errors" in result && Array.isArray(result.errors) + ? result.errors.find((error): error is { detail: string } => + Boolean( + error && + typeof error === "object" && + "detail" in error && + typeof error.detail === "string", + ), + )?.detail + : undefined; + const message = + ("error" in result && typeof result.error === "string" ? result.error - : "Finding triage request failed.", - ); + : jsonApiDetail) || "Finding triage request failed."; + + throw new Error(message); } }; @@ -225,6 +247,28 @@ export async function loadLatestFindingTriageNote( return latestNote; } +export async function loadFindingTriageDetail( + triage: FindingTriageSummary, +): Promise { + const findingUid = triage.findingUid || (await resolveFindingUid(triage)); + const apiResponse = await getJsonApi( + `/findings/${encodePathSegment(findingUid)}/triage`, + ); + throwIfApiError(apiResponse); + const detail = adaptFindingTriageDetailResponse(apiResponse, { + canEdit: triage.canEdit, + disabledReason: triage.disabledReason, + billingHref: triage.billingHref, + }); + + return { + ...detail, + findingId: triage.findingId, + findingUid, + isMuted: triage.isMuted, + }; +} + export async function updateFindingTriage(input: UpdateFindingTriageInput) { let findingUid: string | undefined; let triagePath: `/${string}`; @@ -236,6 +280,29 @@ export async function updateFindingTriage(input: UpdateFindingTriageInput) { triagePath = `/findings/${encodePathSegment(findingUid)}/triage`; } + if (input.status === FINDING_TRIAGE_STATUS.RESOLVED) { + const evidence = input.manualPassEvidence?.trim(); + if (!evidence) { + throw new Error("Fresh evidence is required to pass a MANUAL finding."); + } + if (evidence.length > FINDING_TRIAGE_NOTE_MAX_LENGTH) { + throw new Error( + `Manual pass evidence cannot exceed ${FINDING_TRIAGE_NOTE_MAX_LENGTH} characters.`, + ); + } + + const result = await patchJsonApi( + triagePath, + buildFindingTriageBody({ + status: input.status, + note: evidence, + }), + ); + + revalidatePath("/findings"); + return adaptFindingTriageUpdateResponse(result); + } + if (input.note !== undefined && input.notesCount > 0 && input.noteId) { const notePath: `/${string}` = `${triagePath}/notes/${input.noteId}`; const noteResult = 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 8945842fe4..e90a4de502 100644 --- a/ui/actions/integrations/integrations.ts +++ b/ui/actions/integrations/integrations.ts @@ -2,23 +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; -}; +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 }); @@ -35,8 +31,7 @@ export const getIntegrations = async (searchParams?: URLSearchParams) => { return handleApiResponse(response); } catch (error) { - console.error("Error fetching integrations:", error); - return { data: [], meta: { pagination: { count: 0 } } }; + return handleApiError(error); } }; @@ -265,54 +260,9 @@ export const deleteIntegration = async ( } }; -type ConnectionTaskResult = { connected?: boolean; error?: 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`); @@ -324,41 +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"); - - 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.", - }; - } + return { + success: true, + message: + "Connection test started. It may take some time to complete.", + taskId, + data: parseStringify(data), + }; } else { return { success: false, @@ -378,33 +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"); - - 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.", - }; - } - } 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/integrations/slack.test.ts b/ui/actions/integrations/slack.test.ts new file mode 100644 index 0000000000..7776841dce --- /dev/null +++ b/ui/actions/integrations/slack.test.ts @@ -0,0 +1,753 @@ +/** + * What the Slack actions do off the DOM, which + * `slack-page.integration.test.tsx` cannot cover: which failures reach Sentry, + * and the URLs the channel listing's cursor pagination follows. + */ + +import { revalidatePath } from "next/cache"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + SLACK_GENERIC_ERROR_MESSAGE, + SLACK_PARTIAL_CHANNEL_LIST_MESSAGE, + SLACK_UNREADABLE_RESULT_MESSAGE, +} from "@/lib/integrations/slack-errors"; +import { SentryErrorSource, SentryErrorType } from "@/sentry"; + +const { captureExceptionMock, captureMessageMock, fetchMock } = vi.hoisted( + () => ({ + /** + * The real SDK marks the exception `__sentry_captured__`, and + * `handleApiError` reads that mark to avoid reporting the same throw twice. + */ + captureExceptionMock: vi.fn((exception: unknown, _options?: unknown) => { + if (exception !== null && typeof exception === "object") { + Object.defineProperty(exception, "__sentry_captured__", { + configurable: true, + value: true, + }); + } + }), + captureMessageMock: vi.fn(), + fetchMock: vi.fn(), + }), +); + +vi.mock("@sentry/nextjs", () => ({ + captureException: captureExceptionMock, + captureMessage: captureMessageMock, + // The task poll leaves breadcrumbs on every read it makes. + addBreadcrumb: vi.fn(), +})); + +vi.mock("next/cache", () => ({ + revalidatePath: vi.fn(), +})); + +// The real `handleApiResponse` reads its copy from `lib/helper`, which reaches +// next-auth through `@/auth.config`; stubbing the session lets that copy load. +vi.mock("@/auth.config", () => ({ + auth: vi.fn(() => Promise.resolve({ accessToken: "test-access-token" })), +})); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.test/api/v1", + getAuthHeaders: vi.fn(() => + Promise.resolve({ Authorization: "Bearer test-token" }), + ), + parseStringify: (value: unknown) => value, +})); + +import { + disconnectSlackIntegration, + exchangeSlackOAuthCode, + getSlackAuthorizeUrl, + getSlackChannels, + setSlackAuthorizedChannels, +} from "./slack"; + +/** The status the contract reserves for an upstream Slack failure. */ +const UPSTREAM_STATUS = 502; +const UPSTREAM_DETAIL = "Slack is temporarily unavailable."; +const GENERIC_SERVER_ERROR_MESSAGE = + "Server is temporarily unavailable. Please try again in a few minutes."; + +const errorResponse = (status: number, detail: string, code?: string) => + new Response( + JSON.stringify({ + errors: [ + { + status: String(status), + ...(code ? { code } : {}), + detail, + source: { pointer: "/data" }, + }, + ], + }), + { status, headers: { "content-type": "application/vnd.api+json" } }, + ); + +const exchange = () => + exchangeSlackOAuthCode({ code: "slack-code-1f4a", state: "st-2f1c9d7a" }); + +beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe.each([ + { action: getSlackAuthorizeUrl, name: "getSlackAuthorizeUrl" }, + { action: exchange, name: "exchangeSlackOAuthCode" }, +])("$name", ({ action }) => { + it("reports an upstream Slack failure instead of only turning it into copy", async () => { + // 502 covers `internal_error`, `fatal_error`, `service_unavailable` and + // transport failures. + fetchMock.mockResolvedValue( + errorResponse(UPSTREAM_STATUS, UPSTREAM_DETAIL, "service_unavailable"), + ); + + const result = await action(); + + // Once, not twice: `handleApiResponse` reports and throws, and the action's + // catch sees the mark. + expect(captureExceptionMock).toHaveBeenCalledTimes(1); + expect(captureExceptionMock.mock.calls[0]?.[1]).toMatchObject({ + tags: { + api_error: true, + error_source: SentryErrorSource.HANDLE_API_RESPONSE, + error_type: SentryErrorType.SERVER_ERROR, + status_code: String(UPSTREAM_STATUS), + }, + }); + expect(captureMessageMock).not.toHaveBeenCalled(); + + // The throw lands in the action's catch, so the page gets a result to + // render rather than a rejection that strands the callback on its spinner. + expect(result).toEqual({ error: UPSTREAM_DETAIL }); + }); + + it("answers a 5xx the API described in HTML in Prowler's own words", async () => { + fetchMock.mockResolvedValue( + new Response("

    502 Bad Gateway

    ", { + status: UPSTREAM_STATUS, + statusText: "Bad Gateway", + headers: { "content-type": "text/html" }, + }), + ); + + const result = await action(); + + expect(result).toEqual({ error: GENERIC_SERVER_ERROR_MESSAGE }); + expect(captureExceptionMock).toHaveBeenCalledTimes(1); + }); + + it.each([503, 404])( + "reports nothing for a %s: that is the feature being dark, not a fault", + async (status) => { + // 503 means `SLACK_CLIENT_*` is unset; 404 means no Slack API is served + // in this deployment at all. + fetchMock.mockResolvedValue( + errorResponse(status, "Slack integration is not configured."), + ); + + const result = await action(); + + // Capturing this would report the deliberate ship-dark state from every + // tenant on every page load. + expect(result).toEqual({ unavailable: true }); + expect(captureExceptionMock).not.toHaveBeenCalled(); + }, + ); + + it("reports nothing when Slack is rate limiting: it is a wait, not a fault", async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + errors: [{ status: "429", detail: "Slack is rate limiting." }], + }), + { + status: 429, + headers: { + "content-type": "application/vnd.api+json", + "Retry-After": "30", + }, + }, + ), + ); + + const result = await action(); + + expect(result).toMatchObject({ rateLimited: true, retryAfterSeconds: 30 }); + expect(captureExceptionMock).not.toHaveBeenCalled(); + }); +}); + +/** + * The URL is rendered as the `Add to Slack` link's `href`, so a value the API + * got wrong must not become a redirect to somewhere that is not Slack. + */ +describe("getSlackAuthorizeUrl authorize URL", () => { + const NO_AUTHORIZE_URL_MESSAGE = "Slack did not return an authorization URL."; + const CONSENT_SCREEN_URL = + "https://slack.com/oauth/v2/authorize" + + "?client_id=1234567890.0987654321&state=st-2f1c9d7a"; + + const authorizeUrlResponse = (authorizeUrl: unknown) => + new Response(JSON.stringify({ meta: { authorize_url: authorizeUrl } }), { + status: 200, + headers: { "content-type": "application/vnd.api+json" }, + }); + + it.each([ + ["a hostile scheme", "javascript:alert(document.domain)"], + ["plain HTTP", "http://slack.com/oauth/v2/authorize?client_id=1"], + ["another origin", "https://evil.test/oauth/v2/authorize?client_id=1"], + ["a lookalike hostname", "https://slack.com.evil.test/oauth/v2/authorize"], + [ + "another Slack path", + "https://slack.com/redirect?to=https%3A%2F%2Fevil.test", + ], + ["a value that is not a URL", "oauth/v2/authorize"], + ])( + "refuses %s instead of offering it as the install link", + async (_label, authorizeUrl) => { + // Given — a 2xx whose `meta.authorize_url` is not Slack's consent screen. + fetchMock.mockResolvedValue(authorizeUrlResponse(authorizeUrl)); + + // When + const result = await getSlackAuthorizeUrl(); + + // Then — the answer for no URL at all: nothing here is safe to link to. + expect(result).toEqual({ error: NO_AUTHORIZE_URL_MESSAGE }); + }, + ); + + it("hands over Slack's consent screen with its query untouched", async () => { + // Given + fetchMock.mockResolvedValue(authorizeUrlResponse(CONSENT_SCREEN_URL)); + + // When / Then + expect(await getSlackAuthorizeUrl()).toEqual({ + authorizeUrl: CONSENT_SCREEN_URL, + }); + }); +}); + +/** + * The callback names the workspace and redirects on `integration` alone, so a + * `2xx` body it cannot read back as an integration must not reach it. + */ +describe("exchangeSlackOAuthCode result shape", () => { + const INTEGRATION = { + id: "9b1f4c22-5e7a-4c2e-8f0d-6a3b1c9d7e42", + type: "integrations", + attributes: { + integration_type: "slack", + configuration: { team_name: "Prowler HQ" }, + }, + }; + + const exchangeResponse = (data: unknown) => + new Response(JSON.stringify({ data }), { + status: 200, + headers: { "content-type": "application/vnd.api+json" }, + }); + + it.each<[string, unknown]>([ + ["an empty object", {}], + ["an array", []], + ["a bare string", "invalid"], + ["a resource with no id", { type: "integrations", attributes: {} }], + ["a resource with an empty id", { ...INTEGRATION, id: "" }], + ["a resource of another type", { ...INTEGRATION, type: "tasks" }], + [ + "a resource with no attributes", + { id: INTEGRATION.id, type: "integrations" }, + ], + [ + "another kind of integration", + { + ...INTEGRATION, + attributes: { ...INTEGRATION.attributes, integration_type: "jira" }, + }, + ], + ])("cannot confirm the install from %s", async (_label, data) => { + // Given — a 2xx whose `data` is truthy but is not an integration resource. + fetchMock.mockResolvedValue(exchangeResponse(data)); + + // When + const result = await exchange(); + + // Then — the answer for a body with no `data`: the install happened, only + // its result is unknown. + expect(result).toEqual({ unconfirmed: true }); + }); + + it("hands over the workspace the API upserted", async () => { + // Given + fetchMock.mockResolvedValue(exchangeResponse(INTEGRATION)); + + // When / Then + expect(await exchange()).toEqual({ integration: INTEGRATION }); + }); +}); + +/** The shape the API's integration ids have, which is the only shape accepted. */ +const SLACK_INTEGRATION_ID = "b2c7fd0a-3e51-4d8f-9a6c-1f0e2d3c4b5a"; + +const CHANNELS_URL = + `https://api.test/api/v1/integrations/${SLACK_INTEGRATION_ID}` + + "/slack/channels"; + +const FIRST_CHANNEL = { id: "C0123AB", name: "security" }; +const SECOND_CHANNEL = { id: "C0789EF", name: "platform" }; + +const channelPage = ( + channel: { id: string; name: string }, + next: string | null, +) => + new Response( + JSON.stringify({ + data: [ + { + type: "slack-channels", + id: channel.id, + attributes: { name: channel.name, is_private: false }, + }, + ], + links: { next }, + }), + { status: 200, headers: { "content-type": "application/vnd.api+json" } }, + ); + +const channelOption = (channel: { id: string; name: string }) => ({ + id: channel.id, + name: channel.name, + is_private: false, +}); + +const requestedUrls = (): string[] => + fetchMock.mock.calls.map(([url]) => String(url)); + +const sentBody = (callIndex = 0): unknown => + JSON.parse(String(fetchMock.mock.calls[callIndex]?.[1]?.body)); + +/** + * `MAX_CHANNEL_PAGES` in the action, which a `"use server"` module cannot + * export: only async functions may leave one. + */ +const MAX_CHANNEL_PAGES = 20; + +const channelOptions = (count: number) => + Array.from({ length: count }, () => channelOption(FIRST_CHANNEL)); + +/** What a `429` carrying `Retry-After: 30` is turned into. */ +const RATE_LIMITED_MESSAGE = + "Slack is rate limiting Prowler right now. Try again in about 30 seconds."; + +/** A dead grant as the API reports it: reason in `code`, prose in `detail`. */ +const TOKEN_EXPIRED_CODE = "token_expired"; +const TOKEN_EXPIRED_DETAIL = "Slack refused the request: token_expired."; +const TOKEN_EXPIRED_MESSAGE = + "Prowler's Slack credential has expired. Connect the workspace again to restore access."; + +describe("getSlackChannels", () => { + it("follows a cursor-only `next` on the listing's own URL, not on the API root", async () => { + // The link is opaque (design D6), so the API may answer with the cursor + // alone; resolved against the API root it loses the listing's own path. + fetchMock + .mockResolvedValueOnce(channelPage(FIRST_CHANNEL, "?page[cursor]=2")) + .mockResolvedValueOnce(channelPage(SECOND_CHANNEL, null)); + + const result = await getSlackChannels(SLACK_INTEGRATION_ID); + + expect(requestedUrls()).toEqual([ + CHANNELS_URL, + `${CHANNELS_URL}?page[cursor]=2`, + ]); + expect(result).toEqual({ + channels: [channelOption(FIRST_CHANNEL), channelOption(SECOND_CHANNEL)], + }); + }); + + it.each([ + { + shape: "an absolute", + next: "https://evil.test/api/v1/integrations/x/slack/channels?cursor=2", + }, + { shape: "a protocol-relative", next: "//evil.test/api/v1/channels?c=2" }, + ])( + "stops at $shape off-origin `next` rather than sending the tenant's token to it", + async ({ next }) => { + // `fetch` strips the tenant's `Authorization` on a redirect that leaves + // the origin, but not on a hop the UI makes itself. + fetchMock.mockResolvedValueOnce(channelPage(FIRST_CHANNEL, next)); + + const result = await getSlackChannels(SLACK_INTEGRATION_ID); + + expect(requestedUrls()).toEqual([CHANNELS_URL]); + expect(result).toEqual({ + channels: [channelOption(FIRST_CHANNEL)], + incomplete: SLACK_PARTIAL_CHANNEL_LIST_MESSAGE, + }); + }, + ); + + it("answers an unreadable page as no channels rather than parser prose", async () => { + fetchMock.mockResolvedValueOnce(unreadableOk(HTML_INTERSTITIAL)); + + const result = await getSlackChannels(SLACK_INTEGRATION_ID); + + expect(result).toEqual({ channels: [] }); + expectNoParserProse(result); + }); + + it("says the list is short of the workspace when the page budget runs out", async () => { + // The budget exists because `conversations.list` is tier 2 and a workspace + // can outgrow it (design.md, Risks). A fresh `Response` per call: one + // instance is already consumed on its second read. + fetchMock.mockImplementation(() => + Promise.resolve(channelPage(FIRST_CHANNEL, "?page[cursor]=next")), + ); + + const result = await getSlackChannels(SLACK_INTEGRATION_ID); + + expect(fetchMock).toHaveBeenCalledTimes(MAX_CHANNEL_PAGES); + expect(result).toEqual({ + channels: channelOptions(MAX_CHANNEL_PAGES), + incomplete: SLACK_PARTIAL_CHANNEL_LIST_MESSAGE, + }); + }); + + it("says nothing about a short list for a workspace that just fits the budget", async () => { + let page = 0; + fetchMock.mockImplementation(() => { + page += 1; + return Promise.resolve( + channelPage( + FIRST_CHANNEL, + page < MAX_CHANNEL_PAGES ? `?page[cursor]=${page}` : null, + ), + ); + }); + + const result = await getSlackChannels(SLACK_INTEGRATION_ID); + + expect(fetchMock).toHaveBeenCalledTimes(MAX_CHANNEL_PAGES); + expect(result).toEqual({ channels: channelOptions(MAX_CHANNEL_PAGES) }); + expect(result).not.toHaveProperty("incomplete"); + }); + + it("keeps the pages it read when a later one is refused, saying why the list stops", async () => { + fetchMock + .mockResolvedValueOnce(channelPage(FIRST_CHANNEL, "?page[cursor]=2")) + .mockResolvedValueOnce(rateLimitedResponse()); + + const result = await getSlackChannels(SLACK_INTEGRATION_ID); + + // A rate limit says nothing about the grant, so the truncation names none. + expect(result).toEqual({ + channels: [channelOption(FIRST_CHANNEL)], + incomplete: RATE_LIMITED_MESSAGE, + code: null, + }); + }); + + it("names the reason a later page was refused, not only the wording", async () => { + fetchMock + .mockResolvedValueOnce(channelPage(FIRST_CHANNEL, "?page[cursor]=2")) + .mockResolvedValueOnce( + errorResponse(400, TOKEN_EXPIRED_DETAIL, TOKEN_EXPIRED_CODE), + ); + + const result = await getSlackChannels(SLACK_INTEGRATION_ID); + + expect(result).toEqual({ + channels: [channelOption(FIRST_CHANNEL)], + incomplete: TOKEN_EXPIRED_MESSAGE, + code: TOKEN_EXPIRED_CODE, + }); + }); + + it("answers a refusal on the first page as a failure, having nothing to show", async () => { + fetchMock.mockResolvedValueOnce(rateLimitedResponse()); + + const result = await getSlackChannels(SLACK_INTEGRATION_ID); + + expect(result).toEqual({ + error: RATE_LIMITED_MESSAGE, + code: null, + status: 429, + }); + }); +}); + +/** + * A `2xx` whose body is not JSON:API: an empty answer, or the HTML a proxy or + * WAF puts in front of one. The raw `SyntaxError` survives + * `sanitizeErrorMessage` (V8 truncates the snippet to ten characters, so its + * `` branch never matches) and would be shown verbatim. + */ +const HTML_INTERSTITIAL = + "

    Checking your browser

    "; + +const unreadableOk = (body: string) => + new Response(body, { + status: 200, + headers: { "content-type": body ? "text/html" : "application/json" }, + }); + +/** V8's parser wording, which no user should ever be shown. */ +const PARSER_PROSE = /unexpected (token|end of json)|not valid json/i; + +const expectNoParserProse = (result: unknown) => { + const message = (result as { error?: string }).error ?? ""; + expect(message).not.toMatch(PARSER_PROSE); +}; + +const INTEGRATION_URL = `https://api.test/api/v1/integrations/${SLACK_INTEGRATION_ID}`; + +const saveChannels = () => + setSlackAuthorizedChannels(SLACK_INTEGRATION_ID, [FIRST_CHANNEL.id]); + +/** The save as the API answers it: the channels' names derived server-side. */ +const savedIntegration = () => + new Response( + JSON.stringify({ + data: { + type: "integrations", + id: SLACK_INTEGRATION_ID, + attributes: { + integration_type: "slack", + configuration: { + channels: [ + { + id: FIRST_CHANNEL.id, + name: FIRST_CHANNEL.name, + is_private: false, + confirmation_sent_at: null, + }, + ], + }, + }, + }, + }), + { + status: 200, + headers: { "content-type": "application/vnd.api+json" }, + }, + ); + +const expectIntegrationsRevalidated = () => { + expect(vi.mocked(revalidatePath).mock.calls).toEqual([ + ["/integrations"], + ["/integrations/slack"], + ]); +}; + +describe("setSlackAuthorizedChannels", () => { + it("returns the saved integration and revalidates the pages listing it", async () => { + fetchMock.mockResolvedValueOnce(savedIntegration()); + + const result = await saveChannels(); + + expect(requestedUrls()).toEqual([INTEGRATION_URL]); + expect(result).toMatchObject({ + integration: { + attributes: { + configuration: { + channels: [expect.objectContaining({ name: FIRST_CHANNEL.name })], + }, + }, + }, + }); + expectIntegrationsRevalidated(); + }); + + // The write serializer names whatever it will not take and refuses the whole + // save, so a body that also carried the integration's own (immutable) type + // came back as `Invalid fields: {'integration_type'}` and recorded nothing. + it("submits the channels as the save's only attribute, naming nothing but their ids", async () => { + fetchMock.mockResolvedValueOnce(savedIntegration()); + + await saveChannels(); + + expect(sentBody()).toEqual({ + data: { + type: "integrations", + id: SLACK_INTEGRATION_ID, + attributes: { + configuration: { channels: [{ id: FIRST_CHANNEL.id }] }, + }, + }, + }); + }); + + it("submits a channel once, however many times the caller named it", async () => { + fetchMock.mockResolvedValueOnce(savedIntegration()); + + await setSlackAuthorizedChannels(SLACK_INTEGRATION_ID, [ + FIRST_CHANNEL.id, + FIRST_CHANNEL.id, + ]); + + expect(sentBody()).toMatchObject({ + data: { + attributes: { + configuration: { channels: [{ id: FIRST_CHANNEL.id }] }, + }, + }, + }); + }); + + it.each([ + { shape: "empty", body: "" }, + { shape: "an HTML interstitial", body: HTML_INTERSTITIAL }, + ])( + "answers a $shape `200` as an unread result, not as a failed save", + async ({ body }) => { + fetchMock.mockResolvedValueOnce(unreadableOk(body)); + + const result = await saveChannels(); + + expect(result).toEqual({ error: SLACK_UNREADABLE_RESULT_MESSAGE }); + expectNoParserProse(result); + // The API recorded the channels before answering, so both pages refresh. + expectIntegrationsRevalidated(); + }, + ); + + // The caller reads `integration.attributes.configuration`, so a shallower + // guard lets the miss surface later as the manager's generic catch. + it.each([ + { shape: "no `data`", body: {} }, + { shape: "a null `data`", body: { data: null } }, + { shape: "a `data` with no configuration", body: { data: {} } }, + ])( + "answers a `200` carrying $shape as an unread result", + async ({ body }) => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/vnd.api+json" }, + }), + ); + + const result = await saveChannels(); + + expect(result).toEqual({ error: SLACK_UNREADABLE_RESULT_MESSAGE }); + expectNoParserProse(result); + expectIntegrationsRevalidated(); + }, + ); +}); + +/** The calls whose only failure path is one line of copy. */ +const COPY_ONLY_ACTIONS = [ + { + name: "getSlackChannels", + call: (id: string) => getSlackChannels(id), + }, + { + name: "setSlackAuthorizedChannels", + call: (id: string) => setSlackAuthorizedChannels(id, [FIRST_CHANNEL.id]), + }, + { + name: "disconnectSlackIntegration", + call: (id: string) => disconnectSlackIntegration(id), + }, +]; + +const rateLimitedResponse = () => + new Response( + JSON.stringify({ + errors: [{ status: "429", detail: "Slack is rate limiting." }], + }), + { + status: 429, + headers: { + "content-type": "application/vnd.api+json", + "Retry-After": "30", + }, + }, + ); + +describe.each(COPY_ONLY_ACTIONS)("$name", ({ call }) => { + it("reports an upstream Slack failure and still answers in the same words", async () => { + fetchMock.mockResolvedValue( + errorResponse(UPSTREAM_STATUS, UPSTREAM_DETAIL), + ); + + const result = await call(SLACK_INTEGRATION_ID); + + // Once, not twice: `handleApiResponse` reports and throws, and the action's + // catch sees the mark. + expect(captureExceptionMock).toHaveBeenCalledTimes(1); + expect(captureExceptionMock.mock.calls[0]?.[1]).toMatchObject({ + tags: { + api_error: true, + error_source: SentryErrorSource.HANDLE_API_RESPONSE, + error_type: SentryErrorType.SERVER_ERROR, + status_code: String(UPSTREAM_STATUS), + }, + }); + expect(captureMessageMock).not.toHaveBeenCalled(); + + expect(result).toEqual({ error: UPSTREAM_DETAIL }); + }); + + it.each([ + { + status: 503, + why: "Slack being unavailable, not a fault", + response: () => errorResponse(503, "Slack is unavailable."), + expected: "Slack is unavailable.", + }, + { + status: 429, + why: "a wait, not a fault", + response: rateLimitedResponse, + expected: + "Slack is rate limiting Prowler right now. Try again in about 30 seconds.", + }, + { + status: 400, + why: "a refusal the API meant to give", + response: () => errorResponse(400, "The integration is not connected."), + expected: "The integration is not connected.", + }, + ])("reports nothing for a $status: that is $why", async (refusal) => { + fetchMock.mockResolvedValue(refusal.response()); + + const result = await call(SLACK_INTEGRATION_ID); + + expect(captureExceptionMock).not.toHaveBeenCalled(); + expect(captureMessageMock).not.toHaveBeenCalled(); + // None of these refusals names a `code`. + expect(result).toEqual({ + error: refusal.expected, + code: null, + status: refusal.status, + }); + }); +}); + +/** + * The integration id is interpolated into every one of these URLs, so a + * malformed one is refused before the request is built. + */ +describe.each(COPY_ONLY_ACTIONS)("$name", ({ call }) => { + it.each(["../../users", "not-a-uuid", ""])( + "asks the API nothing when the integration id is %o", + async (id) => { + const result = await call(id); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(result).toEqual({ error: SLACK_GENERIC_ERROR_MESSAGE }); + }, + ); +}); diff --git a/ui/actions/integrations/slack.ts b/ui/actions/integrations/slack.ts new file mode 100644 index 0000000000..dd5170129d --- /dev/null +++ b/ui/actions/integrations/slack.ts @@ -0,0 +1,586 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; + +import { apiBaseUrl, getAuthHeaders, parseStringify } from "@/lib"; +import { + readSlackFailure, + SLACK_GENERIC_ERROR_MESSAGE, + SLACK_PARTIAL_CHANNEL_LIST_MESSAGE, + SLACK_UNREADABLE_RESULT_MESSAGE, + slackErrorMessage, + slackRateLimitMessage, +} from "@/lib/integrations/slack-errors"; +import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper"; +import { + INTEGRATION_TYPE, + type IntegrationProps, + type SlackChannelOption, +} from "@/types/integrations"; + +interface SlackUnavailable { + unavailable: true; +} + +interface SlackRateLimited { + rateLimited: true; + retryAfterSeconds: number | null; + message: string; +} + +/** + * The API accepted the exchange (`2xx`) and the UI could not read the workspace + * back: the install happened, only its result is unknown. + */ +interface SlackUnconfirmed { + unconfirmed: true; +} + +interface SlackActionError { + error: string; + /** + * The refusal's `code`, when it named one, alongside the copy. A caller reads + * it to recognise a class of failure the wording cannot be pattern-matched + * for — a Slack grant that has stopped working, which the contract allows + * from any of these calls (Cross-cutting) and is recovered from by + * reconnecting rather than by retrying. + */ + code?: string | null; + /** + * The refusal's HTTP status, for callers that map a class of refusal the + * `code` does not name — the exchange's code-less `400` for a consumed + * state/code, which no retry can fix. + */ + status?: number; +} + +interface SlackAuthorizeUrl { + authorizeUrl: string; +} + +export type SlackAuthorizeUrlResult = + | SlackAuthorizeUrl + | SlackUnavailable + | SlackRateLimited + | SlackActionError; + +interface SlackExchangeInput { + code: string; + state: string; +} + +const slackExchangeInputSchema = z.object({ + code: z.string().min(1), + state: z.string().min(1), +}); + +/** + * SSRF guard: the integration id is interpolated into the request URL, so only + * the shape the API's ids have reaches it. + */ +const integrationIdSchema = z.uuid(); + +const parseIntegrationId = (integrationId: string): string | null => { + const parsed = integrationIdSchema.safeParse(integrationId); + return parsed.success ? parsed.data : null; +}; + +interface SlackExchangeSuccess { + integration: IntegrationProps; +} + +export type SlackExchangeResult = + | SlackExchangeSuccess + | SlackUnavailable + | SlackRateLimited + | SlackUnconfirmed + | SlackActionError; + +/** + * `503`: no Slack app configured in this deployment. `404`: no Slack API at + * all. Both mean "not available here", unlike `429`/`502` which mean "not now". + */ +const isUnavailableStatus = (status: number): boolean => + status === 503 || status === 404; + +const RATE_LIMITED_STATUS = 429; + +const SLACK_AUTHORIZE_HOSTNAME = "slack.com"; +const SLACK_AUTHORIZE_PATHNAME = "/oauth/v2/authorize"; +const NO_AUTHORIZE_URL_MESSAGE = "Slack did not return an authorization URL."; + +/** + * The URL is rendered as the `Add to Slack` link's `href`, so anything that is + * not Slack's consent screen is a redirect to an origin the user did not choose. + */ +const isSlackAuthorizeUrl = (value: string): boolean => { + try { + const url = new URL(value); + return ( + url.protocol === "https:" && + url.hostname === SLACK_AUTHORIZE_HOSTNAME && + url.pathname === SLACK_AUTHORIZE_PATHNAME + ); + } catch { + return false; + } +}; + +const INTEGRATIONS_RESOURCE_TYPE = "integrations"; + +/** + * The callback names the workspace and redirects on this value alone, so a `2xx` + * payload that is not a JSON:API resource (`{}`, `[]`, `"invalid"`) must read as + * unreadable rather than as a connected workspace. Identity too: a resource + * that is not a linkable Slack integration would be shown as the workspace + * just installed. + */ +const isIntegrationResource = (value: unknown): boolean => { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + + const { id, type, attributes } = value as Record; + + if ( + typeof id !== "string" || + id === "" || + type !== INTEGRATIONS_RESOURCE_TYPE || + typeof attributes !== "object" || + attributes === null || + Array.isArray(attributes) + ) { + return false; + } + + return ( + (attributes as Record).integration_type === + INTEGRATION_TYPE.SLACK + ); +}; + +const failureFrom = async ( + response: Response, + fallback: string, +): Promise => { + if (isUnavailableStatus(response.status)) return { unavailable: true }; + + // A 5xx (including the `502` the contract reserves for "Slack upstream + // broke") goes through the repo's 5xx handling, which reports to Sentry and + // throws, so the caller's catch answers the user. Must run before + // `readSlackFailure`: a body can only be read once. + if (response.status >= 500) await handleApiResponse(response); + + const failure = await readSlackFailure(response); + + if (failure.status === RATE_LIMITED_STATUS) { + return { + rateLimited: true, + retryAfterSeconds: failure.retryAfterSeconds, + message: slackRateLimitMessage(failure.retryAfterSeconds), + }; + } + + return { + error: slackErrorMessage(failure, fallback), + code: failure.code, + status: failure.status, + }; +}; + +/** + * `failureFrom` flattened to one refusal, for the calls whose only outcome is + * "it did not work". Rate limiting keeps its own wording: `conversations.list` + * is Slack tier 2, so a `429` shows up here (contract, Errors) and the wait it + * names is the useful part. + */ +const refusalFrom = async ( + response: Response, + fallback: string, +): Promise => { + // Same 5xx handling as `failureFrom`, `503` excepted: here too it means Slack + // is unavailable. Must run before `readSlackFailure`: a body can only be read + // once. + if (response.status >= 500 && response.status !== 503) { + await handleApiResponse(response); + } + + const failure = await readSlackFailure(response); + + return { + error: + failure.status === RATE_LIMITED_STATUS + ? slackRateLimitMessage(failure.retryAfterSeconds) + : slackErrorMessage(failure, fallback), + code: failure.code, + status: failure.status, + }; +}; + +/** Mint an OAuth state and get the consent URL. Creates no integration. */ +export const getSlackAuthorizeUrl = + async (): Promise => { + const headers = await getAuthHeaders({ contentType: true }); + const url = new URL(`${apiBaseUrl}/integrations/slack/oauth/authorize-url`); + + try { + const response = await fetch(url.toString(), { method: "POST", headers }); + + if (!response.ok) { + // Awaited inside the `try`: a returned promise's rejection would skip + // this `catch`, and a 5xx rejects. + return await failureFrom( + response, + `Unable to start the Slack install: ${response.statusText}`, + ); + } + + // The URL travels in JSON:API `meta`: the call creates no resource. A + // non-JSON `2xx` reads as "no URL" instead of throwing a parser message + // the user would be shown verbatim. + const body = await response.json().catch(() => null); + const authorizeUrl = body?.meta?.authorize_url; + + // A URL that is not Slack's own is no more usable than a missing one. + if ( + typeof authorizeUrl !== "string" || + !isSlackAuthorizeUrl(authorizeUrl) + ) { + return { error: NO_AUTHORIZE_URL_MESSAGE }; + } + + return { authorizeUrl }; + } catch (error) { + return handleApiError(error); + } + }; + +/** + * Complete the install with what Slack put in the callback URL. The API + * consumes the `state`, exchanges the single-use `code`, and upserts the + * tenant's Slack integration. + */ +export const exchangeSlackOAuthCode = async ( + input: SlackExchangeInput, +): Promise => { + const parsed = slackExchangeInputSchema.safeParse(input); + if (!parsed.success) return { error: SLACK_GENERIC_ERROR_MESSAGE }; + + const { code, state } = parsed.data; + const headers = await getAuthHeaders({ contentType: true }); + const url = new URL(`${apiBaseUrl}/integrations/slack/oauth/exchange`); + + try { + const response = await fetch(url.toString(), { + method: "POST", + headers, + body: JSON.stringify({ + data: { + type: "slack-oauth-exchanges", + attributes: { code, state }, + }, + }), + }); + + if (!response.ok) { + // Awaited inside the `try`: unawaited, a 5xx's rejection would skip this + // `catch` and leave the callback on its spinner. + return await failureFrom( + response, + `Unable to connect the Slack workspace: ${response.statusText}`, + ); + } + + const body = await response.json().catch(() => null); + + // Before the guard and on both paths: the API upserted the integration + // before answering, so a cache filled when there was none would list the + // connected workspace as missing. + revalidatePath("/integrations"); + revalidatePath("/integrations/slack"); + + if (!isIntegrationResource(body?.data)) { + // The discriminant is the whole answer: the notice on the integration + // page carries its own unconfirmed copy. + return { unconfirmed: true }; + } + + return { integration: parseStringify(body.data) as IntegrationProps }; + } catch (error) { + return handleApiError(error); + } +}; + +interface SlackChannelsSuccess { + channels: SlackChannelOption[]; + /** + * Present when these channels are only part of the workspace's, carrying the + * sentence that says why: a partial read is a success, so the caller renders + * the picker *and* the reason. + */ + incomplete?: string; + /** + * The `code` of the refusal that cut the read short, when it named one. A + * grant that has stopped working refuses the second cursor page exactly as it + * refuses the first, and a caller reading only the failure path would never + * hear about it. + */ + code?: string | null; +} + +export type SlackChannelsResult = SlackChannelsSuccess | SlackActionError; + +/** + * Cursor pages followed before giving up: `conversations.list` is a tier-2, + * rate-limited Slack call (design.md, Risks), so the aggregation is bounded + * rather than open-ended. + */ +const MAX_CHANNEL_PAGES = 20; + +/** + * Every channel Prowler can post to in the connected workspace — the picker's + * options. + * + * The durable primitive, not the channels recorded on the integration + * (design D6): a consumer needing a per-rule channel reads the same endpoint. + * `links.next` is followed opaquely — the contract does not pin the cursor + * parameter naming, so the UI never builds one of its own. An early stop that + * still read something reports through `incomplete`, not as a failure. + */ +export const getSlackChannels = async ( + integrationId: string, +): Promise => { + const id = parseIntegrationId(integrationId); + if (!id) return { error: SLACK_GENERIC_ERROR_MESSAGE }; + + const headers = await getAuthHeaders({ contentType: false }); + const channels: SlackChannelOption[] = []; + + const listing = new URL(`${apiBaseUrl}/integrations/${id}/slack/channels`); + let next: string | null = listing.toString(); + let incomplete: string | null = null; + + try { + for (let page = 0; next && page < MAX_CHANNEL_PAGES; page += 1) { + const current: string = next; + const response: Response = await fetch(current, { + method: "GET", + headers, + }); + + if (!response.ok) { + let refusal: SlackActionError; + try { + refusal = await refusalFrom( + response, + `Unable to read the workspace's channels: ${response.statusText}`, + ); + } catch (error) { + // `handleApiResponse` reported the 5xx and threw; a first-page + // failure stays a failure, but later pages keep what was read. + if (channels.length === 0) throw error; + return { channels, incomplete: SLACK_GENERIC_ERROR_MESSAGE }; + } + + return channels.length > 0 + ? { channels, incomplete: refusal.error, code: refusal.code } + : refusal; + } + + // A page that is not JSON reads as no channels, rather than throwing a + // parser message the user would be shown verbatim. + const body = await response.json().catch(() => null); + + for (const resource of body?.data ?? []) { + // Radix `Select.Item` refuses an empty value; one malformed resource + // would break the whole picker. + const channelId = resource?.id; + if (typeof channelId !== "string" || channelId.length === 0) continue; + channels.push({ + id: channelId, + name: resource?.attributes?.name ?? "", + is_private: Boolean(resource?.attributes?.is_private), + }); + } + + const rawNext = body?.links?.next; + const candidate = + typeof rawNext === "string" && rawNext.length > 0 + ? new URL(rawNext, current) + : null; + // Resolved against the page it arrived on, so a cursor-only `next` keeps + // this listing's path. Followed only while it stays on the listing's + // origin: every page is fetched with the tenant's token, and an + // off-origin hop made here would carry it along. + if (candidate === null) { + next = null; + } else if (candidate.origin === listing.origin) { + next = candidate.toString(); + } else { + next = null; + incomplete = SLACK_PARTIAL_CHANNEL_LIST_MESSAGE; + } + } + + // A link still waiting when the budget ran out. Checked rather than assumed + // from the page count: a workspace of exactly `MAX_CHANNEL_PAGES` pages was + // read to the end. + if (next) incomplete = SLACK_PARTIAL_CHANNEL_LIST_MESSAGE; + + return incomplete === null ? { channels } : { channels, incomplete }; + } catch (error) { + return handleApiError(error); + } +}; + +interface SlackAuthorizedChannelsSuccess { + integration: IntegrationProps; +} + +export type SlackAuthorizedChannelsResult = + | SlackAuthorizedChannelsSuccess + | SlackActionError; + +/** + * Record the set of channels Prowler is authorized to post to, on the generic + * integration endpoint. The list replaces the set; an empty one clears it. + * + * A Slack action despite the generic `PATCH`: `channel_not_found` and + * `not_in_channel` carry the same `detail`, so only `code` tells them apart, + * and the generic action reads `detail` alone. Only ids travel — the API + * derives each name and its privacy server-side. + */ +export const setSlackAuthorizedChannels = async ( + integrationId: string, + channelIds: string[], +): Promise => { + const id = parseIntegrationId(integrationId); + if (!id) return { error: SLACK_GENERIC_ERROR_MESSAGE }; + + const headers = await getAuthHeaders({ contentType: true }); + const url = new URL(`${apiBaseUrl}/integrations/${id}`); + + try { + const response = await fetch(url.toString(), { + method: "PATCH", + headers, + body: JSON.stringify({ + data: { + type: "integrations", + id, + // `configuration` is the only attribute the save may carry: the write + // serializer refuses whatever it does not accept, so naming the + // integration's own (immutable) type is answered with a 400, + // "Invalid fields: {'integration_type'}". + attributes: { + configuration: { + // Ids wrapped as objects, never a bare array (contract, PATCH). + channels: Array.from(new Set(channelIds), (channelId) => ({ + id: channelId, + })), + }, + }, + }, + }), + }); + + if (!response.ok) { + // Awaited inside the `try`: unawaited, a 5xx's rejection would skip + // this `catch`. + return await refusalFrom( + response, + `Unable to save the destination channels: ${response.statusText}`, + ); + } + + const body = await response.json().catch(() => null); + + // Before the guard and on both paths: the save happened, so a cache still + // holding the previous channels would keep showing them. + revalidatePath("/integrations"); + revalidatePath("/integrations/slack"); + + // Guarded as deep as the caller reads: it names the saved channels from + // `attributes.configuration`. + if (!body?.data?.attributes?.configuration) { + return { error: SLACK_UNREADABLE_RESULT_MESSAGE }; + } + + return { integration: parseStringify(body.data) as IntegrationProps }; + } catch (error) { + return handleApiError(error); + } +}; + +/** + * What the API reports about revoking Prowler's token at Slack: one boolean in + * `meta`, and nothing else — it sends no reason for a revocation that did not + * happen, so there is no field here to hold one. + */ +export interface SlackRevocation { + /** + * Whether Slack confirmed the token no longer grants Prowler anything, or + * `null` when the response carried no outcome. The contract says the outcome + * is always reported, so `null` means the response is wrong, not the + * revocation. + */ + revoked: boolean | null; +} + +interface SlackDisconnectSuccess { + /** The integration is gone from Prowler, whatever Slack answered. */ + disconnected: true; + revocation: SlackRevocation; +} + +export type SlackDisconnectResult = SlackDisconnectSuccess | SlackActionError; + +/** + * Disconnect the workspace: `DELETE /integrations/{id}`. + * + * The generic `deleteIntegration` cannot serve this: it discards the response + * body, and the body is the whole point. Revocation at Slack is best-effort — + * the row is removed either way and the outcome travels in JSON:API `meta` — so + * a caller has to tell "gone and revoked" from "gone, but still installed in + * Slack". + * + * A body without the field (an empty `204`, say) yields `null`, not `false`: an + * unreported outcome must not send the user off to clean up Slack, nor be shown + * as access revoked. + */ +export const disconnectSlackIntegration = async ( + integrationId: string, +): Promise => { + const id = parseIntegrationId(integrationId); + if (!id) return { error: SLACK_GENERIC_ERROR_MESSAGE }; + + const headers = await getAuthHeaders({ contentType: true }); + const url = new URL(`${apiBaseUrl}/integrations/${id}`); + + try { + const response = await fetch(url.toString(), { method: "DELETE", headers }); + + if (!response.ok) { + return await refusalFrom( + response, + `Unable to disconnect the Slack workspace: ${response.statusText}`, + ); + } + + const body = await response.json().catch(() => ({})); + const meta = body?.meta ?? {}; + + revalidatePath("/integrations"); + revalidatePath("/integrations/slack"); + + return { + disconnected: true, + revocation: { + revoked: typeof meta.revoked === "boolean" ? meta.revoked : null, + }, + }; + } catch (error) { + return handleApiError(error); + } +}; diff --git a/ui/actions/invitations/invitation.adapter.test.ts b/ui/actions/invitations/invitation.adapter.test.ts new file mode 100644 index 0000000000..f0edc02918 --- /dev/null +++ b/ui/actions/invitations/invitation.adapter.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { toSentInvitation } from "./invitation.adapter"; + +const created = { + data: { + id: "inv-1", + type: "invitations", + attributes: { + email: "teammate@company.com", + token: "abc123DEF45678", + state: "pending", + expires_at: "2026-10-07T10:00:00Z", + }, + }, +}; + +describe("toSentInvitation", () => { + it("reads the id, email and token of a created invitation", () => { + expect(toSentInvitation(created)).toEqual({ + id: "inv-1", + email: "teammate@company.com", + token: "abc123DEF45678", + }); + }); + + it("returns null when the action resolved without a value", () => { + // A 5xx makes `sendInvite` resolve undefined. + expect(toSentInvitation(undefined)).toBeNull(); + }); + + it("returns null on a rejection, with or without an errors array", () => { + expect( + toSentInvitation({ errors: [{ detail: "Invalid email" }] }), + ).toBeNull(); + expect(toSentInvitation({ error: "Something went wrong" })).toBeNull(); + }); + + it("returns null when the record is missing any of the fields the link needs", () => { + expect( + toSentInvitation({ + data: { id: "inv-1", attributes: { email: "a@b.com" } }, + }), + ).toBeNull(); + expect( + toSentInvitation({ + data: { id: "inv-1", attributes: { token: "abc123DEF45678" } }, + }), + ).toBeNull(); + }); +}); diff --git a/ui/actions/invitations/invitation.adapter.ts b/ui/actions/invitations/invitation.adapter.ts new file mode 100644 index 0000000000..41fccefbb8 --- /dev/null +++ b/ui/actions/invitations/invitation.adapter.ts @@ -0,0 +1,25 @@ +import type { SentInvitation } from "@/types/onboarding-invite"; + +const readString = (value: unknown): string | null => + typeof value === "string" && value.length > 0 ? value : null; + +/** + * The created record out of `sendInvite`'s JSON:API response. Null for every + * failure shape: `undefined` (a 5xx makes the action resolve without a value), + * `{ errors }`, a bare `{ error }`, or a record missing what the link needs. + */ +export function toSentInvitation(response: unknown): SentInvitation | null { + if (!response || typeof response !== "object") return null; + const { data } = response as { data?: unknown }; + if (!data || typeof data !== "object") return null; + const { id, attributes } = data as { id?: unknown; attributes?: unknown }; + const fields = + attributes && typeof attributes === "object" + ? (attributes as Record) + : {}; + const invitationId = readString(id); + const email = readString(fields.email); + const token = readString(fields.token); + if (!invitationId || !email || !token) return null; + return { id: invitationId, email, token }; +} 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/invitations/roles.ts b/ui/actions/invitations/roles.ts new file mode 100644 index 0000000000..d90695ef91 --- /dev/null +++ b/ui/actions/invitations/roles.ts @@ -0,0 +1,19 @@ +"use server"; + +import { getRoles } from "@/actions/roles"; +import type { InvitationRoleOption } from "@/types/onboarding-invite"; + +const ROLES_PAGE_SIZE = 50; + +// Roles an invitation can grant; empty when the read fails so a caller can +// fall back (skip, disable) rather than block. +export const getInvitationRoles = async (): Promise => { + 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/mute-rules/mute-rules.test.ts b/ui/actions/mute-rules/mute-rules.test.ts new file mode 100644 index 0000000000..cfb7f5daf9 --- /dev/null +++ b/ui/actions/mute-rules/mute-rules.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { fetchMock, getAuthHeadersMock, revalidatePathMock } = vi.hoisted( + () => ({ + fetchMock: vi.fn(), + getAuthHeadersMock: vi.fn(), + revalidatePathMock: vi.fn(), + }), +); + +vi.mock("@/lib/helper", () => ({ + apiBaseUrl: "https://api.test/api/v1", + getAuthHeaders: getAuthHeadersMock, +})); + +vi.mock("next/cache", () => ({ + revalidatePath: revalidatePathMock, +})); + +import { createMuteRule } from "./mute-rules"; + +const NAME_CONFLICT_DETAIL = "A mute rule with this name already exists."; + +const errorResponse = (contentType: string, body: string, status = 400) => + new Response(body, { + status, + headers: { "Content-Type": contentType }, + }); + +const nameConflictBody = JSON.stringify({ + errors: [ + { + detail: NAME_CONFLICT_DETAIL, + status: "400", + source: { pointer: "/data/attributes/name" }, + code: "invalid", + }, + ], +}); + +const muteRuleFormData = () => { + const formData = new FormData(); + formData.set("name", "Root account has a hardware MFA device enabled"); + formData.set("reason", "Not our approach here with SSO"); + formData.set("finding_ids", JSON.stringify(["finding-1"])); + return formData; +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + vi.spyOn(console, "error").mockImplementation(() => {}); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); +}); + +describe("createMuteRule", () => { + it("should return only the error detail for a JSON:API error response", async () => { + fetchMock.mockResolvedValue( + errorResponse("application/vnd.api+json", nameConflictBody), + ); + + const result = await createMuteRule(null, muteRuleFormData()); + + expect(result?.errors?.general).toBe(NAME_CONFLICT_DETAIL); + expect(revalidatePathMock).not.toHaveBeenCalled(); + }); + + it("should return only the error detail for a plain JSON error response", async () => { + fetchMock.mockResolvedValue( + errorResponse("application/json", nameConflictBody), + ); + + const result = await createMuteRule(null, muteRuleFormData()); + + expect(result?.errors?.general).toBe(NAME_CONFLICT_DETAIL); + }); + + it("should return the response text for a non-JSON error response", async () => { + fetchMock.mockResolvedValue( + errorResponse("text/plain", "Bad gateway", 502), + ); + + const result = await createMuteRule(null, muteRuleFormData()); + + expect(result?.errors?.general).toBe("Bad gateway"); + }); +}); diff --git a/ui/actions/mute-rules/mute-rules.ts b/ui/actions/mute-rules/mute-rules.ts index c74c4a7e2a..1ce9a8b3bc 100644 --- a/ui/actions/mute-rules/mute-rules.ts +++ b/ui/actions/mute-rules/mute-rules.ts @@ -156,7 +156,8 @@ export const createMuteRule = async ( let errorMessage = `Failed to create mute rule: ${response.statusText}`; const responseContentType = response.headers.get("content-type"); try { - if (responseContentType?.includes("application/json")) { + // The API answers with application/vnd.api+json + if (responseContentType?.includes("json")) { const errorData = await response.json(); const jsonApiError = ( errorData as { diff --git a/ui/actions/organizations/organizations.adapter.ts b/ui/actions/organizations/organizations.adapter.ts index 7753d66f54..f63fcadf0e 100644 --- a/ui/actions/organizations/organizations.adapter.ts +++ b/ui/actions/organizations/organizations.adapter.ts @@ -5,6 +5,8 @@ import { ApplyDiscoveryPayload, AwsDiscoveryResult, AwsOrgHierarchy, + AzureDiscoveryResult, + AzureOrgHierarchy, GcpDiscoveryResult, GcpOrgHierarchy, NODE_KIND, @@ -84,6 +86,43 @@ export function mapGcpDiscovery(result: GcpDiscoveryResult): GcpOrgHierarchy { }; } +/** + * Ingestion mapper: Azure discovery wire result → normalized hierarchy model. + * + * A management group's identity is its canonical resource ID + * (`/providers/Microsoft.Management/managementGroups/{name}`), which is exactly + * what its children carry as `parent_id`, so nesting matches on that ref. The + * selected root group is collapsed away — it is absent from the node set, so + * groups and subscriptions parented by it rebuild as top-level. + */ +export function mapAzureDiscovery( + result: AzureDiscoveryResult, +): AzureOrgHierarchy { + return { + orgType: ORGANIZATION_TYPE.AZURE, + organization: { + // Tenant id: what the user typed and what the organization stores as + // `external_id`. + uid: result.root_management_group.tenant_id, + name: + result.root_management_group.display_name || + result.root_management_group.name, + }, + nodes: result.management_groups.map((group) => ({ + id: group.id, + kind: NODE_KIND.MANAGEMENT_GROUP, + name: group.display_name || group.name, + parentId: group.parent_id, + })), + candidates: result.subscriptions.map((subscription) => ({ + uid: subscription.subscription_id, + label: subscription.display_name || subscription.subscription_id, + parentId: subscription.parent_id, + registration: subscription.registration, + })), + }; +} + /** * Transforms the normalized hierarchy into hierarchical TreeDataItem[] for * TreeView. Container nodes (OUs / folders) nest candidates (accounts / @@ -338,6 +377,14 @@ export function buildApplyPayload( selectedCandidateIds, ).map((id) => ({ id })), }; + case ORGANIZATION_TYPE.AZURE: + return { + orgType: ORGANIZATION_TYPE.AZURE, + subscriptions: selectedCandidateIds.map((id) => ({ + subscription_id: id, + ...aliasOf(id), + })), + }; case ORGANIZATION_TYPE.GCP: return { orgType: ORGANIZATION_TYPE.GCP, diff --git a/ui/actions/organizations/organizations.test.ts b/ui/actions/organizations/organizations.test.ts index da77422ed1..013e906eac 100644 --- a/ui/actions/organizations/organizations.test.ts +++ b/ui/actions/organizations/organizations.test.ts @@ -51,6 +51,7 @@ describe("organizations actions", () => { it("rejects invalid organization secret identifiers", async () => { // When const result = await updateOrganizationSecret("../secret-id", { + orgType: ORGANIZATION_TYPE.AWS, secretType: ORG_SECRET_TYPE.ROLE, secret: { role_arn: "arn:aws:iam::123456789012:role/ProwlerOrgRole", @@ -85,13 +86,13 @@ describe("organizations actions", () => { }); it("rejects an organization type with no onboarding flow instead of coercing it", async () => { - // Given a form asking for a type this build cannot onboard. `azure` is a - // real OrganizationType — display supports it, onboarding does not — so it - // is the exact boundary a blind cast would let through. + // Given a type this build cannot onboard. `oraclecloud` is real — display + // supports it, onboarding does not — the exact boundary a blind cast lets + // through. const formData = new FormData(); - formData.set("name", "Contoso"); - formData.set("externalId", "o-abc123def4"); - formData.set("orgType", ORGANIZATION_TYPE.AZURE); + formData.set("name", "Tenancy"); + formData.set("externalId", "ocid1.tenancy.oc1..aaaa1111"); + formData.set("orgType", "oraclecloud"); // When const result = await createOrganization(formData); diff --git a/ui/actions/organizations/organizations.ts b/ui/actions/organizations/organizations.ts index ec177d54bd..761c08acfb 100644 --- a/ui/actions/organizations/organizations.ts +++ b/ui/actions/organizations/organizations.ts @@ -489,11 +489,34 @@ export const getDiscovery = async ( } }; +/** + * JSON:API attributes for an apply request. The `default` arm is the + * exhaustiveness guard — `noImplicitReturns` is off. + */ +function buildApplyAttributes(payload: ApplyDiscoveryPayload) { + switch (payload.orgType) { + case ORGANIZATION_TYPE.AWS: + return { + accounts: payload.accounts, + organizational_units: payload.organizationalUnits, + }; + case ORGANIZATION_TYPE.AZURE: + return { subscriptions: payload.subscriptions }; + case ORGANIZATION_TYPE.GCP: + return { projects: payload.projects }; + default: { + const exhaustivePayload: never = payload; + return exhaustivePayload; + } + } +} + /** * Applies discovery results — creates providers, links to org/nodes, * auto-generates secrets. The payload is discriminated by organization type: * AWS sends `accounts` + client-side-derived `organizational_units`; GCP sends - * `projects` only (folder ancestors are derived server-side). + * `projects` and Azure `subscriptions` only (folder / Management Group ancestors + * are derived server-side). * POST /api/v1/organizations/{orgId}/discoveries/{discoveryId}/apply */ export const applyDiscovery = async ( @@ -523,15 +546,9 @@ export const applyDiscovery = async ( ); // No `include`: the apply view rejects the parameter outright and fails the // whole request. The created providers' uids are read afterwards instead, with - // `getProviderUidsByIds`. + // `getProviderUidsAndConnectionBaselines`. - const attributes = - payload.orgType === ORGANIZATION_TYPE.AWS - ? { - accounts: payload.accounts, - organizational_units: payload.organizationalUnits, - } - : { projects: payload.projects }; + const attributes = buildApplyAttributes(payload); try { const response = await fetch(url.toString(), { 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..3f3024a772 --- /dev/null +++ b/ui/actions/providers/dynamic-provider-credentials.test.ts @@ -0,0 +1,236 @@ +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"; +import unionSchema from "@/lib/provider-credentials/fixtures/union-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); + }); + describe("when the secret type's schema offers several variants", () => { + const basic = { + auth_method: "basic", + host: "api.acme.test", + username: "fixture-user", + password: "fixture-password-not-a-secret", + }; + beforeEach(() => { + getProviderSchemas.mockResolvedValue({ + status: "success", + providerType: "acme", + secretTypes: { static: unionSchema }, + }); + }); + + it("sends a secret that one variant accepts", async () => { + // Given + fetchMock + .mockResolvedValueOnce(response(account())) + .mockResolvedValueOnce(response({ data: { id: "saved" } }, 201)); + + // When + const result = await saveDynamicProviderCredentials({ + ...input, + secretType: "static", + secret: basic, + }); + + // Then + expect(result).toEqual({ status: "saved", secretId: "saved" }); + expect( + JSON.parse(fetchMock.mock.calls[1][1].body).data.attributes, + ).toEqual({ secret_type: "static", secret: basic }); + }); + + it.each([ + ["fields of two variants", { ...basic, token: "fixture-token" }], + ["another variant's discriminator", { ...basic, auth_method: "token" }], + ["a missing required field", { ...basic, password: "" }], + ])("does not write a secret with %s", async (_name, secret) => { + // Given + fetchMock.mockResolvedValueOnce(response(account())); + + // When + const result = await saveDynamicProviderCredentials({ + ...input, + secretType: "static", + secret, + }); + + // Then + expect(result.status).toBe("invalid"); + 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..7506b657ca --- /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 { parseRegistryCredentialVariants } from "@/lib/provider-credentials/provider-credential-schema"; +import { validateCredentialVariants } 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 variants = parseRegistryCredentialVariants( + schemas.secretTypes[secretType], + ); + if (!variants) return { status: "schema_unavailable" }; + const validated = validateCredentialVariants(variants, 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/providers.ts b/ui/actions/providers/providers.ts index a862c488af..76e57b30ba 100644 --- a/ui/actions/providers/providers.ts +++ b/ui/actions/providers/providers.ts @@ -151,28 +151,28 @@ export const getProvider = async (formData: FormData) => { const PROVIDERS_PAGE_MAX = 100; /** - * Uids of the given providers, keyed by provider id. A provider's `uid` is the - * candidate it was created for (AWS account id / GCP project id), so this is what - * matches an apply's created providers back to the selection. Batched with - * `filter[id__in]` rather than one `GET /providers/{id}` per id. + * Providers matching the given ids, batched with `filter[id__in]` (page size + * `PROVIDERS_PAGE_MAX`, the server max, which also bounds the id batch size) + * rather than one `GET /providers/{id}` per id. Shared by every action below + * that resolves providers by id; a batch that fails to fetch leaves its + * providers out of the result rather than failing the rest, and it is on each + * caller to say what "missing" means for its own map. Not exported: an + * exported function in this `"use server"` module becomes a callable server + * action. */ -export const getProviderUidsByIds = async ( +const fetchProvidersByIds = async ( providerIds: string[], -): Promise> => { +): Promise => { const uniqueIds = Array.from(new Set(providerIds.filter(Boolean))); if (uniqueIds.length === 0) { - return {}; + return []; } const headers = await getAuthHeaders({ contentType: false }); - const batches: string[][] = []; + const providers: ProvidersApiResponse["data"] = []; + for (let start = 0; start < uniqueIds.length; start += PROVIDERS_PAGE_MAX) { - batches.push(uniqueIds.slice(start, start + PROVIDERS_PAGE_MAX)); - } - - const uidById: Record = {}; - - for (const batch of batches) { + const batch = uniqueIds.slice(start, start + PROVIDERS_PAGE_MAX); const url = new URL(`${apiBaseUrl}/providers`); url.searchParams.set("filter[id__in]", batch.join(",")); url.searchParams.set("page[size]", String(PROVIDERS_PAGE_MAX)); @@ -183,20 +183,93 @@ export const getProviderUidsByIds = async ( | ProvidersApiResponse | undefined; - for (const provider of result?.data ?? []) { - const uid = provider?.attributes?.uid; - if (typeof provider?.id === "string" && typeof uid === "string") { - uidById[provider.id] = uid; - } - } + providers.push(...(result?.data ?? [])); } catch { - // A failed batch leaves its providers unmapped rather than failing the rest. + // A failed batch leaves its providers out of the result rather than + // failing the rest. + } + } + + return providers; +}; + +/** + * Uids of the given providers, keyed by provider id. A provider's `uid` is the + * candidate it was created for (AWS account id / GCP project id), so this is what + * matches an apply's created providers back to the selection. + */ +export const getProviderUidsByIds = async ( + providerIds: string[], +): Promise> => { + const uidById: Record = {}; + + for (const provider of await fetchProvidersByIds(providerIds)) { + const uid = provider?.attributes?.uid; + if (typeof provider?.id === "string" && typeof uid === "string") { + uidById[provider.id] = uid; } } return uidById; }; +/** + * `connection.last_checked_at` for each given provider, keyed by id. Read before + * a batch of connection checks is dispatched, so `resolveProviderConnectionState` + * can tell a check's own result apart from an older one already on record by + * comparing values, never by comparing the browser's clock against the server's + * (see that function for why). A provider missing from the response -- the batch + * read failed, or it was deleted mid-flight -- is left out of the map rather than + * defaulted, so callers can tell "no prior check" (`null`) from "unknown". + */ +export const getProviderConnectionBaselines = async ( + providerIds: string[], +): Promise> => { + const baselineById: Record = {}; + + for (const provider of await fetchProvidersByIds(providerIds)) { + if (typeof provider?.id === "string") { + baselineById[provider.id] = + provider.attributes?.connection?.last_checked_at ?? null; + } + } + + return baselineById; +}; + +/** + * Uid and `connection.last_checked_at` for each given provider, keyed by id, read + * with a single batched `filter[id__in]` request. The organization onboarding + * apply step needs both right after creating providers: the uid to match each one + * back to the candidate it was created for (see `getProviderUidsByIds`), and the + * baseline to compare a dispatched check's result against (see + * `getProviderConnectionBaselines`). Reading them together avoids fetching the + * same set of providers twice. + */ +export const getProviderUidsAndConnectionBaselines = async ( + providerIds: string[], +): Promise<{ + uidById: Record; + baselineById: Record; +}> => { + const uidById: Record = {}; + const baselineById: Record = {}; + + for (const provider of await fetchProvidersByIds(providerIds)) { + if (typeof provider?.id !== "string") { + continue; + } + const uid = provider.attributes?.uid; + if (typeof uid === "string") { + uidById[provider.id] = uid; + } + baselineById[provider.id] = + provider.attributes?.connection?.last_checked_at ?? null; + } + + return { uidById, baselineById }; +}; + export const updateProvider = async (formData: FormData) => { const headers = await getAuthHeaders({ contentType: true }); const providerId = formData.get(ProviderCredentialFields.PROVIDER_ID); 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..e84fab1218 --- /dev/null +++ b/ui/actions/registry/registry.adapter.ts @@ -0,0 +1,490 @@ +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", + PAGE_NOT_FOUND: "registry_page_not_found", +} 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 }; +} + +// An enabled backend also answers 404 (missing page): tell them apart by code. +export async function isRegistryDisabledResponse(response: Response) { + if (response.status !== 404) return false; + const codes = await getRegistryErrorCodes(response); + return !codes.includes(REGISTRY_ERROR_CODE.PAGE_NOT_FOUND); +} + +function isRegistryDiscoveryEndpoint(endpoint: RegistryEndpoint) { + return registryDiscoveryEndpoints.has(endpoint); +} + +async function getRegistryErrorCodes(response: Response) { + const parsed = errorDocumentSchema.safeParse( + await response + .clone() + .json() + .catch(() => undefined), + ); + return parsed.success ? parsed.data.errors.map(({ code }) => code) : []; +} + +async function getRegistryErrorCode(response: Response) { + return (await getRegistryErrorCodes(response))[0]; +} + +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..a3a44ffcf9 --- /dev/null +++ b/ui/actions/registry/registry.test.ts @@ -0,0 +1,1264 @@ +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, + failureBody = {}, + }: { + emptyMetadata?: boolean; + failedEndpoint?: string; + failureStatus?: number; + failureBody?: unknown; + } = {}) { + fetchMock.mockImplementation((url: string) => { + const endpoint = new URL(url).pathname.split("/").pop(); + if (endpoint === failedEndpoint) + return jsonResponse(failureBody, 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", + }); + }, + ); + + it.each(["available-artifacts", "artifacts", "providers"])( + "hides Registry when the backend has it disabled and %s answers 404", + async (failedEndpoint) => { + mockDiscovery({ failedEndpoint, failureStatus: 404 }); + expect(await getInstalledRegistryProviderOptions()).toEqual({ + status: "access_denied", + }); + }, + ); + + it.each(["available-artifacts", "providers"])( + "keeps Registry visible when an enabled backend answers 404 for a missing %s page", + async (failedEndpoint) => { + mockDiscovery({ + failedEndpoint, + failureStatus: 404, + failureBody: { + errors: [{ status: "404", code: "registry_page_not_found" }], + }, + }); + expect(await getInstalledRegistryProviderOptions()).toEqual({ + status: "error", + }); + }, + ); + + it("keeps Registry visible when the missing-page code is not the first error", async () => { + mockDiscovery({ + failedEndpoint: "available-artifacts", + failureStatus: 404, + failureBody: { + errors: [{ code: "not_found" }, { code: "registry_page_not_found" }], + }, + }); + expect(await getInstalledRegistryProviderOptions()).toEqual({ + status: "error", + }); + }); +}); + +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..18cd2322ce --- /dev/null +++ b/ui/actions/registry/registry.ts @@ -0,0 +1,603 @@ +"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, + isRegistryDisabledResponse, + 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; + // A backend with Registry disabled answers 404: hide it like a denial. + if (await isRegistryDisabledResponse(response)) + return { status: REGISTRY_FAILURE.ACCESS_DENIED }; + + 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, + ), + }; +} + +/** Logo of each registry provider type; empty whenever Registry is out of reach. */ +export async function getRegistryProviderLogos(): Promise< + Record +> { + const access = (await auth())?.accessToken; + const permission = await evaluateRegistryProviderAccess(access); + if (!access || permission.status !== REGISTRY_ACCESS.ELIGIBLE) return {}; + const result = await readRegistryProviders(access, null); + if (result.status !== "ready") return {}; + return Object.fromEntries( + result.providers.flatMap(({ type, logoUrl }) => + logoUrl?.startsWith("https://") ? [[type, logoUrl]] : [], + ), + ); +} + +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 3546649604..6c79579b99 100644 --- a/ui/actions/roles/roles.test.ts +++ b/ui/actions/roles/roles.test.ts @@ -49,6 +49,8 @@ const makeRoleFormData = () => { formData.set("manage_integrations", "false"); 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; }; @@ -72,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"); @@ -106,4 +138,56 @@ describe("role actions", () => { // Then expect(lastRequestBody().data.attributes.manage_alerts).toBe(true); }); + + it("includes manage_lighthouse_ai_configuration when creating a role in Prowler Cloud", async () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "true"); + + // When + await addRole(makeRoleFormData()); + + // Then + expect( + lastRequestBody().data.attributes.manage_lighthouse_ai_configuration, + ).toBe(true); + }); + + it("omits manage_lighthouse_ai_configuration when creating a role outside Prowler Cloud", async () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "false"); + + // When + await addRole(makeRoleFormData()); + + // Then + expect(lastRequestBody().data.attributes).not.toHaveProperty( + "manage_lighthouse_ai_configuration", + ); + }); + + it("includes manage_lighthouse_ai_configuration when updating a role in Prowler Cloud", async () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "true"); + + // When + await updateRole(makeRoleFormData(), "role-1"); + + // Then + expect( + lastRequestBody().data.attributes.manage_lighthouse_ai_configuration, + ).toBe(true); + }); + + it("omits manage_lighthouse_ai_configuration when updating a role outside Prowler Cloud", async () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "false"); + + // When + await updateRole(makeRoleFormData(), "role-1"); + + // Then + expect(lastRequestBody().data.attributes).not.toHaveProperty( + "manage_lighthouse_ai_configuration", + ); + }); }); diff --git a/ui/actions/roles/roles.ts b/ui/actions/roles/roles.ts index cfe3837414..00302165e9 100644 --- a/ui/actions/roles/roles.ts +++ b/ui/actions/roles/roles.ts @@ -114,6 +114,10 @@ export const addRole = async (formData: FormData) => { formData.get("manage_billing") === "true"; payload.data.attributes.manage_alerts = 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 @@ -171,6 +175,10 @@ export const updateRole = async (formData: FormData, roleId: string) => { formData.get("manage_billing") === "true"; payload.data.attributes.manage_alerts = 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/actions/scans/scans.test.ts b/ui/actions/scans/scans.test.ts index bf91484ee7..6e08a6f2a9 100644 --- a/ui/actions/scans/scans.test.ts +++ b/ui/actions/scans/scans.test.ts @@ -6,12 +6,14 @@ const { getAuthHeadersMock, handleApiErrorMock, handleApiResponseMock, + isReportDownloadLockedMock, } = vi.hoisted(() => ({ addScanOperationMock: vi.fn(), fetchMock: vi.fn(), getAuthHeadersMock: vi.fn(), handleApiErrorMock: vi.fn(), handleApiResponseMock: vi.fn(), + isReportDownloadLockedMock: vi.fn(), })); vi.mock("@/lib", () => ({ @@ -23,6 +25,10 @@ vi.mock("@/lib", () => ({ error instanceof Error ? error.message : String(error), })); +vi.mock("next/cache", () => ({ + revalidatePath: vi.fn(), +})); + vi.mock("@/lib/server-actions-helper", () => ({ handleApiError: handleApiErrorMock, handleApiResponse: handleApiResponseMock, @@ -32,7 +38,17 @@ vi.mock("@/lib/sentry-breadcrumbs", () => ({ addScanOperation: addScanOperationMock, })); +vi.mock("@/lib/report-download-access", () => ({ + REPORT_DOWNLOAD_LOCKED_ERROR: + "Report downloads require an active subscription.", + isReportDownloadLocked: isReportDownloadLockedMock, +})); + import { + createPartialScan, + getComplianceCsv, + getComplianceOcsf, + getCompliancePdfReport, getExportsZip, launchOrganizationScans, scheduleOrganizationDailyScans, @@ -156,6 +172,7 @@ describe("getExportsZip", () => { vi.clearAllMocks(); vi.stubGlobal("fetch", fetchMock); getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + isReportDownloadLockedMock.mockResolvedValue(false); }); it("returns a generic server error when the report endpoint returns HTML", async () => { @@ -181,3 +198,98 @@ describe("getExportsZip", () => { }); }); }); + +describe("report downloads for subscription-only tenants", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + isReportDownloadLockedMock.mockResolvedValue(true); + }); + + it.each([ + { name: "scan ZIP", download: () => getExportsZip("scan-123") }, + { + name: "compliance CSV", + download: () => getComplianceCsv("scan-123", "cis_2.0_aws"), + }, + { + name: "compliance OCSF", + download: () => getComplianceOcsf("scan-123", "dora_aws"), + }, + { + name: "compliance PDF", + download: () => getCompliancePdfReport("scan-123", "threatscore"), + }, + ])("rejects the $name without calling the API", async ({ download }) => { + // When + const result = await download(); + + // Then + expect(fetchMock).not.toHaveBeenCalled(); + expect(result).toEqual({ + error: "Report downloads require an active subscription.", + }); + }); +}); + +describe("createPartialScan", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + fetchMock.mockResolvedValue(new Response(null, { status: 202 })); + handleApiResponseMock.mockResolvedValue({ data: { id: "scan-1" } }); + }); + + it("posts the resource uids as a scan of one provider", async () => { + // When + const result = await createPartialScan({ + providerId: "provider-1", + resourceUids: ["arn:aws:s3:::bucket"], + }); + + // Then + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/api/v1/scans", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + data: { + type: "scans", + attributes: { resource_uids: ["arn:aws:s3:::bucket"] }, + relationships: { + provider: { data: { type: "providers", id: "provider-1" } }, + }, + }, + }), + }), + ); + expect(handleApiResponseMock).toHaveBeenCalledWith( + expect.any(Response), + "/scans", + ); + expect(result).toEqual({ data: { id: "scan-1" } }); + expect(addScanOperationMock).toHaveBeenCalledWith("start", "scan-1"); + }); + + it("refuses more resources than the API accepts without calling it", async () => { + // Given — the API caps a partial scan at 10 resources. + const resourceUids = Array.from( + { length: 11 }, + (_, index) => `arn:aws:s3:::bucket-${index}`, + ); + + // When + const result = await createPartialScan({ + providerId: "provider-1", + resourceUids, + }); + + // Then + expect(fetchMock).not.toHaveBeenCalled(); + expect(result).toEqual({ + error: "Select between 1 and 10 resources to re-check", + }); + }); +}); diff --git a/ui/actions/scans/scans.ts b/ui/actions/scans/scans.ts index 89f832552c..cd08f5ce5a 100644 --- a/ui/actions/scans/scans.ts +++ b/ui/actions/scans/scans.ts @@ -15,9 +15,14 @@ import { } from "@/lib/compliance/compliance-report-types"; import { runWithConcurrencyLimit } from "@/lib/concurrency"; import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters"; +import { + isReportDownloadLocked, + REPORT_DOWNLOAD_LOCKED_ERROR, +} from "@/lib/report-download-access"; import { addScanOperation } from "@/lib/sentry-breadcrumbs"; import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper"; import { SCAN_STATES } from "@/types/attack-paths"; +import { PARTIAL_SCAN_MAX_RESOURCES } from "@/types/partial-scans"; const ORGANIZATION_SCAN_CONCURRENCY_LIMIT = 5; @@ -182,6 +187,70 @@ export const scanOnDemand = async (formData: FormData) => { } }; +/** Prowler Cloud only: re-check up to PARTIAL_SCAN_MAX_RESOURCES resources of one provider. */ +export const createPartialScan = async ({ + providerId, + resourceUids, +}: { + providerId: string; + resourceUids: string[]; +}) => { + if (!providerId) { + return { error: "Provider ID is required" }; + } + if ( + resourceUids.length === 0 || + resourceUids.length > PARTIAL_SCAN_MAX_RESOURCES + ) { + return { + error: `Select between 1 and ${PARTIAL_SCAN_MAX_RESOURCES} resources to re-check`, + }; + } + + const headers = await getAuthHeaders({ contentType: true }); + + addScanOperation("create", undefined, { + provider_id: providerId, + partial: true, + resource_count: resourceUids.length, + }); + + const url = new URL(`${apiBaseUrl}/scans`); + + try { + const requestBody = { + data: { + type: "scans", + attributes: { resource_uids: resourceUids }, + relationships: { + provider: { + data: { + type: "providers", + id: providerId, + }, + }, + }, + }, + }; + + const response = await fetch(url.toString(), { + method: "POST", + headers, + body: JSON.stringify(requestBody), + }); + + const result = await handleApiResponse(response, "/scans"); + if (result?.data?.id) { + addScanOperation("start", result.data.id); + revalidatePath("/scans"); + } + return result; + } catch (error) { + addScanOperation("create"); + return handleApiError(error); + } +}; + export const scheduleDaily = async (formData: FormData) => { const headers = await getAuthHeaders({ contentType: true }); @@ -377,6 +446,10 @@ export const updateScan = async (formData: FormData) => { }; export const getExportsZip = async (scanId: string) => { + if (await isReportDownloadLocked()) { + return { error: REPORT_DOWNLOAD_LOCKED_ERROR }; + } + const headers = await getAuthHeaders({ contentType: false }); const url = new URL(`${apiBaseUrl}/scans/${scanId}/report`); @@ -457,6 +530,10 @@ const _fetchScanBinary = async ( filename: string, errorLabel: string, ): Promise => { + if (await isReportDownloadLocked()) { + return { error: REPORT_DOWNLOAD_LOCKED_ERROR }; + } + const headers = await getAuthHeaders({ contentType: false }); const url = new URL(`${apiBaseUrl}/scans/${scanId}/${urlPath}`); 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/(auth)/invitation/accept/accept-invitation-client.tsx b/ui/app/(auth)/invitation/accept/accept-invitation-client.tsx index c412eaf48c..df68239136 100644 --- a/ui/app/(auth)/invitation/accept/accept-invitation-client.tsx +++ b/ui/app/(auth)/invitation/accept/accept-invitation-client.tsx @@ -1,6 +1,6 @@ "use client"; -import { Icon } from "@iconify/react"; +import { Mail, TriangleAlert } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; @@ -12,6 +12,7 @@ import { } from "@/app/(auth)/invitation/_lib/invitation-errors"; import { AuthBrand } from "@/components/auth/oss/auth-brand"; import { Button } from "@/components/shadcn"; +import { Spinner } from "@/components/shadcn/spinner/spinner"; type AcceptState = | { kind: "no-token" } @@ -74,10 +75,9 @@ export function AcceptInvitationClient({ {/* No token */} {state.kind === "no-token" && (
    -