diff --git a/.env b/.env index c6085538a0..35417637da 100644 --- a/.env +++ b/.env @@ -6,14 +6,20 @@ PROWLER_UI_VERSION="stable" AUTH_URL=http://localhost:3000 API_BASE_URL=http://prowler-api:8080/api/v1 +# deprecated, use UI_API_BASE_URL NEXT_PUBLIC_API_BASE_URL=${API_BASE_URL} +UI_API_BASE_URL=${API_BASE_URL} +# deprecated, use UI_API_DOCS_URL NEXT_PUBLIC_API_DOCS_URL=http://prowler-api:8080/api/v1/docs +UI_API_DOCS_URL=http://prowler-api:8080/api/v1/docs AUTH_TRUST_HOST=true UI_PORT=3000 # openssl rand -base64 32 AUTH_SECRET="N/c6mnaS5+SWq81+819OrzQZlmx1Vxtp/orjttJSmw8=" -# Google Tag Manager ID +# Google Tag Manager ID (empty/unset ⇒ GTM not loaded, zero egress) +# deprecated, use UI_GOOGLE_TAG_MANAGER_ID NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID="" +UI_GOOGLE_TAG_MANAGER_ID="" #### MCP Server #### PROWLER_MCP_VERSION=stable @@ -139,13 +145,19 @@ DJANGO_BROKER_VISIBILITY_TIMEOUT=86400 DJANGO_SENTRY_DSN= DJANGO_THROTTLE_TOKEN_OBTAIN=50/minute -# Sentry settings -SENTRY_ENVIRONMENT=local +# Sentry for the web app (server + browser). Empty/unset UI_SENTRY_DSN ⇒ +# Sentry disabled, zero egress. SENTRY_RELEASE (unprefixed) feeds the web app's +# server/edge SDKs. +UI_SENTRY_DSN= +UI_SENTRY_ENVIRONMENT=local SENTRY_RELEASE=local -NEXT_PUBLIC_SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT} +# Reserved runtime public config (registered now; no UI consumer yet) +# POSTHOG_KEY= +# POSTHOG_HOST= +# REO_DEV_CLIENT_ID= #### Prowler release version #### -NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.30.0 +NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.31.0 # Social login credentials SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google" diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3300394d83..ed97ff5a1a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,7 +1,6 @@ # SDK /* @prowler-cloud/detection-remediation /prowler/ @prowler-cloud/detection-remediation -/prowler/compliance/ @prowler-cloud/compliance /tests/ @prowler-cloud/detection-remediation /dashboard/ @prowler-cloud/detection-remediation /docs/ @prowler-cloud/detection-remediation diff --git a/.github/actions/osv-scanner/action.yml b/.github/actions/osv-scanner/action.yml index 4bfb5993cb..de5116fdcf 100644 --- a/.github/actions/osv-scanner/action.yml +++ b/.github/actions/osv-scanner/action.yml @@ -1,5 +1,5 @@ name: 'OSV-Scanner' -description: 'Install osv-scanner and scan a lockfile, failing on HIGH/CRITICAL/UNKNOWN severity findings. Posts/updates a PR comment with findings on pull_request events (requires pull-requests: write).' +description: 'Install osv-scanner and scan a lockfile, failing on CRITICAL severity findings. Posts/updates a PR comment with findings on pull_request events (requires pull-requests: write).' author: 'Prowler' inputs: @@ -7,9 +7,9 @@ inputs: description: 'Path to the lockfile to scan, relative to the repository root (e.g. uv.lock, api/uv.lock, ui/pnpm-lock.yaml).' required: true severity-levels: - description: 'Comma-separated severity levels that fail the scan. Default: HIGH,CRITICAL,UNKNOWN.' + description: 'Comma-separated severity levels that fail the scan. Default: CRITICAL.' required: false - default: 'HIGH,CRITICAL,UNKNOWN' + default: 'CRITICAL' version: description: 'osv-scanner release tag to install. When overriding, you MUST also override binary-sha256.' required: false diff --git a/.github/actions/setup-python-uv/action.yml b/.github/actions/setup-python-uv/action.yml index e23388d86e..14b9b81d15 100644 --- a/.github/actions/setup-python-uv/action.yml +++ b/.github/actions/setup-python-uv/action.yml @@ -43,8 +43,17 @@ runs: if: github.repository_owner == 'prowler-cloud' && github.repository != 'prowler-cloud/prowler' shell: bash working-directory: ${{ inputs.working-directory }} + env: + GITHUB_TOKEN: ${{ github.token }} run: | - LATEST_COMMIT=$(curl -s "https://api.github.com/repos/prowler-cloud/prowler/commits/master" | jq -r '.sha') + LATEST_COMMIT=$(curl -sf \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/prowler-cloud/prowler/commits/master" \ + | jq -er '.sha') || { + echo "::error::Failed to fetch latest prowler/master commit from the GitHub API (HTTP error or missing .sha). Check the GITHUB_TOKEN and API rate limits." + exit 1 + } echo "Latest commit hash: $LATEST_COMMIT" sed -i "s|\(git = \"https://github\.com/prowler-cloud/prowler\.git?rev=master\)#[a-f0-9]\{40\}\"|\1#${LATEST_COMMIT}\"|g" uv.lock echo "Updated uv.lock entry:" @@ -54,8 +63,17 @@ runs: if: github.event_name == 'push' && github.ref == 'refs/heads/master' && github.repository == 'prowler-cloud/prowler' shell: bash working-directory: ${{ inputs.working-directory }} + env: + GITHUB_TOKEN: ${{ github.token }} run: | - LATEST_COMMIT=$(curl -s "https://api.github.com/repos/prowler-cloud/prowler/commits/master" | jq -r '.sha') + LATEST_COMMIT=$(curl -sf \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/prowler-cloud/prowler/commits/master" \ + | jq -er '.sha') || { + echo "::error::Failed to fetch latest prowler/master commit from the GitHub API (HTTP error or missing .sha). Check the GITHUB_TOKEN and API rate limits." + exit 1 + } echo "Latest commit hash: $LATEST_COMMIT" sed -i "s|\(git = \"https://github\.com/prowler-cloud/prowler\.git?rev=master\)#[a-f0-9]\{40\}\"|\1#${LATEST_COMMIT}\"|g" uv.lock echo "Updated uv.lock entry:" diff --git a/.github/actions/trivy-scan/action.yml b/.github/actions/trivy-scan/action.yml index 9073e2fbcb..45034ddd6a 100644 --- a/.github/actions/trivy-scan/action.yml +++ b/.github/actions/trivy-scan/action.yml @@ -63,7 +63,7 @@ runs: exit-code: '0' scanners: 'vuln' timeout: '5m' - version: 'v0.69.2' + version: 'v0.71.0' - name: Run Trivy vulnerability scan (SARIF) if: inputs.upload-sarif == 'true' && github.event_name == 'push' @@ -76,7 +76,7 @@ runs: exit-code: '0' scanners: 'vuln' timeout: '5m' - version: 'v0.69.2' + version: 'v0.71.0' - name: Upload Trivy results to GitHub Security tab if: inputs.upload-sarif == 'true' && github.event_name == 'push' diff --git a/.github/scripts/osv-scan.sh b/.github/scripts/osv-scan.sh index d9c2e1a901..16afc6668c 100755 --- a/.github/scripts/osv-scan.sh +++ b/.github/scripts/osv-scan.sh @@ -6,8 +6,7 @@ # - .github/workflows/api-security.yml, sdk-security.yml, ui-security.yml # # Severity levels (comma-separated) are read from OSV_SEVERITY_LEVELS. -# Default: HIGH,CRITICAL,UNKNOWN — preserves prior .safety-policy.yml policy -# (ignore-cvss-severity-below: 7 + ignore-cvss-unknown-severity: False). +# Default: CRITICAL — only CVSS >= 9.0 findings fail the scan. # osv-scanner has no native CVSS threshold (google/osv-scanner#1400, closed # not-planned). Severity is derived from $group.max_severity (numeric CVSS # score string) which osv-scanner emits per group. @@ -33,7 +32,7 @@ set -euo pipefail ROOT="$(git rev-parse --show-toplevel)" CONFIG="${ROOT}/osv-scanner.toml" -SEVERITY_LEVELS="${OSV_SEVERITY_LEVELS:-HIGH,CRITICAL,UNKNOWN}" +SEVERITY_LEVELS="${OSV_SEVERITY_LEVELS:-CRITICAL}" for bin in osv-scanner jq; do if ! command -v "${bin}" >/dev/null 2>&1; then diff --git a/.github/workflows/api-container-checks.yml b/.github/workflows/api-container-checks.yml index 44482e2428..d12bf5d2d4 100644 --- a/.github/workflows/api-container-checks.yml +++ b/.github/workflows/api-container-checks.yml @@ -12,9 +12,6 @@ on: branches: - 'master' - 'v5.*' - paths: - - 'api/**' - - '.github/workflows/api-container-checks.yml' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -134,5 +131,5 @@ jobs: with: image-name: ${{ env.IMAGE_NAME }} image-tag: ${{ github.sha }} - fail-on-critical: 'false' + fail-on-critical: 'true' severity: 'CRITICAL' diff --git a/.github/workflows/api-security.yml b/.github/workflows/api-security.yml index ba7f5fe58a..ed4e5476d2 100644 --- a/.github/workflows/api-security.yml +++ b/.github/workflows/api-security.yml @@ -16,13 +16,6 @@ on: branches: - "master" - "v5.*" - paths: - - 'api/**' - - '.github/workflows/api-tests.yml' - - '.github/workflows/api-security.yml' - - '.github/actions/setup-python-uv/**' - - '.github/actions/osv-scanner/**' - - '.github/scripts/osv-scan.sh' concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/.github/workflows/mcp-container-checks.yml b/.github/workflows/mcp-container-checks.yml index 7493e1b763..23e6ba2984 100644 --- a/.github/workflows/mcp-container-checks.yml +++ b/.github/workflows/mcp-container-checks.yml @@ -12,9 +12,6 @@ on: branches: - 'master' - 'v5.*' - paths: - - 'mcp_server/**' - - '.github/workflows/mcp-container-checks.yml' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -127,5 +124,5 @@ jobs: with: image-name: ${{ env.IMAGE_NAME }} image-tag: ${{ github.sha }} - fail-on-critical: 'false' + fail-on-critical: 'true' severity: 'CRITICAL' diff --git a/.github/workflows/mcp-security.yml b/.github/workflows/mcp-security.yml index 66a5cd8c0d..4deb6a478d 100644 --- a/.github/workflows/mcp-security.yml +++ b/.github/workflows/mcp-security.yml @@ -15,12 +15,6 @@ on: branches: - 'master' - 'v5.*' - paths: - - 'mcp_server/pyproject.toml' - - 'mcp_server/uv.lock' - - '.github/workflows/mcp-security.yml' - - '.github/actions/osv-scanner/**' - - '.github/scripts/osv-scan.sh' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -30,7 +24,6 @@ permissions: {} jobs: mcp-security-scans: - if: github.repository == 'prowler-cloud/prowler' runs-on: ubuntu-latest timeout-minutes: 15 permissions: diff --git a/.github/workflows/sdk-code-quality.yml b/.github/workflows/sdk-code-quality.yml index 5c76547989..2b1efc69a9 100644 --- a/.github/workflows/sdk-code-quality.yml +++ b/.github/workflows/sdk-code-quality.yml @@ -29,6 +29,7 @@ jobs: - '3.10' - '3.11' - '3.12' + - '3.13' steps: - name: Harden Runner diff --git a/.github/workflows/sdk-container-checks.yml b/.github/workflows/sdk-container-checks.yml index 42709ac6f7..2b436db468 100644 --- a/.github/workflows/sdk-container-checks.yml +++ b/.github/workflows/sdk-container-checks.yml @@ -15,12 +15,6 @@ on: branches: - 'master' - 'v5.*' - paths: - - 'prowler/**' - - 'Dockerfile*' - - 'pyproject.toml' - - 'uv.lock' - - '.github/workflows/sdk-container-checks.yml' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -111,25 +105,14 @@ jobs: id: check-changes uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: - files: ./** + files: | + prowler/** + Dockerfile* + pyproject.toml + uv.lock + .github/workflows/sdk-container-checks.yml files_ignore: | - .github/** prowler/CHANGELOG.md - docs/** - permissions/** - api/** - ui/** - dashboard/** - mcp_server/** - skills/** - README.md - mkdocs.yml - .backportrc.json - .env - docker-compose* - examples/** - .gitignore - contrib/** **/AGENTS.md - name: Set up Docker Buildx @@ -153,5 +136,5 @@ jobs: with: image-name: ${{ env.IMAGE_NAME }} image-tag: ${{ github.sha }} - fail-on-critical: 'false' + fail-on-critical: 'true' severity: 'CRITICAL' diff --git a/.github/workflows/sdk-security.yml b/.github/workflows/sdk-security.yml index c18f56ea8f..11a7894ce8 100644 --- a/.github/workflows/sdk-security.yml +++ b/.github/workflows/sdk-security.yml @@ -19,16 +19,6 @@ on: branches: - 'master' - 'v5.*' - paths: - - 'prowler/**' - - 'tests/**' - - 'pyproject.toml' - - 'uv.lock' - - '.github/workflows/sdk-tests.yml' - - '.github/workflows/sdk-security.yml' - - '.github/actions/setup-python-uv/**' - - '.github/actions/osv-scanner/**' - - '.github/scripts/osv-scan.sh' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -71,27 +61,18 @@ jobs: id: check-changes uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: - files: - ./** + files: | + prowler/** + tests/** + pyproject.toml + uv.lock + .github/workflows/sdk-tests.yml .github/workflows/sdk-security.yml + .github/actions/setup-python-uv/** + .github/actions/osv-scanner/** + .github/scripts/osv-scan.sh files_ignore: | - .github/** prowler/CHANGELOG.md - docs/** - permissions/** - api/** - ui/** - dashboard/** - mcp_server/** - skills/** - README.md - mkdocs.yml - .backportrc.json - .env - docker-compose* - examples/** - .gitignore - contrib/** **/AGENTS.md - name: Setup Python with uv diff --git a/.github/workflows/sdk-tests.yml b/.github/workflows/sdk-tests.yml index d0ef83c772..2952ebc2d5 100644 --- a/.github/workflows/sdk-tests.yml +++ b/.github/workflows/sdk-tests.yml @@ -29,6 +29,7 @@ jobs: - '3.10' - '3.11' - '3.12' + - '3.13' steps: - name: Harden Runner @@ -589,6 +590,33 @@ jobs: flags: prowler-py${{ matrix.python-version }}-stackit files: ./stackit_coverage.xml + # External Provider (dynamic loading) + - name: Check if External Provider files changed + if: steps.check-changes.outputs.any_changed == 'true' + id: changed-external + uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + with: + files: | + ./prowler/providers/common/** + ./prowler/config/** + ./prowler/lib/** + ./tests/providers/external/** + ./uv.lock + + - name: Run External Provider tests + if: steps.changed-external.outputs.any_changed == 'true' + run: uv run pytest -n auto --cov=./prowler/providers/common --cov=./prowler/config --cov=./prowler/lib --cov-report=xml:external_coverage.xml tests/providers/external + + - name: Upload External Provider coverage to Codecov + if: steps.changed-external.outputs.any_changed == 'true' + + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + flags: prowler-py${{ matrix.python-version }}-external + files: ./external_coverage.xml + # Lib - name: Check if Lib files changed if: steps.check-changes.outputs.any_changed == 'true' diff --git a/.github/workflows/ui-container-build-push.yml b/.github/workflows/ui-container-build-push.yml index 984256af7f..3210f6e998 100644 --- a/.github/workflows/ui-container-build-push.yml +++ b/.github/workflows/ui-container-build-push.yml @@ -32,9 +32,6 @@ env: PROWLERCLOUD_DOCKERHUB_REPOSITORY: prowlercloud PROWLERCLOUD_DOCKERHUB_IMAGE: prowler-ui - # Build args - NEXT_PUBLIC_API_BASE_URL: http://prowler-api:8080/api/v1 - permissions: {} jobs: @@ -146,7 +143,6 @@ jobs: context: ${{ env.WORKING_DIRECTORY }} build-args: | NEXT_PUBLIC_PROWLER_RELEASE_VERSION=${{ (github.event_name == 'release' || github.event_name == 'workflow_dispatch') && format('v{0}', env.RELEASE_TAG) || needs.setup.outputs.short-sha }} - NEXT_PUBLIC_API_BASE_URL=${{ env.NEXT_PUBLIC_API_BASE_URL }} push: true platforms: ${{ matrix.platform }} tags: | diff --git a/.github/workflows/ui-container-checks.yml b/.github/workflows/ui-container-checks.yml index 0eae703fce..a931e50f22 100644 --- a/.github/workflows/ui-container-checks.yml +++ b/.github/workflows/ui-container-checks.yml @@ -12,9 +12,6 @@ on: branches: - 'master' - 'v5.*' - paths: - - 'ui/**' - - '.github/workflows/ui-container-checks.yml' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -132,5 +129,5 @@ jobs: with: image-name: ${{ env.IMAGE_NAME }} image-tag: ${{ github.sha }} - fail-on-critical: 'false' + fail-on-critical: 'true' severity: 'CRITICAL' diff --git a/.github/workflows/ui-e2e-tests-v2.yml b/.github/workflows/ui-e2e-tests-v2.yml index 5e50adb19c..7165882d62 100644 --- a/.github/workflows/ui-e2e-tests-v2.yml +++ b/.github/workflows/ui-e2e-tests-v2.yml @@ -40,7 +40,8 @@ jobs: AUTH_SECRET: 'fallback-ci-secret-for-testing' AUTH_TRUST_HOST: true NEXTAUTH_URL: 'http://localhost:3000' - NEXT_PUBLIC_API_BASE_URL: 'http://localhost:8080/api/v1' + 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 }} @@ -77,6 +78,14 @@ jobs: E2E_ALIBABACLOUD_ACCESS_KEY_ID: ${{ secrets.E2E_ALIBABACLOUD_ACCESS_KEY_ID }} E2E_ALIBABACLOUD_ACCESS_KEY_SECRET: ${{ secrets.E2E_ALIBABACLOUD_ACCESS_KEY_SECRET }} E2E_ALIBABACLOUD_ROLE_ARN: ${{ secrets.E2E_ALIBABACLOUD_ROLE_ARN }} + E2E_OKTA_DOMAIN: ${{ secrets.E2E_OKTA_DOMAIN }} + E2E_OKTA_CLIENT_ID: ${{ secrets.E2E_OKTA_CLIENT_ID }} + E2E_OKTA_BASE64_PRIVATE_KEY: ${{ secrets.E2E_OKTA_BASE64_PRIVATE_KEY }} + E2E_GOOGLEWORKSPACE_CUSTOMER_ID: ${{ secrets.E2E_GOOGLEWORKSPACE_CUSTOMER_ID }} + E2E_GOOGLEWORKSPACE_SERVICE_ACCOUNT_JSON: ${{ secrets.E2E_GOOGLEWORKSPACE_SERVICE_ACCOUNT_JSON }} + E2E_GOOGLEWORKSPACE_DELEGATED_USER: ${{ secrets.E2E_GOOGLEWORKSPACE_DELEGATED_USER }} + E2E_VERCEL_TEAM_ID: ${{ secrets.E2E_VERCEL_TEAM_ID }} + E2E_VERCEL_API_TOKEN: ${{ secrets.E2E_VERCEL_API_TOKEN }} # Pass E2E paths from impact analysis E2E_TEST_PATHS: ${{ needs.impact-analysis.outputs.ui-e2e }} RUN_ALL_TESTS: ${{ needs.impact-analysis.outputs.run-all }} @@ -134,7 +143,17 @@ jobs: # docker-compose.yml references prowlercloud/prowler-api:latest from the registry, # which lags behind PR changes; build locally so E2E exercises the API image # produced by this PR. - run: docker build -t prowlercloud/prowler-api:latest ./api + # + # The image installs the SDK from git@master (api/uv.lock), so a PR changing BOTH the SDK + # and the API would run against the OLD SDK and crash on startup. Overlay the checkout's + # SDK source so both run together. New SDK dependencies still need an api/uv.lock bump. + run: | + docker build -t prowlercloud/prowler-api:pr-base ./api + docker build -t prowlercloud/prowler-api:latest -f - prowler <<'DOCKERFILE' + FROM prowlercloud/prowler-api:pr-base + RUN rm -rf /home/prowler/.venv/lib/python3.12/site-packages/prowler + COPY --chown=prowler:prowler . /home/prowler/.venv/lib/python3.12/site-packages/prowler + DOCKERFILE - name: Start API services run: | @@ -147,7 +166,7 @@ jobs: timeout=150 elapsed=0 while [ $elapsed -lt $timeout ]; do - if curl -s ${NEXT_PUBLIC_API_BASE_URL}/docs >/dev/null 2>&1; then + if curl -s ${UI_API_BASE_URL}/docs >/dev/null 2>&1; then echo "Prowler API is ready!" exit 0 fi diff --git a/.github/workflows/ui-security.yml b/.github/workflows/ui-security.yml index 470fae1f9c..2dc444654f 100644 --- a/.github/workflows/ui-security.yml +++ b/.github/workflows/ui-security.yml @@ -15,12 +15,6 @@ on: branches: - 'master' - 'v5.*' - paths: - - 'ui/package.json' - - 'ui/pnpm-lock.yaml' - - '.github/workflows/ui-security.yml' - - '.github/actions/osv-scanner/**' - - '.github/scripts/osv-scan.sh' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -30,7 +24,6 @@ permissions: {} jobs: ui-security-scans: - if: github.repository == 'prowler-cloud/prowler' runs-on: ubuntu-latest timeout-minutes: 15 permissions: diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 918755a629..2dc50a26bb 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -131,6 +131,10 @@ jobs: if: steps.check-changes.outputs.any_changed == 'true' run: pnpm run healthcheck + - name: Check product-tour alignment + if: steps.check-changes.outputs.any_changed == 'true' + run: pnpm run tour:check + - name: Run pnpm audit if: steps.check-changes.outputs.any_changed == 'true' run: pnpm run audit diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4e79141bb7..1e9d74c973 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,6 +7,10 @@ # P50 — dependency validation default_install_hook_types: [pre-commit] +# Hooks run on commit only by default; +# NOTE: default_stages does NOT override a hook's manifest stages, so fixers shipping pre-push in their +# manifest need an explicit stages: ["pre-commit"] below to stay off push. +default_stages: [pre-commit] repos: ## GENERAL (prek built-in — no external repo needed) @@ -21,13 +25,16 @@ repos: - id: check-json priority: 10 - id: end-of-file-fixer + stages: ["pre-commit"] priority: 0 - id: trailing-whitespace + stages: ["pre-commit"] priority: 0 - id: no-commit-to-branch priority: 10 - id: pretty-format-json args: ["--autofix", --no-sort-keys, --no-ensure-ascii] + stages: ["pre-commit"] priority: 10 ## TOML @@ -82,6 +89,7 @@ repos: name: "SDK - isort" files: { glob: ["{prowler,tests,dashboard,util,scripts}/**/*.py"] } args: ["--profile", "black"] + stages: ["pre-commit"] priority: 20 - repo: https://github.com/psf/black diff --git a/.trivyignore b/.trivyignore new file mode 100644 index 0000000000..117925354f --- /dev/null +++ b/.trivyignore @@ -0,0 +1,85 @@ +# Trivy ignore file for prowlercloud/prowler SDK container image. +# Each entry below documents (a) the affected package and why it ships in the +# image, (b) why the CVE is not exploitable in Prowler's runtime, and (c) the +# upstream fix status. Entries carry an expiry so they auto-force re-review. +# Entries are scoped per-package so suppressions cannot drift onto unrelated +# packages that may be assigned the same CVE in the future. +# +# Scanned by: .github/actions/trivy-scan via .github/workflows/sdk-container-checks.yml + +# CVE-2026-42496 — perl-archive-tar path traversal via crafted symlinks. +# CVE-2026-8376 — perl heap buffer overflow when compiling regex. +# Packages: perl, perl-base, perl-modules-5.36, libperl5.36. +# Why ignored: perl-base is part of Debian's "Essential: yes" set; it cannot be +# removed without breaking dpkg. The Prowler SDK does not invoke perl at runtime; +# neither vulnerable code path (Archive::Tar parsing or regex compilation of +# attacker-controlled input) is reachable from Prowler. No Debian bookworm fix +# is available yet. +CVE-2026-42496 pkg:perl exp:2026-07-15 +CVE-2026-42496 pkg:perl-base exp:2026-07-15 +CVE-2026-42496 pkg:perl-modules-5.36 exp:2026-07-15 +CVE-2026-42496 pkg:libperl5.36 exp:2026-07-15 +CVE-2026-8376 pkg:perl exp:2026-07-15 +CVE-2026-8376 pkg:perl-base exp:2026-07-15 +CVE-2026-8376 pkg:perl-modules-5.36 exp:2026-07-15 +CVE-2026-8376 pkg:libperl5.36 exp:2026-07-15 + +# CVE-2025-7458 — SQLite integer overflow. +# Package: libsqlite3-0. +# Why ignored: transitive dependency of CPython's stdlib sqlite3 module. The +# Prowler SDK does not open user-supplied SQLite databases; SQLite usage is +# internal and bounded. No Debian bookworm fix is available. +CVE-2025-7458 pkg:libsqlite3-0 exp:2026-07-15 + +# CVE-2026-43185 — Linux kernel ksmbd signedness bug. +# Package: linux-libc-dev. +# Why ignored: linux-libc-dev ships kernel headers for build-time compilation, +# not a running kernel. Containers execute against the host kernel, so these +# headers are inert at runtime. The upstream fix landed in kernel 7.0-rc2 and +# has not been backported to Debian's 6.1 LTS line. +CVE-2026-43185 pkg:linux-libc-dev exp:2026-07-15 + +# CVE-2023-45853 — zlib MiniZip integer overflow / heap overflow in +# zipOpenNewFileInZip4_64. +# Packages: zlib1g, zlib1g-dev. +# Why ignored: Debian Security Tracker status for bookworm is , with +# the published rationale "contrib/minizip not built and src:zlib not producing +# binary packages" — i.e. the vulnerable symbol is not present in the libz.so +# shipped by Debian. Real-not-affected, not unpatched. Upstream fix is in +# zlib 1.3.1, available in Debian trixie (13); migrating the base image would +# clear it fully. +# Ref: https://security-tracker.debian.org/tracker/CVE-2023-45853 +CVE-2023-45853 pkg:zlib1g exp:2026-07-15 +CVE-2023-45853 pkg:zlib1g-dev exp:2026-07-15 + +# --- API container image (api/Dockerfile) --- +# The entries below are specific to the Prowler API image, which ships +# PowerShell and additional build tooling on top of the same bookworm base. + +# CVE-2026-7210 — CPython/Expat hash-flooding denial of service in +# `xml.parsers.expat` and `xml.etree.ElementTree`. +# Packages: the Debian system Python 3.11 (python3.11*, libpython3.11*). +# Why ignored: the API runs under the Python 3.12 interpreter shipped in its +# `.venv`; the system `python3.11` is only present because `python3-dev` is +# pulled in to compile native extensions (xmlsec, lxml) and is never executed +# at runtime. The vulnerable path requires parsing attacker-controlled XML with +# the affected interpreter, which Prowler does not do with the system Python. +# Full mitigation also needs libexpat >= 2.8.0; no Debian bookworm fix yet. +CVE-2026-7210 pkg:python3.11 exp:2026-07-15 +CVE-2026-7210 pkg:python3.11-dev exp:2026-07-15 +CVE-2026-7210 pkg:python3.11-minimal exp:2026-07-15 +CVE-2026-7210 pkg:libpython3.11 exp:2026-07-15 +CVE-2026-7210 pkg:libpython3.11-dev exp:2026-07-15 +CVE-2026-7210 pkg:libpython3.11-minimal exp:2026-07-15 +CVE-2026-7210 pkg:libpython3.11-stdlib exp:2026-07-15 + +# CVE-2026-33278 — Unbound DNSSEC validator use-after-free (DoS, possible RCE). +# CVE-2026-42960 — Unbound DNS cache poisoning via promiscuous additional records. +# Package: libunbound8. +# Why ignored: libunbound8 is a transitive apt dependency of the TLS/networking +# stack (GnuTLS DANE support); only the shared library ships in the image. Both +# vulnerabilities require operating a live Unbound recursive DNSSEC validator +# that processes attacker-influenced DNS responses. Prowler never starts an +# Unbound resolver, so neither code path is reachable. No Debian bookworm fix yet. +CVE-2026-33278 pkg:libunbound8 exp:2026-07-15 +CVE-2026-42960 pkg:libunbound8 exp:2026-07-15 diff --git a/AGENTS.md b/AGENTS.md index 1cf023889c..c9d63a8c27 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,6 +51,7 @@ Use these skills for detailed patterns on-demand: | `django-migration-psql` | Django migration best practices for PostgreSQL | [SKILL.md](skills/django-migration-psql/SKILL.md) | | `postgresql-indexing` | PostgreSQL indexing, EXPLAIN, monitoring, maintenance | [SKILL.md](skills/postgresql-indexing/SKILL.md) | | `prowler-attack-paths-query` | Create Attack Paths openCypher queries | [SKILL.md](skills/prowler-attack-paths-query/SKILL.md) | +| `prowler-tour` | Keep product-tour definitions aligned with the UI | [SKILL.md](skills/prowler-tour/SKILL.md) | | `gh-aw` | GitHub Agentic Workflows (gh-aw) | [SKILL.md](skills/gh-aw/SKILL.md) | | `skill-creator` | Create new AI agent skills | [SKILL.md](skills/skill-creator/SKILL.md) | @@ -67,10 +68,12 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST: | Adding new providers | `prowler-provider` | | Adding privilege escalation detection queries | `prowler-attack-paths-query` | | Adding services to existing providers | `prowler-provider` | +| Adding, updating, or removing a tour definition (*.tour.ts) | `prowler-tour` | | After creating/modifying a skill | `skill-sync` | | App Router / Server Actions | `nextjs-16` | | Auditing check-to-requirement mappings as a cloud auditor | `prowler-compliance` | | Building AI chat features | `ai-sdk-5` | +| Changing button labels or section headings on a tour-covered page | `prowler-tour` | | Committing changes | `prowler-commit` | | Configuring MCP servers in agentic workflows | `gh-aw` | | Create PR that requires changelog entry | `prowler-changelog` | @@ -89,6 +92,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST: | Creating/updating compliance frameworks | `prowler-compliance` | | Debug why a GitHub Actions job is failing | `prowler-ci` | | Debugging gh-aw compilation errors | `gh-aw` | +| Editing a UI file containing data-tour-id attributes | `prowler-tour` | | Fill .github/pull_request_template.md (Context/Description/Steps to review/Checklist) | `prowler-pr` | | Fixing bug | `tdd` | | Fixing compliance JSON bugs (duplicate IDs, empty Section, stale refs) | `prowler-compliance` | @@ -105,6 +109,8 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST: | Modifying gh-aw workflow frontmatter or safe-outputs | `gh-aw` | | Refactoring code | `tdd` | | Regenerate AGENTS.md Auto-invoke tables (sync.sh) | `skill-sync` | +| Renaming or removing a data-tour-id attribute value | `prowler-tour` | +| Restructuring routes or layouts covered by a tour | `prowler-tour` | | Review PR requirements: template, title conventions, changelog gate | `prowler-pr` | | Review changelog format and conventions | `prowler-changelog` | | Reviewing JSON:API compliance | `jsonapi` | diff --git a/Dockerfile b/Dockerfile index 0ab4458fad..678cb2b36f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.12.11-slim-bookworm@sha256:519591d6871b7bc437060736b9f7456b8731f1499a57e22e6c285135ae657bf7 AS build +FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf AS build LABEL maintainer="https://github.com/prowler-cloud/prowler" LABEL org.opencontainers.image.source="https://github.com/prowler-cloud/prowler" @@ -6,7 +6,7 @@ LABEL org.opencontainers.image.source="https://github.com/prowler-cloud/prowler" ARG POWERSHELL_VERSION=7.5.0 ENV POWERSHELL_VERSION=${POWERSHELL_VERSION} -ARG TRIVY_VERSION=0.70.0 +ARG TRIVY_VERSION=0.71.0 ENV TRIVY_VERSION=${TRIVY_VERSION} ARG ZIZMOR_VERSION=1.24.1 diff --git a/Makefile b/Makefile index 8bdb9bf156..e2623a4bf3 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,34 @@ .DEFAULT_GOAL:=help +DEV_LOCAL := ./scripts/development/dev-local.sh + +.PHONY: dev dev-setup dev-attach dev-launch dev-stop dev-clean dev-wipe dev-status + +##@ Local Development +dev: ## Start local API, worker, and database logs + $(DEV_LOCAL) all + +dev-setup: ## Bootstrap local dependencies, migrations, and fixtures + $(DEV_LOCAL) setup + +dev-attach: ## Attach to the local tmux development session + $(DEV_LOCAL) attach + +dev-launch: ## Start the local stack on fixed ports and attach + $(DEV_LOCAL) launch + +dev-stop: ## Stop the local tmux session and containers + $(DEV_LOCAL) kill + +dev-clean: ## Remove stopped local development containers + $(DEV_LOCAL) clean + +dev-wipe: ## Stop everything and delete local development data + $(DEV_LOCAL) wipe + +dev-status: ## Show local development container status + $(DEV_LOCAL) status + ##@ Testing test: ## Test with pytest rm -rf .coverage && \ diff --git a/README.md b/README.md index eff78169b0..74e91021ca 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically | Vercel | 26 | 6 | 0 | 8 | Official | UI, API, CLI | | Okta | 1 | 1 | 0 | 1 | Official | CLI | | Scaleway [Contact us](https://prowler.com/contact) | 1 | 1 | 0 | 1 | Unofficial | CLI | -| StackIT [Contact us](https://prowler.com/contact) | 4 | 1 | 0 | 1 | Unofficial | CLI | +| StackIT [Contact us](https://prowler.com/contact) | 7 | 2 | 0 | 3 | Unofficial | CLI | | NHN | 6 | 2 | 1 | 0 | Unofficial | CLI | > [!Note] @@ -153,6 +153,8 @@ Prowler App offers flexible installation methods tailored to various environment #### Commands +_macOS/Linux:_ + ``` console VERSION=$(curl -s https://api.github.com/repos/prowler-cloud/prowler/releases/latest | jq -r .tag_name) curl -sLO "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/${VERSION}/docker-compose.yml" @@ -161,6 +163,16 @@ curl -sLO "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/${V docker compose up -d ``` +_Windows PowerShell:_ + +``` powershell +$VERSION = (Invoke-RestMethod -Uri "https://api.github.com/repos/prowler-cloud/prowler/releases/latest").tag_name +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/$VERSION/docker-compose.yml" -OutFile "docker-compose.yml" +# Environment variables can be customized in the .env file. Using default values in production environments is not recommended. +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/$VERSION/.env" -OutFile ".env" +docker compose up -d +``` + > [!WARNING] > 🔒 For a secure setup, the API auto-generates a unique key pair, `DJANGO_TOKEN_SIGNING_KEY` and `DJANGO_TOKEN_VERIFYING_KEY`, and stores it in `~/.config/prowler-api` (non-container) or the bound Docker volume in `_data/api` (container). Never commit or reuse static/default keys. To rotate keys, delete the stored key files and restart the API. diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index bbc675c8d2..8e2130fb88 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -2,6 +2,92 @@ All notable changes to the **Prowler API** are documented in this file. +## [1.32.0] (Prowler UNRELEASED) + +### 🚀 Added + +- Provider group filters for API endpoints that support cloud provider filtering, including exact and `__in` variants [(#11573)](https://github.com/prowler-cloud/prowler/pull/11573) +- Provider filters for `GET /api/v1/compliance-overviews`, `/metadata`, and `/requirements`, using latest completed scans per matching provider [(#11587)](https://github.com/prowler-cloud/prowler/pull/11587) +- Server-Sent Events (SSE) infrastructure for the API: a base viewset, a tenant-aware channel manager, and channel-name helpers backed by `django-eventstream` over Valkey Pub/Sub and served through the Gunicorn ASGI worker, so feature endpoints can stream events to clients over a single long-lived connection [(#11556)](https://github.com/prowler-cloud/prowler/pull/11556) + +### 🔄 Changed + +- Gunicorn worker timeout raised from the 30s default to 120s, so long-running requests are no longer killed prematurely [(#11631)](https://github.com/prowler-cloud/prowler/pull/11631) +- Sentry now drops ASGI's `RequestAborted` errors from health-check probe disconnects on `/health/live` [(#11632)](https://github.com/prowler-cloud/prowler/pull/11632) + +### 🐞 Fixed + +- Database connections no longer leak under the ASGI worker, which previously exhausted the read replica's connection slots and caused 500s on read endpoints [(#11640)](https://github.com/prowler-cloud/prowler/pull/11640) + +### 🔐 Security + +- `aiohttp` to 3.14.0 and `idna` to 3.15, patching known CVEs [(#11596)](https://github.com/prowler-cloud/prowler/pull/11596) +- Container base image to `python:3.12.13-slim-bookworm` and `trivy` to 0.71.0, patching OS and Go module CVEs [(#11596)](https://github.com/prowler-cloud/prowler/pull/11596) +- `trivy` binary bumped to 0.71.0 patching embedded `golang.org/x/crypto`, `golang.org/x/net`, and Go `stdlib` CVEs [(#11592)](https://github.com/prowler-cloud/prowler/pull/11592) + +--- + +## [1.31.2] (Prowler v5.30.2) + +### 🔄 Changed + +- `scan-compliance-overviews` task now streams the findings aggregation and the requirement-row writes so it runs faster and its peak memory no longer grows with the number of regions and frameworks [(#11591)](https://github.com/prowler-cloud/prowler/pull/11591) + +--- + +## [1.31.1] (Prowler v5.30.1) + +### 🐞 Fixed + +- `compliance-overviews/attributes` now resolves the provider from the scan, so multi-provider universal frameworks (e.g. CSA CCM) return the check IDs of the scan's provider and Azure/GCP requirement details show their findings instead of appearing empty [(#11546)](https://github.com/prowler-cloud/prowler/pull/11546) +- Attack Paths: `drop_subgraph` now deletes relationships first and then nodes in batches, using less memory on Neo4j when clearing a dense provider graph [(#11557)](https://github.com/prowler-cloud/prowler/pull/11557) +- OCI scans now use API key credentials with the configured region instead of falling back to `/home/prowler/.oci/config` [(#11558)](https://github.com/prowler-cloud/prowler/pull/11558) + +--- + +## [1.31.0] (Prowler v5.30.0) + +### 🚀 Added + +- Opt-in automatic recovery of allowlisted idempotent background tasks whose worker died during a deploy or crash: when enabled via `DJANGO_TASK_RECOVERY_ENABLED` (off by default), stuck summary and deletion tasks are detected and re-run instead of staying pending forever (scan and Jira tasks are excluded), with a `reconcile_orphan_tasks` management command for on-demand recovery [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416) +- DORA compliance framework support [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131) +- Label Postgres connections with `application_name=":"` (component injected per process via `DJANGO_APP_COMPONENT`) so connections are attributable by component in `pg_stat_activity` [(#11494)](https://github.com/prowler-cloud/prowler/pull/11494) +- DISA Okta IDaaS STIG V1R2 compliance framework export support for the Okta provider [(#11428)](https://github.com/prowler-cloud/prowler/pull/11428) + +### 🔄 Changed + +- Allowlisted idempotent background tasks are no longer lost when a worker is stopped or crashes mid-task; tasks with external side effects are marked terminal instead of blindly re-running [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416) +- SAML logins no longer wipe a user's roles when the IdP does not send the `userType` attribute; existing roles are kept, and when `userType` names a role that does not exist it is now created with read-only access (visibility over all providers, no management permissions) instead of no permissions at all [(#11520)](https://github.com/prowler-cloud/prowler/pull/11520) + +### 🐞 Fixed + +- Workers now shut down gracefully on deploy or restart, finishing or re-queueing in-flight tasks instead of being force-killed and leaving them stuck [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416) +- Resource `name` is now stored and refreshed on every scan, so resources no longer keep an empty name [(#11476)](https://github.com/prowler-cloud/prowler/pull/11476) +- Compliance catalog now warms in background during startup. `compliance-overviews/attributes` returns `503` while warming, so the first request after a deploy no longer trips the API timeout [(#11530)](https://github.com/prowler-cloud/prowler/pull/11530) + +### 🔐 Security + +- `dulwich` from 0.23.0 to 1.2.5 and `pyjwt` from 2.12.1 to 2.13.0, patching `GHSA-897w-fcg9-f6xj` (arbitrary file write) and `PYSEC-2026-179` (HMAC/JWK key confusion) [(#11499)](https://github.com/prowler-cloud/prowler/pull/11499) + +--- + +## [1.30.3] (Prowler v5.29.3) + +### 🐞 Fixed + +- API startup no longer crashes when Neo4j is unreachable, as the Neo4j driver now connects lazily on first use rather than during app initialization [(#11491)](https://github.com/prowler-cloud/prowler/pull/11491) + +--- + +## [1.30.1] (Prowler v5.29.1) + +### 🐞 Fixed + +- `GET /api/v1/findings` N+1 query loading `resources__tags` when listing findings [(#11420)](https://github.com/prowler-cloud/prowler/pull/11420) +- Clean up the scan tmp output directory when `scan-report` fails so partial files do not accumulate and fill the worker disk (`No space left on device`) [(#11421)](https://github.com/prowler-cloud/prowler/pull/11421) + +--- + ## [1.30.0] (Prowler v5.29.0) ### 🔄 Changed diff --git a/api/Dockerfile b/api/Dockerfile index 259492cb08..bb9da0b280 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,11 +1,11 @@ -FROM python:3.12.10-slim-bookworm@sha256:fd95fa221297a88e1cf49c55ec1828edd7c5a428187e67b5d1805692d11588db AS build +FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf AS build LABEL maintainer="https://github.com/prowler-cloud/api" ARG POWERSHELL_VERSION=7.5.0 ENV POWERSHELL_VERSION=${POWERSHELL_VERSION} -ARG TRIVY_VERSION=0.70.0 +ARG TRIVY_VERSION=0.71.0 ENV TRIVY_VERSION=${TRIVY_VERSION} ARG ZIZMOR_VERSION=1.24.1 diff --git a/api/README.md b/api/README.md index ea9661366f..c510479ab2 100644 --- a/api/README.md +++ b/api/README.md @@ -196,6 +196,42 @@ python -m celery -A config.celery worker -l info -E The Celery worker does not detect and reload changes in the code, so you need to restart it manually when you make changes. +### Makefile-Assisted Local Deployment + +This method is an additional local development workflow. It does not replace the manual local deployment or the Docker deployment described in this guide. + +PostgreSQL, Valkey, and Neo4j run with Docker Compose, while Django and the Celery worker run natively through `uv`. Additionally, this workflow creates a `tmux` session with panes for the API, worker, and PostgreSQL logs. + +Before using this method, ensure `docker compose`, `tmux`, and `uv` are installed. + +This workflow is designed for macOS and should also work on Linux when Docker, `tmux`, and `uv` are available. Windows requires script changes before it can be supported. + +From the repository root, run: + +```console +make dev +``` + +The API will be available at: + +```console +http://localhost:8080/api/v1 +``` + +Use these commands to manage the local stack: + +```console +make dev-setup # Bootstrap dependencies, migrations, and fixtures +make dev-attach # Attach to the tmux session +make dev-launch # Start the stack on fixed ports and attach +make dev-stop # Stop the tmux session and containers +make dev-clean # Remove stopped development containers +make dev-wipe # Stop everything and delete local development data +make dev-status # Show development container status +``` + +This workflow does not start the UI. Start it separately from the `ui/` directory when needed. + ### Docker deployment This method requires `docker` and `docker compose`. diff --git a/api/docker-entrypoint.sh b/api/docker-entrypoint.sh index 4535fb34e2..e077a3bfd6 100755 --- a/api/docker-entrypoint.sh +++ b/api/docker-entrypoint.sh @@ -21,13 +21,19 @@ apply_fixtures() { } start_dev_server() { - echo "Starting the development server..." - uv run python manage.py runserver 0.0.0.0:"${DJANGO_PORT:-8080}" + echo "Starting the development server (Gunicorn ASGI, debug + reload)..." + # Same server/worker as prod (config.asgi via the native `asgi` worker), so + # SSE streams run on the event loop exactly as they do in production. DEBUG is + # on so guniconf's `reload = DEBUG` hot-reloads edited code (and flips + # `preload_app` off so reload actually takes). + export DJANGO_DEBUG="${DJANGO_DEBUG:-True}" + export DJANGO_BIND_ADDRESS="${DJANGO_BIND_ADDRESS:-0.0.0.0}" + exec uv run gunicorn -c config/guniconf.py config.asgi:application } start_prod_server() { echo "Starting the Gunicorn server..." - uv run gunicorn -c config/guniconf.py config.wsgi:application + exec uv run gunicorn -c config/guniconf.py config.asgi:application } resolve_worker_hostname() { @@ -47,7 +53,7 @@ resolve_worker_hostname() { start_worker() { echo "Starting the worker..." - uv run python -m celery -A config.celery worker \ + exec uv run python -m celery -A config.celery worker \ -n "$(resolve_worker_hostname)" \ -l "${DJANGO_LOGGING_LEVEL:-info}" \ -Q celery,scans,scan-reports,deletion,backfill,overview,integrations,compliance,attack-paths-scans \ @@ -56,7 +62,7 @@ start_worker() { start_worker_beat() { echo "Starting the worker-beat..." - uv run python -m celery -A config.celery beat -l "${DJANGO_LOGGING_LEVEL:-info}" --scheduler django_celery_beat.schedulers:DatabaseScheduler + exec uv run python -m celery -A config.celery beat -l "${DJANGO_LOGGING_LEVEL:-info}" --scheduler django_celery_beat.schedulers:DatabaseScheduler } manage_db_partitions() { @@ -68,6 +74,15 @@ manage_db_partitions() { fi } +# Identify this process to Postgres (application_name=:) so +# connections are attributable by component in pg_stat_activity. Web tiers +# report "api"; everything else uses the launch subcommand. +case "$1" in + prod|dev) DJANGO_APP_COMPONENT="api" ;; + *) DJANGO_APP_COMPONENT="$1" ;; +esac +export DJANGO_APP_COMPONENT + case "$1" in dev) apply_migrations diff --git a/api/docs/orphan-task-recovery.md b/api/docs/orphan-task-recovery.md new file mode 100644 index 0000000000..a47b4f36a9 --- /dev/null +++ b/api/docs/orphan-task-recovery.md @@ -0,0 +1,105 @@ +# Orphan Celery task recovery + +When a worker is terminated mid-task (a deploy, an OOM kill, a node eviction), the +task it was running can be left non-terminal forever: the `TaskResult` stays +`STARTED` and nothing re-runs it. This page describes the mechanisms that detect and +recover allowlisted idempotent orphans so pending-task alerts do not fire. Scan tasks +are not auto-recovered (re-running a scan is not safe to do automatically); the +watchdog covers the summary/aggregation and deletion tasks. + +## How recovery works + +1. **Durable delivery.** The broker is configured so a task message is acknowledged + only after the task finishes (`task_acks_late`), one task is reserved at a time + (`worker_prefetch_multiplier = 1`), and an abruptly-lost worker re-queues its task + (`task_reject_on_worker_lost`). On `SIGTERM` the worker is given a soft-shutdown + window (`worker_soft_shutdown_timeout`) to finish or re-queue in-flight work + before it is force-killed. `scan-perform`, `scan-perform-scheduled` and + `integration-jira` opt out of redelivery with `acks_late=False`, so a crash drops + them rather than re-running and duplicating findings or Jira issues. Other + non-recovered side-effect tasks keep `acks_late=True`, so the broker can still + re-deliver them after a worker loss: the S3 upload rebuilds from worker-local files + that did not survive the crash and so no-ops, but Security Hub re-reads findings from + the DB and re-sends them to AWS. + +2. **Periodic watchdog.** A Beat task, `reconcile-orphan-tasks`, runs every couple of + minutes (a `django_celery_beat` periodic task created by migration). For each + in-flight task result with an allowlisted idempotent task name, it pings the + worker recorded on the task's `TaskResult`: + - worker responds -> the task is still running, leave it alone; + - worker is gone (and the task started before a short grace window) -> it is a + real orphan: the stale task is revoked and marked terminal (clearing the + pending/started alert), and the task is re-enqueued from its stored name and + kwargs. + + The re-run is safe because only tasks with proven idempotency are allowlisted: the + summary/aggregation tasks clear and re-write their own rows, and deletions are + idempotent. Scan tasks and external side effects are excluded: re-running a scan is + not safe to do automatically, Jira sends would create duplicate issues, the S3 + upload rebuilds from worker-local files that do not survive a crash, and + report/Security Hub recovery is out of scope. + +3. **Recovery cap.** A per-task Valkey counter limits how often the same task is + re-enqueued. After `--max-attempts` recoveries (default 3) the orphan is marked + terminal instead of re-enqueued, so a task that repeatedly kills its worker cannot + loop forever. + +A Postgres advisory lock ensures that, even with multiple API/worker replicas, only +one reconciliation runs at a time; the others no-op. + +## On-demand command + +The same logic is available as a management command, useful right after a deploy or +for manual intervention: + +```bash +python manage.py reconcile_orphan_tasks # recover now +python manage.py reconcile_orphan_tasks --dry-run # report orphans, change nothing +python manage.py reconcile_orphan_tasks --grace-minutes 5 --max-attempts 3 +``` + +## Configuration + +All settings have safe defaults; override via environment variables. + +| Env var | Default | Purpose | +| --- | --- | --- | +| `DJANGO_CELERY_WORKER_PREFETCH_MULTIPLIER` | `1` | Tasks reserved per worker process. | +| `DJANGO_CELERY_WORKER_SOFT_SHUTDOWN_TIMEOUT` | `60` | Seconds the worker drains/re-queues on `SIGTERM` before force-kill. | +| `DJANGO_CELERY_TASK_TIME_LIMIT` | `21600` (6h) | Hard limit for most tasks; connection checks are capped at 120s. | +| `DJANGO_CELERY_TASK_SOFT_TIME_LIMIT` | hard - 600 | Soft limit; raises `SoftTimeLimitExceeded` for cleanup. | +| `DJANGO_CELERY_LONG_TASK_TIME_LIMIT` | `172800` (48h) | Hard limit for scans and provider/tenant deletions, which can legitimately run for more than a day. | +| `DJANGO_CELERY_LONG_TASK_SOFT_TIME_LIMIT` | long hard - 600 | Soft limit for the long-running tasks above. | +| `DJANGO_TASK_RECOVERY_ENABLED` | `false` | Master switch for orphan-task recovery, disabled by default (opt-in); set to `true` to enable. When off, no orphan is detected, marked terminal, or re-enqueued (attack-paths stale cleanup still runs). | +| `DJANGO_TASK_RECOVERY_SUMMARIES_ENABLED` | `true` | Auto re-enqueue orphaned scan summary/aggregation tasks. | +| `DJANGO_TASK_RECOVERY_DELETIONS_ENABLED` | `true` | Auto re-enqueue orphaned provider/tenant deletion tasks. | + +Recovery is opt-in: with the master flag off (the default) the sweep does nothing. +Once enabled, the per-group flags default to on, so every group recovers unless you +turn one off; a task whose group flag is off is marked terminal instead of +re-enqueued. + +Turning recovery off only disables this watchdog sweep; it does not change Celery's +broker-level redelivery (`task_acks_late`/`task_reject_on_worker_lost`), which still +re-delivers tasks that keep `acks_late=True` on worker loss, independently of this flag. + +`task_acks_late` and `task_reject_on_worker_lost` are enabled in `config/celery.py`. + +## Deployment requirement + +Two conditions must both hold for the soft shutdown to actually drain work: + +1. **The worker must receive `SIGTERM`.** The container entrypoint `exec`s the + Celery process so it runs as PID 1; otherwise `SIGTERM` from `docker stop`/ECS + hits the entrypoint shell, never reaches Celery, and the worker is hard-killed + (SIGKILL) at the grace deadline without draining. Custom entrypoints must + preserve the `exec`. +2. **The orchestrator must give the worker enough time** before force-killing it. + Set the stop grace period to exceed `DJANGO_CELERY_WORKER_SOFT_SHUTDOWN_TIMEOUT` + plus a margin: + - **docker-compose:** `stop_grace_period` on the worker services (set to `120s`). + - **AWS ECS:** the worker container `stopTimeout` (configured in the deployment + repository). + +If either condition is missing, long tasks are still recovered by the watchdog, +but they are cut mid-run on every deploy instead of draining. diff --git a/api/pyproject.toml b/api/pyproject.toml index 5d996878fa..41014d3382 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -41,7 +41,9 @@ dependencies = [ "drf-spectacular==0.27.2", "drf-spectacular-jsonapi==0.5.1", "defusedxml==0.7.1", - "gunicorn==23.0.0", + "django-eventstream==5.3.3", + "gunicorn==26.0.0", + "uvloop==0.22.1", "lxml==6.1.0", "prowler @ git+https://github.com/prowler-cloud/prowler.git@master", "psycopg2-binary==2.9.9", @@ -68,7 +70,7 @@ name = "prowler-api" package-mode = false # Needed for the SDK compatibility requires-python = ">=3.11,<3.13" -version = "1.31.0" +version = "1.32.0" [tool.uv] # Transitive pins matching master to avoid silent drift; bump deliberately. @@ -79,7 +81,7 @@ constraint-dependencies = [ "aiobotocore==2.25.1", "aiofiles==24.1.0", "aiohappyeyeballs==2.6.1", - "aiohttp==3.13.5", + "aiohttp==3.14.0", "aioitertools==0.13.0", "aiosignal==1.4.0", "alibabacloud-actiontrail20200706==2.4.1", @@ -124,9 +126,8 @@ constraint-dependencies = [ "astroid==3.2.4", "async-timeout==5.0.1", "attrs==25.4.0", - "authlib==1.6.9", + "authlib==1.6.12", "autopep8==2.3.2", - "awsipranges==0.3.3", "azure-cli-core==2.83.0", "azure-cli-telemetry==1.1.0", "azure-common==1.1.28", @@ -209,6 +210,7 @@ constraint-dependencies = [ "django-celery-results==2.6.0", "django-cors-headers==4.4.0", "django-environ==0.11.2", + "django-eventstream==5.3.3", "django-filter==24.3", "django-guid==3.5.0", "django-postgres-extra==2.0.9", @@ -226,7 +228,7 @@ constraint-dependencies = [ "drf-simple-apikey==2.2.1", "drf-spectacular==0.27.2", "drf-spectacular-jsonapi==0.5.1", - "dulwich==0.23.0", + "dulwich==1.2.5", "duo-client==5.5.0", "durationpy==0.10", "email-validator==2.2.0", @@ -253,7 +255,7 @@ constraint-dependencies = [ "grpc-google-iam-v1==0.14.3", "grpcio==1.76.0", "grpcio-status==1.76.0", - "gunicorn==23.0.0", + "gunicorn==26.0.0", "h11==0.16.0", "h2==4.3.0", "hpack==4.1.0", @@ -262,8 +264,8 @@ constraint-dependencies = [ "httpx==0.28.1", "humanfriendly==10.0", "hyperframe==6.1.0", - "iamdata==0.1.202602021", - "idna==3.11", + "iamdata==0.1.202605131", + "idna==3.15", "importlib-metadata==8.7.1", "inflection==0.5.1", "iniconfig==2.3.0", @@ -315,7 +317,7 @@ constraint-dependencies = [ "neo4j==6.1.0", "nest-asyncio==1.6.0", "nltk==3.9.4", - "numpy==2.0.2", + "numpy==2.2.6", "oauthlib==3.3.1", "oci==2.169.0", "openai==1.109.1", @@ -344,7 +346,7 @@ constraint-dependencies = [ "psutil==7.2.2", "psycopg2-binary==2.9.9", "py-deviceid==0.1.1", - "py-iam-expand==0.1.0", + "py-iam-expand==0.3.0", "py-ocsf-models==0.8.1", "pyasn1==0.6.3", "pyasn1-modules==0.4.2", @@ -354,7 +356,7 @@ constraint-dependencies = [ "pydantic-core==2.41.5", "pygithub==2.8.0", "pygments==2.20.0", - "pyjwt==2.12.1", + "pyjwt==2.13.0", "pylint==3.2.5", "pymsalruntime==0.18.1", "pynacl==1.6.2", @@ -420,6 +422,7 @@ constraint-dependencies = [ "uritemplate==4.2.0", "urllib3==2.7.0", "uuid6==2024.7.10", + "uvloop==0.22.1", "vine==5.1.0", "vulture==2.14", "wcwidth==0.5.3", @@ -443,7 +446,17 @@ constraint-dependencies = [ # The microsoft-kiota-http security bump to 1.9.9 (GHSA-7j59-v9qr-6fq9) requires # microsoft-kiota-abstractions>=1.9.9, which a constraint cannot satisfy against the # SDK's hard pin; override it to the patched, kiota-aligned version. +# +# 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 +# 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", - "microsoft-kiota-abstractions==1.9.9" + "microsoft-kiota-abstractions==1.9.9", + "dulwich==1.2.5", + "pyjwt[crypto]==2.13.0" ] diff --git a/api/src/backend/api/apps.py b/api/src/backend/api/apps.py index 3209f75888..6c3a4ec2d9 100644 --- a/api/src/backend/api/apps.py +++ b/api/src/backend/api/apps.py @@ -1,12 +1,14 @@ import logging import os import sys + from pathlib import Path +from django.apps import AppConfig +from django.conf import settings + from config.custom_logging import BackendLogger from config.env import env -from django.apps import AppConfig -from django.conf import settings logger = logging.getLogger(BackendLogger.API) @@ -30,7 +32,6 @@ class ApiConfig(AppConfig): def ready(self): from api import schema_extensions # noqa: F401 from api import signals # noqa: F401 - from api.attack_paths import database as graph_database # Generate required cryptographic keys if not present, but only if: # `"manage.py" not in sys.argv[0]`: If an external server (e.g., Gunicorn) is running the app @@ -41,37 +42,8 @@ class ApiConfig(AppConfig): ): self._ensure_crypto_keys() - # Commands that don't need Neo4j - SKIP_NEO4J_DJANGO_COMMANDS = [ - "makemigrations", - "migrate", - "pgpartition", - "check", - "help", - "showmigrations", - "check_and_fix_socialaccount_sites_migration", - ] - - # Skip eager Neo4j init for tests, some Django commands, and Celery (prefork pool: driver must stay lazy, no post_fork hook) - if getattr(settings, "TESTING", False) or ( - len(sys.argv) > 1 - and ( - ( - "manage.py" in sys.argv[0] - and sys.argv[1] in SKIP_NEO4J_DJANGO_COMMANDS - ) - or "celery" in sys.argv[0] - ) - ): - logger.info( - "Skipping eager Neo4j init: tests, some Django commands, or Celery prefork pool (driver stays lazy)" - ) - - else: - graph_database.init_driver() - - # Neo4j driver is initialized at API startup (see api.attack_paths.database) - # It remains lazy for Celery workers and selected Django commands + # Neo4j driver is created lazily on first use (see api.attack_paths.database). + # App init never contacts Neo4j, so a Neo4j outage cannot block API startup. def _ensure_crypto_keys(self): """ diff --git a/api/src/backend/api/attack_paths/database.py b/api/src/backend/api/attack_paths/database.py index f5fddd0613..0e6cc083dc 100644 --- a/api/src/backend/api/attack_paths/database.py +++ b/api/src/backend/api/attack_paths/database.py @@ -1,22 +1,24 @@ import atexit import logging import threading + from contextlib import contextmanager from typing import Any, Iterator from uuid import UUID import neo4j import neo4j.exceptions + from config.env import env from django.conf import settings + +from api.attack_paths.retryable_session import RetryableSession from tasks.jobs.attack_paths.config import ( BATCH_SIZE, PROVIDER_RESOURCE_LABEL, get_provider_label, ) -from api.attack_paths.retryable_session import RetryableSession - # Without this Celery goes crazy with Neo4j logging logging.getLogger("neo4j").setLevel(logging.ERROR) logging.getLogger("neo4j").propagate = False @@ -28,6 +30,9 @@ READ_QUERY_TIMEOUT_SECONDS = env.int( "ATTACK_PATHS_READ_QUERY_TIMEOUT_SECONDS", default=30 ) MAX_CUSTOM_QUERY_NODES = env.int("ATTACK_PATHS_MAX_CUSTOM_QUERY_NODES", default=250) +# Shorter than CONN_ACQUISITION_TIMEOUT — the driver requires acquisition to be +# the longer of the two (it may include opening a new connection). +CONNECTION_TIMEOUT = env.int("NEO4J_CONNECTION_TIMEOUT", default=5) CONN_ACQUISITION_TIMEOUT = env.int("NEO4J_CONN_ACQUISITION_TIMEOUT", default=15) READ_EXCEPTION_CODES = [ "Neo.ClientError.Statement.AccessMode", @@ -58,15 +63,24 @@ def init_driver() -> neo4j.Driver: uri = get_uri() config = settings.DATABASES["neo4j"] - _driver = neo4j.GraphDatabase.driver( + driver = neo4j.GraphDatabase.driver( uri, auth=(config["USER"], config["PASSWORD"]), keep_alive=True, max_connection_lifetime=7200, + connection_timeout=CONNECTION_TIMEOUT, connection_acquisition_timeout=CONN_ACQUISITION_TIMEOUT, max_connection_pool_size=50, ) - _driver.verify_connectivity() + # Publish the singleton only after connectivity is verified so a + # failed probe does not leave an unverified driver behind. Close the + # driver on failure so a repeatedly-probed outage cannot leak pools. + try: + driver.verify_connectivity() + except Exception: + driver.close() + raise + _driver = driver # Register cleanup handler (only runs once since we're inside the _driver is None block) atexit.register(close_driver) @@ -161,7 +175,8 @@ def drop_subgraph(database: str, provider_id: str) -> int: """ Delete all nodes for a provider from the tenant database. - Uses batched deletion to avoid memory issues with large graphs. + Deletes relationships then nodes in batches (not `DETACH DELETE`) so a dense + provider's graph cannot exceed Neo4j's transaction memory limit. Silently returns 0 if the database doesn't exist. """ provider_label = get_provider_label(provider_id) @@ -169,13 +184,28 @@ def drop_subgraph(database: str, provider_id: str) -> int: try: with get_session(database) as session: + # Phase 1: delete relationships incident to provider nodes in batches. + deleted_count = 1 + while deleted_count > 0: + result = session.run( + f""" + MATCH (:`{provider_label}`)-[r]-() + WITH DISTINCT r LIMIT $batch_size + DELETE r + RETURN COUNT(r) AS deleted_rels_count + """, + {"batch_size": BATCH_SIZE}, + ) + deleted_count = result.single().get("deleted_rels_count", 0) + + # Phase 2: delete the now relationship-free nodes in batches. deleted_count = 1 while deleted_count > 0: result = session.run( f""" MATCH (n:{PROVIDER_RESOURCE_LABEL}:`{provider_label}`) WITH n LIMIT $batch_size - DETACH DELETE n + DELETE n RETURN COUNT(n) AS deleted_nodes_count """, {"batch_size": BATCH_SIZE}, diff --git a/api/src/backend/api/authentication.py b/api/src/backend/api/authentication.py index 499e290bb7..04740ac219 100644 --- a/api/src/backend/api/authentication.py +++ b/api/src/backend/api/authentication.py @@ -93,3 +93,30 @@ class CombinedJWTOrAPIKeyAuthentication(BaseAuthentication): # Default fallback return self.jwt_auth.authenticate(request) + + +class SSEAuthentication(CombinedJWTOrAPIKeyAuthentication): + """JWT/API-Key auth that also accepts `?access_token=`. + + Browser `EventSource` is the only widely available SSE client API + and it cannot set the `Authorization` header (its constructor takes + only a URL and `withCredentials`). To keep browser SSE clients on + the same auth stack as the rest of the API, SSE endpoints additionally + accept a JWT via the `?access_token=` query parameter — the + standard parameter name defined in RFC 6750 Section 2.3 for bearer tokens. + """ + + def authenticate(self, request: Request): + auth_header = request.headers.get("Authorization", "") + if auth_header: + return super().authenticate(request) + + raw_token = request.query_params.get("access_token") + if not raw_token: + # No header and no query token — let the default path raise + # the canonical AuthenticationFailed via the parent class. + return super().authenticate(request) + + validated_token = self.jwt_auth.get_validated_token(raw_token) + user = self.jwt_auth.get_user(validated_token) + return user, validated_token diff --git a/api/src/backend/api/compliance.py b/api/src/backend/api/compliance.py index 25b8fb6735..77c45cbffd 100644 --- a/api/src/backend/api/compliance.py +++ b/api/src/backend/api/compliance.py @@ -1,11 +1,26 @@ +import logging +import threading from collections.abc import Iterable, Mapping from api.models import Provider -from prowler.lib.check.compliance_models import Compliance +from prowler.lib.check.compliance_models import ( + get_bulk_compliance_frameworks_universal, +) from prowler.lib.check.models import CheckMetadata +logger = logging.getLogger(__name__) + AVAILABLE_COMPLIANCE_FRAMEWORKS = {} +# Per-process readiness flags for the background compliance warm-up. +# `STARTED` is set as soon as warming begins (only happens under Gunicorn via +# the post_fork hook); `WARMED` is set when it finishes. The attributes +# endpoint checks both: it returns 503 only while warming is in progress. +# Under `runserver` warming never runs, so `STARTED` stays clear and the +# endpoint keeps lazy-loading as before. +COMPLIANCE_WARMING_STARTED = threading.Event() +COMPLIANCE_WARMED = threading.Event() + class LazyComplianceTemplate(Mapping): """Lazy-load compliance templates per provider on first access.""" @@ -94,25 +109,22 @@ PROWLER_CHECKS = LazyChecksMapping() def get_compliance_frameworks(provider_type: Provider.ProviderChoices) -> list[str]: - """List compliance frameworks the API can load for `provider_type`. + """List compliance framework identifiers available for `provider_type`. - The list is sourced from `Compliance.get_bulk` so that the names - returned here are guaranteed to be loadable by the bulk loader. This - prevents downstream key mismatches (e.g. CSV report generation iterating - framework names and looking them up in the bulk dict). + Includes both per-provider frameworks and universal top-level frameworks + (e.g. ``dora_2022_2554``, ``csa_ccm_4.0``). Args: - provider_type (Provider.ProviderChoices): The cloud provider type for which to retrieve - available compliance frameworks (e.g., "aws", "azure", "gcp", "m365"). + provider_type (Provider.ProviderChoices): The cloud provider type + (e.g., "aws", "azure", "gcp", "m365"). Returns: - list[str]: A list of framework identifiers (e.g., "cis_1.4_aws", "mitre_attack_azure") available - for the given provider. + list[str]: Framework identifiers (e.g., "cis_1.4_aws", "dora_2022_2554"). """ global AVAILABLE_COMPLIANCE_FRAMEWORKS if provider_type not in AVAILABLE_COMPLIANCE_FRAMEWORKS: AVAILABLE_COMPLIANCE_FRAMEWORKS[provider_type] = list( - Compliance.get_bulk(provider_type).keys() + get_bulk_compliance_frameworks_universal(provider_type).keys() ) return AVAILABLE_COMPLIANCE_FRAMEWORKS[provider_type] @@ -139,18 +151,14 @@ def get_prowler_provider_compliance(provider_type: Provider.ProviderChoices) -> """ Retrieve the Prowler compliance data for a specified provider type. - This function fetches the compliance frameworks and their associated - requirements for the given cloud provider. - Args: provider_type (Provider.ProviderChoices): The provider type (e.g., 'aws', 'azure') for which to retrieve compliance data. Returns: - dict: A dictionary mapping compliance framework names to their respective - Compliance objects for the specified provider. + dict: Mapping of framework name to `ComplianceFramework` for the provider. """ - return Compliance.get_bulk(provider_type) + return get_bulk_compliance_frameworks_universal(provider_type) def _load_provider_assets(provider_type: Provider.ProviderChoices) -> tuple[dict, dict]: @@ -179,6 +187,56 @@ def _ensure_provider_loaded(provider_type: Provider.ProviderChoices) -> None: PROWLER_CHECKS._cache[provider_type] = checks +def warm_compliance_caches( + provider_types: Iterable[str] | None = None, +) -> list[str]: + """ + Eagerly populate the per-process compliance caches at server startup. + + Moves the cold-cache catalog load off the request thread so the first + request does not trip the Gunicorn worker timeout. Reads only on-disk + metadata (no database access). Each provider is warmed in isolation; + failures are logged and fall back to lazy loading. + + Args: + provider_types (Iterable[str] | None): Subset to warm. Defaults to all. + + Returns: + list[str]: Provider types that could not be warmed. + """ + if provider_types is None: + provider_types = Provider.ProviderChoices.values + provider_types = list(provider_types) + + COMPLIANCE_WARMING_STARTED.set() + logger.info("Compliance cache warm-up started for providers: %s", provider_types) + + failed = [] + for provider_type in provider_types: + try: + get_compliance_frameworks(provider_type) + _ensure_provider_loaded(provider_type) + # Prowler check loading may sys.exit (SystemExit, not Exception). + except (Exception, SystemExit): + logger.warning( + "Failed to warm compliance caches for provider '%s'; " + "loading lazily on first request", + provider_type, + exc_info=True, + ) + failed.append(provider_type) + + # Mark as warmed even when some providers failed: a failed provider falls + # back to a single-provider lazy load, which stays under the worker timeout. + COMPLIANCE_WARMED.set() + logger.info( + "Compliance cache warm-up finished (providers warmed: %d, failed: %s)", + len(provider_types) - len(failed), + failed, + ) + return failed + + def load_prowler_checks( prowler_compliance, provider_types: Iterable[str] | None = None ): @@ -209,8 +267,8 @@ def load_prowler_checks( for compliance_name, compliance_data in prowler_compliance.get( provider_type, {} ).items(): - for requirement in compliance_data.Requirements: - for check in requirement.Checks: + for requirement in compliance_data.requirements: + for check in requirement.checks.get(provider_type, []): try: checks[provider_type][check].add(compliance_name) except KeyError: @@ -290,24 +348,40 @@ def generate_compliance_overview_template( requirements_status = {"passed": 0, "failed": 0, "manual": 0} total_requirements = 0 - for requirement in compliance_data.Requirements: + for requirement in compliance_data.requirements: total_requirements += 1 - total_checks = len(requirement.Checks) - checks_dict = {check: None for check in requirement.Checks} + provider_check_list = list(requirement.checks.get(provider_type, [])) + total_checks = len(provider_check_list) + checks_dict = {check: None for check in provider_check_list} req_status_val = "MANUAL" if total_checks == 0 else "PASS" + # MITRE attrs are wrapped under `_raw_attributes` by the + # universal adapter — unwrap so consumers see the flat list. + requirement_attributes = requirement.attributes + if ( + isinstance(requirement_attributes, dict) + and "_raw_attributes" in requirement_attributes + ): + attributes_payload = list(requirement_attributes["_raw_attributes"]) + elif isinstance(requirement_attributes, dict): + attributes_payload = ( + [dict(requirement_attributes)] if requirement_attributes else [] + ) + else: + attributes_payload = [ + dict(attribute) for attribute in requirement_attributes + ] + # Build requirement dictionary requirement_dict = { - "name": requirement.Name or requirement.Id, - "description": requirement.Description, - "tactics": getattr(requirement, "Tactics", []), - "subtechniques": getattr(requirement, "SubTechniques", []), - "platforms": getattr(requirement, "Platforms", []), - "technique_url": getattr(requirement, "TechniqueURL", ""), - "attributes": [ - dict(attribute) for attribute in requirement.Attributes - ], + "name": requirement.name or requirement.id, + "description": requirement.description, + "tactics": requirement.tactics or [], + "subtechniques": requirement.sub_techniques or [], + "platforms": requirement.platforms or [], + "technique_url": requirement.technique_url or "", + "attributes": attributes_payload, "checks": checks_dict, "checks_status": { "pass": 0, @@ -325,15 +399,15 @@ def generate_compliance_overview_template( requirements_status["passed"] += 1 # Add requirement to compliance requirements - compliance_requirements[requirement.Id] = requirement_dict + compliance_requirements[requirement.id] = requirement_dict # Build compliance dictionary compliance_dict = { - "framework": compliance_data.Framework, - "name": compliance_data.Name, - "version": compliance_data.Version, + "framework": compliance_data.framework, + "name": compliance_data.name, + "version": compliance_data.version, "provider": provider_type, - "description": compliance_data.Description, + "description": compliance_data.description, "requirements": compliance_requirements, "requirements_status": requirements_status, "total_requirements": total_requirements, diff --git a/api/src/backend/api/exceptions.py b/api/src/backend/api/exceptions.py index 78f8c64c7d..4f6f26c2ea 100644 --- a/api/src/backend/api/exceptions.py +++ b/api/src/backend/api/exceptions.py @@ -187,6 +187,32 @@ class UpstreamServiceUnavailableError(APIException): ) +class ComplianceWarmingError(APIException): + """Compliance catalog is still warming (503 Service Unavailable). + + Returned by the compliance attributes endpoint while the per-process + catalog warm-up is in progress, so the request thread never triggers the + slow cold load that would trip the Gunicorn worker timeout. + """ + + status_code = status.HTTP_503_SERVICE_UNAVAILABLE + default_detail = ( + "Compliance data is still loading. Please try again in a few seconds." + ) + default_code = "compliance_warming" + + def __init__(self, detail=None): + super().__init__( + detail=[ + { + "detail": detail or self.default_detail, + "status": str(self.status_code), + "code": self.default_code, + } + ] + ) + + class UpstreamInternalError(APIException): """Unexpected error communicating with provider (500 Internal Server Error). diff --git a/api/src/backend/api/filters.py b/api/src/backend/api/filters.py index c787d714b1..195982011d 100644 --- a/api/src/backend/api/filters.py +++ b/api/src/backend/api/filters.py @@ -102,7 +102,7 @@ class BaseProviderFilter(FilterSet): """ Abstract base filter for models with direct FK to Provider. - Provides standard provider_id and provider_type filters. + Provides standard provider_id, provider_type, and provider_groups filters. Subclasses must define Meta.model. """ @@ -116,6 +116,16 @@ class BaseProviderFilter(FilterSet): choices=Provider.ProviderChoices.choices, lookup_expr="in", ) + provider_groups = UUIDFilter( + field_name="provider__provider_groups__id", + lookup_expr="exact", + distinct=True, + ) + provider_groups__in = UUIDInFilter( + field_name="provider__provider_groups__id", + lookup_expr="in", + distinct=True, + ) class Meta: abstract = True @@ -126,7 +136,7 @@ class BaseScanProviderFilter(FilterSet): """ Abstract base filter for models with FK to Scan (and Scan has FK to Provider). - Provides standard provider_id and provider_type filters via scan relationship. + Provides standard provider_id, provider_type, and provider_groups filters via scan relationship. Subclasses must define Meta.model. """ @@ -140,6 +150,16 @@ class BaseScanProviderFilter(FilterSet): choices=Provider.ProviderChoices.choices, lookup_expr="in", ) + provider_groups = UUIDFilter( + field_name="scan__provider__provider_groups__id", + lookup_expr="exact", + distinct=True, + ) + provider_groups__in = UUIDInFilter( + field_name="scan__provider__provider_groups__id", + lookup_expr="in", + distinct=True, + ) class Meta: abstract = True @@ -160,6 +180,16 @@ class CommonFindingFilters(FilterSet): provider_type__in = ChoiceInFilter( choices=Provider.ProviderChoices.choices, field_name="scan__provider__provider" ) + provider_groups = UUIDFilter( + field_name="scan__provider__provider_groups__id", + lookup_expr="exact", + distinct=True, + ) + provider_groups__in = UUIDInFilter( + field_name="scan__provider__provider_groups__id", + lookup_expr="in", + distinct=True, + ) provider_uid = CharFilter(field_name="scan__provider__uid", lookup_expr="exact") provider_uid__in = CharInFilter(field_name="scan__provider__uid", lookup_expr="in") provider_uid__icontains = CharFilter( @@ -370,6 +400,12 @@ class ProviderFilter(FilterSet): choices=Provider.ProviderChoices.choices, lookup_expr="in", ) + provider_groups = UUIDFilter( + field_name="provider_groups__id", lookup_expr="exact", distinct=True + ) + provider_groups__in = UUIDInFilter( + field_name="provider_groups__id", lookup_expr="in", distinct=True + ) class Meta: model = Provider @@ -395,6 +431,16 @@ class ProviderRelationshipFilterSet(FilterSet): provider_type__in = ChoiceInFilter( choices=Provider.ProviderChoices.choices, field_name="provider__provider" ) + provider_groups = UUIDFilter( + field_name="provider__provider_groups__id", + lookup_expr="exact", + distinct=True, + ) + provider_groups__in = UUIDInFilter( + field_name="provider__provider_groups__id", + lookup_expr="in", + distinct=True, + ) provider_uid = CharFilter(field_name="provider__uid", lookup_expr="exact") provider_uid__in = CharInFilter(field_name="provider__uid", lookup_expr="in") provider_uid__icontains = CharFilter( @@ -1001,6 +1047,16 @@ class FindingGroupSummaryFilter(_CheckTitleToCheckIdMixin, FilterSet): field_name="provider__provider", choices=Provider.ProviderChoices.choices ) provider_type__in = CharInFilter(field_name="provider__provider", lookup_expr="in") + provider_groups = UUIDFilter( + field_name="provider__provider_groups__id", + lookup_expr="exact", + distinct=True, + ) + provider_groups__in = UUIDInFilter( + field_name="provider__provider_groups__id", + lookup_expr="in", + distinct=True, + ) class Meta: model = FindingGroupDailySummary @@ -1101,6 +1157,16 @@ class LatestFindingGroupSummaryFilter(_CheckTitleToCheckIdMixin, FilterSet): field_name="provider__provider", choices=Provider.ProviderChoices.choices ) provider_type__in = CharInFilter(field_name="provider__provider", lookup_expr="in") + provider_groups = UUIDFilter( + field_name="provider__provider_groups__id", + lookup_expr="exact", + distinct=True, + ) + provider_groups__in = UUIDInFilter( + field_name="provider__provider_groups__id", + lookup_expr="in", + distinct=True, + ) class Meta: model = FindingGroupDailySummary @@ -1280,12 +1346,19 @@ class RoleFilter(FilterSet): } -class ComplianceOverviewFilter(FilterSet): +class ComplianceOverviewFilter(BaseScanProviderFilter): + """ + Keep provider filters in the schema while runtime filtering resolves scans first. + + Compliance overview provider filters are applied to the latest completed scans + in the viewset, then this filterset handles the remaining compliance fields. + """ + inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date") - scan_id = UUIDFilter(field_name="scan_id", required=True) + scan_id = UUIDFilter(field_name="scan_id") region = CharFilter(field_name="region") - class Meta: + class Meta(BaseScanProviderFilter.Meta): model = ComplianceRequirementOverview fields = { "inserted_at": ["date", "gte", "lte"], @@ -1306,6 +1379,16 @@ class ScanSummaryFilter(FilterSet): provider_type__in = ChoiceInFilter( field_name="scan__provider__provider", choices=Provider.ProviderChoices.choices ) + provider_groups = UUIDFilter( + field_name="scan__provider__provider_groups__id", + lookup_expr="exact", + distinct=True, + ) + provider_groups__in = UUIDInFilter( + field_name="scan__provider__provider_groups__id", + lookup_expr="in", + distinct=True, + ) region = CharFilter(field_name="region") class Meta: @@ -1329,6 +1412,16 @@ class DailySeveritySummaryFilter(FilterSet): provider_type__in = ChoiceInFilter( field_name="provider__provider", choices=Provider.ProviderChoices.choices ) + provider_groups = UUIDFilter( + field_name="provider__provider_groups__id", + lookup_expr="exact", + distinct=True, + ) + provider_groups__in = UUIDInFilter( + field_name="provider__provider_groups__id", + lookup_expr="in", + distinct=True, + ) date_from = DateFilter(method="filter_noop") date_to = DateFilter(method="filter_noop") @@ -1585,6 +1678,16 @@ class ThreatScoreSnapshotFilter(FilterSet): choices=Provider.ProviderChoices.choices, lookup_expr="in", ) + provider_groups = UUIDFilter( + field_name="provider__provider_groups__id", + lookup_expr="exact", + distinct=True, + ) + provider_groups__in = UUIDInFilter( + field_name="provider__provider_groups__id", + lookup_expr="in", + distinct=True, + ) compliance_id = CharFilter(field_name="compliance_id", lookup_expr="exact") compliance_id__in = CharInFilter(field_name="compliance_id", lookup_expr="in") @@ -1628,6 +1731,16 @@ class ResourceGroupOverviewFilter(FilterSet): choices=Provider.ProviderChoices.choices, lookup_expr="in", ) + provider_groups = UUIDFilter( + field_name="scan__provider__provider_groups__id", + lookup_expr="exact", + distinct=True, + ) + provider_groups__in = UUIDInFilter( + field_name="scan__provider__provider_groups__id", + lookup_expr="in", + distinct=True, + ) resource_group = CharFilter(field_name="resource_group", lookup_expr="exact") resource_group__in = CharInFilter(field_name="resource_group", lookup_expr="in") diff --git a/api/src/backend/api/management/commands/reconcile_orphan_tasks.py b/api/src/backend/api/management/commands/reconcile_orphan_tasks.py new file mode 100644 index 0000000000..8ba8f5b342 --- /dev/null +++ b/api/src/backend/api/management/commands/reconcile_orphan_tasks.py @@ -0,0 +1,59 @@ +from django.core.management.base import BaseCommand + +from tasks.jobs.orphan_recovery import reconcile_orphans + + +class Command(BaseCommand): + help = ( + "Recover orphaned allowlisted Celery tasks whose worker is gone and mark " + "other stale task results terminal. Single-flight via a Postgres advisory lock." + ) + + def add_arguments(self, parser): + parser.add_argument( + "--grace-minutes", + type=int, + default=2, + help="Skip tasks started within this window (worker may still register).", + ) + parser.add_argument( + "--max-attempts", + type=int, + default=3, + help="Give up re-running a task after this many recovery attempts; it is then left terminal instead of re-enqueued.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Detect and report orphans without revoking or re-enqueuing.", + ) + + def handle(self, *args, **options): + result = reconcile_orphans( + grace_minutes=options["grace_minutes"], + max_attempts=options["max_attempts"], + dry_run=options["dry_run"], + ) + + if not result.get("acquired"): + self.stdout.write("Reconcile skipped: another run holds the lock.") + return + + if result.get("enabled") is False: + message = ( + "Task recovery is disabled (DJANGO_TASK_RECOVERY_ENABLED is off); " + "no orphans were recovered." + ) + if result.get("attack_paths") is not None: + message += " Attack-paths stale cleanup still ran." + self.stdout.write(message) + return + + self.stdout.write( + self.style.SUCCESS( + "Orphan reconcile complete: " + f"recovered={len(result.get('recovered', []))} " + f"failed={len(result.get('failed', []))} " + f"skipped(in-flight)={len(result.get('skipped', []))}" + ) + ) diff --git a/api/src/backend/api/middleware.py b/api/src/backend/api/middleware.py index 63f2fc630b..2b0a2340c4 100644 --- a/api/src/backend/api/middleware.py +++ b/api/src/backend/api/middleware.py @@ -1,9 +1,35 @@ import logging import time +from django.core.handlers.asgi import ASGIRequest +from django.db import connections + from config.custom_logging import BackendLogger +class CloseDBConnectionsMiddleware: + """ + Close request-scoped DB connections at the end of each ASGI request. + + Under the ASGI worker, connections opened by sync views are not released + by Django's normal request-boundary cleanup, so they accumulate idle until + Postgres runs out of slots. Only ASGI requests are handled; the sync WSGI + test client manages its own connections and must be left alone. + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + try: + return self.get_response(request) + finally: + if isinstance(request, ASGIRequest): + for conn in connections.all(initialized_only=True): + if not conn.in_atomic_block: + conn.close_if_unusable_or_obsolete() + + def extract_auth_info(request) -> dict: if getattr(request, "auth", None) is not None: tenant_id = request.auth.get("tenant_id", "N/A") diff --git a/api/src/backend/api/migrations/0095_reconcile_orphan_tasks_periodic_task.py b/api/src/backend/api/migrations/0095_reconcile_orphan_tasks_periodic_task.py new file mode 100644 index 0000000000..9d67404258 --- /dev/null +++ b/api/src/backend/api/migrations/0095_reconcile_orphan_tasks_periodic_task.py @@ -0,0 +1,49 @@ +from django.db import migrations + + +TASK_NAME = "reconcile-orphan-tasks" +INTERVAL_MINUTES = 2 + + +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_MINUTES, + period="minutes", + ) + + 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_MINUTES, + period="minutes", + periodictask__isnull=True, + ).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0093_okta_provider"), + ("django_celery_beat", "0019_alter_periodictasks_options"), + ] + + operations = [ + migrations.RunPython(create_periodic_task, delete_periodic_task), + ] diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index 5efefc0790..18566c5c71 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.31.0 + version: 1.32.0 description: |- Prowler API specification. @@ -13137,8 +13137,59 @@ paths: responses: '200': description: CSV file containing the compliance report + '202': + description: The task is in progress + '403': + description: There is a problem with credentials '404': - description: Compliance report not found + description: Compliance report not found, or the scan has no reports yet + /api/v1/scans/{id}/compliance/{name}/ocsf: + get: + operationId: scans_compliance_ocsf_retrieve + description: Download a specific compliance report as an OCSF JSON file. Only + universal frameworks that declare an output configuration produce this artifact + (currently 'dora' and 'csa_ccm_4.0'); any other framework returns 404. + summary: Retrieve compliance report as OCSF JSON + parameters: + - in: query + name: fields[scan-reports] + schema: + type: array + items: + type: string + enum: + - id + - name + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this scan. + required: true + - in: path + name: name + schema: + type: string + description: The compliance report name, like 'dora' + required: true + tags: + - Scan + security: + - JWT or API Key: [] + responses: + '200': + description: OCSF JSON file containing the compliance report + '202': + description: The task is in progress + '403': + description: There is a problem with credentials + '404': + description: Compliance report not found, the framework does not provide + an OCSF export, or the scan has no reports yet /api/v1/scans/{id}/csa: get: operationId: scans_csa_retrieve diff --git a/api/src/backend/api/sse/__init__.py b/api/src/backend/api/sse/__init__.py new file mode 100644 index 0000000000..244bd3c9ad --- /dev/null +++ b/api/src/backend/api/sse/__init__.py @@ -0,0 +1,13 @@ +"""Platform Server-Sent Events (SSE) infrastructure. + +Wires `django-eventstream` into the API: a base viewset features +subclass to expose an SSE endpoint +(:class:`api.sse.base_views.BaseSSEViewSet`), the channel manager that +enforces the tenant gate (:class:`api.sse.channelmanager.SSEChannelManager`), +and the channel-name helpers (:func:`api.sse.utils.make_channel_name`). +""" + +from api.sse.utils import make_channel_name +from api.sse.base_views import BaseSSEViewSet + +__all__ = ["BaseSSEViewSet", "make_channel_name"] diff --git a/api/src/backend/api/sse/base_views.py b/api/src/backend/api/sse/base_views.py new file mode 100644 index 0000000000..c4a24540ee --- /dev/null +++ b/api/src/backend/api/sse/base_views.py @@ -0,0 +1,46 @@ +"""Base view class for SSE endpoints.""" + +from api.authentication import SSEAuthentication +from api.base_views import BaseRLSViewSet +from django_eventstream.renderers import SSEEventRenderer +from django_eventstream.views import events + + +class BaseSSEViewSet(BaseRLSViewSet): + """Base class for platform SSE endpoints. + + Subclasses override method `get_channels` to declare the channel + names the connection should subscribe to — the same way a regular + DRF viewset overrides method `get_queryset`. The channel manager + reads the result from `request.sse_channels`; there is no other + coupling between platform and feature. + """ + + authentication_classes = [SSEAuthentication] + # Pin the SSE renderer so content negotiation accepts the browser's + # `Accept: text/event-stream`. + renderer_classes = [SSEEventRenderer] + + def get_channels(self) -> set[str]: + """Return the channels this connection subscribes to. + + Implementations MUST raise the relevant DRF exceptions + (`NotAuthenticated`, `PermissionDenied`, `NotFound`) when + authorization fails. Returning an empty set would surface as + django-eventstream's "No channels specified" which masks the + real cause. + """ + raise NotImplementedError + + def get_queryset(self): + # Most SSE viewsets only need `get_channels` and never call + # `get_queryset` (the SSE list path bypasses serialization + # entirely). Subclasses that perform their own queryset lookup + # inside `get_channels` should override; the default raises + # the same error a missing override on a ModelViewSet would. + raise NotImplementedError + + def list(self, request, *_args, **kwargs): + """Resolve channels under the regular DRF stack and stream.""" + request.sse_channels = self.get_channels() + return events(request, **kwargs) diff --git a/api/src/backend/api/sse/channelmanager.py b/api/src/backend/api/sse/channelmanager.py new file mode 100644 index 0000000000..72b2c18367 --- /dev/null +++ b/api/src/backend/api/sse/channelmanager.py @@ -0,0 +1,75 @@ +"""Channel manager that wires `django-eventstream` to platform SSE views.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID + +from django_eventstream.channelmanager import DefaultChannelManager +from rest_framework.request import Request + +from api.sse.utils import tenant_id_from_channel + +if TYPE_CHECKING: + from api.models import User + + +class SSEChannelManager(DefaultChannelManager): + """Connect `django-eventstream` to the platform's SSE viewsets.""" + + def get_channels_for_request(self, request: Request, view_kwargs: dict) -> set[str]: # noqa: vulture + """Return the request's channels scoped to the active JWT tenant. + + Args: + request: The authenticated DRF request, carrying `tenant_id` + (set by `BaseRLSViewSet`) and `sse_channels` (set by + `BaseSSEViewSet.list`). + view_kwargs: URL keyword arguments from django-eventstream; + unused because channels are resolved on the request. + + Returns: + The subset of `request.sse_channels` whose embedded tenant + matches the active request tenant. + """ + try: + request_tenant_id = UUID(str(getattr(request, "tenant_id", None))) + except (TypeError, ValueError): + return set() + return { + channel + for channel in getattr(request, "sse_channels", set()) + if tenant_id_from_channel(channel) == request_tenant_id + } + + def can_read_channel(self, user: "User | None", channel: str) -> bool: + """Re-verify tenant membership once the stream is established. + + Args: + user: The connection's authenticated `User`, or `None` for an + anonymous connection — django-eventstream passes `None` + rather than an `AnonymousUser`. + channel: The channel name being read, in the canonical + `::` format. + + Returns: + `True` only when `user` is authenticated and a member of the + tenant embedded in `channel`; `False` otherwise, including for + anonymous connections and malformed channel names. + """ + if user is None or not user.is_authenticated: + return False + tenant_id = tenant_id_from_channel(channel) + if tenant_id is None: + return False + return user.is_member_of_tenant(tenant_id) + + def is_channel_reliable(self, channel: str) -> bool: + """Report whether the channel keeps a server-side replay buffer. + + Args: + channel: The channel name being queried. + + Returns: + `False`, unconditionally. Replay storage is not configured + """ + return False diff --git a/api/src/backend/api/sse/utils.py b/api/src/backend/api/sse/utils.py new file mode 100644 index 0000000000..a30ed26311 --- /dev/null +++ b/api/src/backend/api/sse/utils.py @@ -0,0 +1,51 @@ +"""Channel-name convention shared by SSE publishers, consumers, and the +channel manager. The format is `::`. +""" + +from __future__ import annotations + +import uuid + +CHANNEL_SEPARATOR = ":" + + +def make_channel_name( + prefix: str, + tenant_id: str | uuid.UUID, + resource_id: str | uuid.UUID, +) -> str: + """Build the canonical channel name for a resource. + + Args: + prefix: Feature-owned prefix (e.g. `"lighthouse-session"`). + tenant_id: Tenant the resource belongs to. + resource_id: Resource identifier within the tenant. + + Raises: + ValueError: If any segment contains `CHANNEL_SEPARATOR`, which + would break the `::` contract + and let a crafted name smuggle extra segments past the parser. + """ + segments = (str(prefix), str(tenant_id), str(resource_id)) + if any(CHANNEL_SEPARATOR in segment for segment in segments): + raise ValueError( + f"Channel segments must not contain '{CHANNEL_SEPARATOR}': {segments!r}" + ) + return CHANNEL_SEPARATOR.join(segments) + + +def tenant_id_from_channel(channel: str) -> uuid.UUID | None: + """Return the tenant UUID embedded in *channel*, or `None` if + *channel* does not follow the platform convention. + + A `None` result MUST be treated by callers as "not authorized" or + a malformed channel cannot be safely read. + """ + segments = channel.split(CHANNEL_SEPARATOR) + if len(segments) != 3: + # Reject non-canonical names + return None + try: + return uuid.UUID(segments[1]) + except ValueError: + return None diff --git a/api/src/backend/api/tests/test_apps.py b/api/src/backend/api/tests/test_apps.py index 5889b4e2cb..2f5b55a6e2 100644 --- a/api/src/backend/api/tests/test_apps.py +++ b/api/src/backend/api/tests/test_apps.py @@ -182,23 +182,19 @@ def _make_app(): return ApiConfig("api", api) -def test_ready_initializes_driver_for_api_process(monkeypatch): +@pytest.mark.parametrize( + "argv", + [ + ["gunicorn"], + ["celery", "-A", "api"], + ["manage.py", "migrate"], + ], + ids=["api", "celery", "manage_py"], +) +def test_ready_never_eagerly_initializes_neo4j_driver(monkeypatch, argv): + """ready() must never contact Neo4j; the driver is created lazily on first use.""" config = _make_app() - _set_argv(monkeypatch, ["gunicorn"]) - _set_testing(monkeypatch, False) - - with ( - patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), - patch("api.attack_paths.database.init_driver") as init_driver, - ): - config.ready() - - init_driver.assert_called_once() - - -def test_ready_skips_driver_for_celery(monkeypatch): - config = _make_app() - _set_argv(monkeypatch, ["celery", "-A", "api"]) + _set_argv(monkeypatch, argv) _set_testing(monkeypatch, False) with ( @@ -208,31 +204,3 @@ def test_ready_skips_driver_for_celery(monkeypatch): config.ready() init_driver.assert_not_called() - - -def test_ready_skips_driver_for_manage_py_skip_command(monkeypatch): - config = _make_app() - _set_argv(monkeypatch, ["manage.py", "migrate"]) - _set_testing(monkeypatch, False) - - with ( - patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), - patch("api.attack_paths.database.init_driver") as init_driver, - ): - config.ready() - - init_driver.assert_not_called() - - -def test_ready_skips_driver_when_testing(monkeypatch): - config = _make_app() - _set_argv(monkeypatch, ["gunicorn"]) - _set_testing(monkeypatch, True) - - with ( - patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), - patch("api.attack_paths.database.init_driver") as init_driver, - ): - config.ready() - - init_driver.assert_not_called() 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 8828d23911..7ca8a4accb 100644 --- a/api/src/backend/api/tests/test_attack_paths_database.py +++ b/api/src/backend/api/tests/test_attack_paths_database.py @@ -1,15 +1,16 @@ """ Tests for Neo4j database lazy initialization. -The Neo4j driver connects on first use by default. API processes may -eagerly initialize the driver during app startup, while Celery workers -remain lazy. These tests validate the database module behavior itself. +The Neo4j driver is created on first use for every process type; app startup +never contacts Neo4j. These tests validate the database module behavior itself. """ import threading + from unittest.mock import MagicMock, patch import neo4j +import neo4j.exceptions import pytest import api.attack_paths.database as db_module @@ -59,6 +60,32 @@ class TestLazyInitialization: assert result is mock_driver assert db_module._driver is mock_driver + @patch("api.attack_paths.database.settings") + @patch("api.attack_paths.database.neo4j.GraphDatabase.driver") + def test_init_driver_leaves_driver_none_when_verify_fails( + self, mock_driver_factory, mock_settings + ): + """A failed verify_connectivity() must not publish or leak the driver.""" + mock_driver = MagicMock() + mock_driver.verify_connectivity.side_effect = ( + neo4j.exceptions.ServiceUnavailable("down") + ) + mock_driver_factory.return_value = mock_driver + mock_settings.DATABASES = { + "neo4j": { + "HOST": "localhost", + "PORT": 7687, + "USER": "neo4j", + "PASSWORD": "password", + } + } + + with pytest.raises(neo4j.exceptions.ServiceUnavailable): + db_module.init_driver() + + assert db_module._driver is None + mock_driver.close.assert_called_once() + @patch("api.attack_paths.database.settings") @patch("api.attack_paths.database.neo4j.GraphDatabase.driver") def test_init_driver_returns_cached_driver_on_subsequent_calls( @@ -116,21 +143,23 @@ class TestConnectionAcquisitionTimeout: @pytest.fixture(autouse=True) def reset_module_state(self): original_driver = db_module._driver - original_timeout = db_module.CONN_ACQUISITION_TIMEOUT + original_acq_timeout = db_module.CONN_ACQUISITION_TIMEOUT + original_conn_timeout = db_module.CONNECTION_TIMEOUT db_module._driver = None yield db_module._driver = original_driver - db_module.CONN_ACQUISITION_TIMEOUT = original_timeout + db_module.CONN_ACQUISITION_TIMEOUT = original_acq_timeout + db_module.CONNECTION_TIMEOUT = original_conn_timeout @patch("api.attack_paths.database.settings") @patch("api.attack_paths.database.neo4j.GraphDatabase.driver") def test_driver_receives_configured_timeout( self, mock_driver_factory, mock_settings ): - """init_driver() should pass CONN_ACQUISITION_TIMEOUT to the neo4j driver.""" + """init_driver() should pass the configured timeouts to the neo4j driver.""" mock_driver_factory.return_value = MagicMock() mock_settings.DATABASES = { "neo4j": { @@ -141,11 +170,13 @@ class TestConnectionAcquisitionTimeout: } } db_module.CONN_ACQUISITION_TIMEOUT = 42 + db_module.CONNECTION_TIMEOUT = 7 db_module.init_driver() _, kwargs = mock_driver_factory.call_args assert kwargs["connection_acquisition_timeout"] == 42 + assert kwargs["connection_timeout"] == 7 class TestAtexitRegistration: @@ -511,3 +542,84 @@ class TestHasProviderData: ): with pytest.raises(db_module.GraphDatabaseQueryException): db_module.has_provider_data("db-tenant-abc", "provider-123") + + +class TestDropSubgraph: + """Test drop_subgraph two-phase batched deletion of a provider's graph.""" + + @staticmethod + def _result(count): + result = MagicMock() + result.single.return_value.get.return_value = count + return result + + @staticmethod + def _session_ctx(session): + ctx = MagicMock() + ctx.__enter__.return_value = session + ctx.__exit__.return_value = False + return ctx + + def test_deletes_relationships_then_nodes_in_batches(self): + session = MagicMock() + # Phase 1 (relationships): one full batch then empty. + # Phase 2 (nodes): one full batch then empty. + session.run.side_effect = [ + self._result(1000), + self._result(0), + self._result(1000), + self._result(0), + ] + + with patch( + "api.attack_paths.database.get_session", + return_value=self._session_ctx(session), + ): + deleted = db_module.drop_subgraph("db-tenant-abc", "provider-123") + + # Only phase-2 node counts contribute to the return value. + assert deleted == 1000 + assert session.run.call_count == 4 + + queries = [call.args[0] for call in session.run.call_args_list] + + # Regression guard: the memory blow-up was caused by DETACH DELETE. + assert all("DETACH DELETE" not in query for query in queries) + + rel_queries = [query for query in queries if "DELETE r" in query] + node_queries = [query for query in queries if "DELETE n" in query] + assert rel_queries and node_queries + # DISTINCT avoids double-counting relationships matched from both ends. + assert all("DISTINCT r" in query for query in rel_queries) + + # Relationships must be fully drained before nodes are deleted. + first_node = next(i for i, q in enumerate(queries) if "DELETE n" in q) + last_rel = max(i for i, q in enumerate(queries) if "DELETE r" in q) + assert last_rel < first_node + + def test_returns_zero_when_database_not_found(self): + session_ctx = MagicMock() + session_ctx.__enter__.side_effect = db_module.GraphDatabaseQueryException( + message="Database does not exist", + code="Neo.ClientError.Database.DatabaseNotFound", + ) + + with patch( + "api.attack_paths.database.get_session", + return_value=session_ctx, + ): + assert db_module.drop_subgraph("db-tenant-gone", "provider-123") == 0 + + def test_raises_on_other_errors(self): + session_ctx = MagicMock() + session_ctx.__enter__.side_effect = db_module.GraphDatabaseQueryException( + message="Connection refused", + code="Neo.TransientError.General.UnknownError", + ) + + with patch( + "api.attack_paths.database.get_session", + return_value=session_ctx, + ): + with pytest.raises(db_module.GraphDatabaseQueryException): + db_module.drop_subgraph("db-tenant-abc", "provider-123") diff --git a/api/src/backend/api/tests/test_authentication.py b/api/src/backend/api/tests/test_authentication.py index 6745c36e91..505d7c9320 100644 --- a/api/src/backend/api/tests/test_authentication.py +++ b/api/src/backend/api/tests/test_authentication.py @@ -1,13 +1,13 @@ import time from datetime import datetime, timedelta, timezone -from unittest.mock import patch +from unittest.mock import MagicMock, patch from uuid import uuid4 import pytest from django.test import RequestFactory from rest_framework.exceptions import AuthenticationFailed -from api.authentication import TenantAPIKeyAuthentication +from api.authentication import SSEAuthentication, TenantAPIKeyAuthentication from api.db_router import MainRouter from api.models import TenantAPIKey @@ -382,3 +382,62 @@ class TestTenantAPIKeyAuthentication: auth_backend.authenticate(request) assert str(exc_info.value.detail) == "API Key has already expired." + + +class TestSSEAuthentication: + """`SSEAuthentication` adds an `?access_token=` fallback for + browser `EventSource` clients while keeping the standard + `Authorization` header as the authoritative source.""" + + def test_header_present_delegates_to_super(self): + request = MagicMock() + request.headers = {"Authorization": "Bearer header-token"} + with patch.object( + SSEAuthentication.__bases__[0], "authenticate", return_value=("user", "tok") + ) as super_auth: + result = SSEAuthentication().authenticate(request) + super_auth.assert_called_once_with(request) + assert result == ("user", "tok") + + def test_no_header_no_query_token_delegates_to_super(self): + request = MagicMock() + request.headers = {} + request.query_params = {} + with patch.object( + SSEAuthentication.__bases__[0], "authenticate", return_value=None + ) as super_auth: + result = SSEAuthentication().authenticate(request) + super_auth.assert_called_once_with(request) + assert result is None + + def test_query_token_used_only_as_fallback(self): + request = MagicMock() + request.headers = {} + request.query_params = {"access_token": "query-jwt"} + + jwt_instance = MagicMock() + jwt_instance.get_validated_token.return_value = "validated" + jwt_instance.get_user.return_value = "query-user" + + with patch.object(SSEAuthentication, "jwt_auth", jwt_instance): + user, token = SSEAuthentication().authenticate(request) + + jwt_instance.get_validated_token.assert_called_once_with("query-jwt") + assert user == "query-user" + assert token == "validated" + + def test_query_token_invalid_raises_authentication_failed(self): + request = MagicMock() + request.headers = {} + request.query_params = {"access_token": "bad-token"} + + jwt_instance = MagicMock() + jwt_instance.get_validated_token.side_effect = AuthenticationFailed( + "Invalid token" + ) + + with patch.object(SSEAuthentication, "jwt_auth", jwt_instance): + with pytest.raises(AuthenticationFailed): + SSEAuthentication().authenticate(request) + + jwt_instance.get_validated_token.assert_called_once_with("bad-token") diff --git a/api/src/backend/api/tests/test_compliance.py b/api/src/backend/api/tests/test_compliance.py index ce30a3cc52..99a31ea12c 100644 --- a/api/src/backend/api/tests/test_compliance.py +++ b/api/src/backend/api/tests/test_compliance.py @@ -10,9 +10,12 @@ from api.compliance import ( get_prowler_provider_checks, get_prowler_provider_compliance, load_prowler_checks, + warm_compliance_caches, ) from api.models import Provider -from prowler.lib.check.compliance_models import Compliance +from prowler.lib.check.compliance_models import ( + get_bulk_compliance_frameworks_universal, +) class TestCompliance: @@ -28,16 +31,16 @@ class TestCompliance: assert set(checks) == {"check1", "check2", "check3"} mock_check_metadata.get_bulk.assert_called_once_with(provider_type) - @patch("api.compliance.Compliance") - def test_get_prowler_provider_compliance(self, mock_compliance): + @patch("api.compliance.get_bulk_compliance_frameworks_universal") + def test_get_prowler_provider_compliance(self, mock_get_bulk): provider_type = Provider.ProviderChoices.AWS - mock_compliance.get_bulk.return_value = { + mock_get_bulk.return_value = { "compliance1": MagicMock(), "compliance2": MagicMock(), } compliance_data = get_prowler_provider_compliance(provider_type) - assert compliance_data == mock_compliance.get_bulk.return_value - mock_compliance.get_bulk.assert_called_once_with(provider_type) + assert compliance_data == mock_get_bulk.return_value + mock_get_bulk.assert_called_once_with(provider_type) @patch("api.compliance.get_prowler_provider_checks") @patch("api.models.Provider.ProviderChoices") @@ -51,9 +54,9 @@ class TestCompliance: prowler_compliance = { "aws": { "compliance1": MagicMock( - Requirements=[ + requirements=[ MagicMock( - Checks=["check1", "check2"], + checks={"aws": ["check1", "check2"]}, ), ], ), @@ -167,35 +170,38 @@ class TestCompliance: def test_generate_compliance_overview_template(self, mock_provider_choices): mock_provider_choices.values = ["aws"] + # ``name`` is a reserved MagicMock kwarg (it labels the mock for repr, + # it does NOT set a ``.name`` attribute), so it must be assigned + # explicitly after construction. requirement1 = MagicMock( - Id="requirement1", - Name="Requirement 1", - Description="Description of requirement 1", - Attributes=[], - Checks=["check1", "check2"], - Tactics=["tactic1"], - SubTechniques=["subtechnique1"], - Platforms=["platform1"], - TechniqueURL="https://example.com", + id="requirement1", + description="Description of requirement 1", + attributes=[], + checks={"aws": ["check1", "check2"]}, + tactics=["tactic1"], + sub_techniques=["subtechnique1"], + platforms=["platform1"], + technique_url="https://example.com", ) + requirement1.name = "Requirement 1" requirement2 = MagicMock( - Id="requirement2", - Name="Requirement 2", - Description="Description of requirement 2", - Attributes=[], - Checks=[], - Tactics=[], - SubTechniques=[], - Platforms=[], - TechniqueURL="", + id="requirement2", + description="Description of requirement 2", + attributes=[], + checks={"aws": []}, + tactics=[], + sub_techniques=[], + platforms=[], + technique_url="", ) + requirement2.name = "Requirement 2" compliance1 = MagicMock( - Requirements=[requirement1, requirement2], - Framework="Framework 1", - Version="1.0", - Description="Description of compliance1", - Name="Compliance 1", + requirements=[requirement1, requirement2], + framework="Framework 1", + version="1.0", + description="Description of compliance1", ) + compliance1.name = "Compliance 1" prowler_compliance = {"aws": {"compliance1": compliance1}} template = generate_compliance_overview_template(prowler_compliance) @@ -262,33 +268,43 @@ def reset_compliance_cache(): """Reset the module-level cache so each test starts cold.""" previous = dict(compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS) compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS.clear() + # The warming flags are module-global; clear them so they do not leak + # between tests that call warm_compliance_caches. + compliance_module.COMPLIANCE_WARMING_STARTED.clear() + compliance_module.COMPLIANCE_WARMED.clear() try: yield finally: compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS.clear() compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS.update(previous) + compliance_module.COMPLIANCE_WARMING_STARTED.clear() + compliance_module.COMPLIANCE_WARMED.clear() class TestGetComplianceFrameworks: def test_returns_keys_from_compliance_get_bulk(self, reset_compliance_cache): - with patch("api.compliance.Compliance") as mock_compliance: - mock_compliance.get_bulk.return_value = { + with patch( + "api.compliance.get_bulk_compliance_frameworks_universal" + ) as mock_get_bulk: + mock_get_bulk.return_value = { "cis_1.4_aws": MagicMock(), "mitre_attack_aws": MagicMock(), } result = get_compliance_frameworks(Provider.ProviderChoices.AWS) assert sorted(result) == ["cis_1.4_aws", "mitre_attack_aws"] - mock_compliance.get_bulk.assert_called_once_with(Provider.ProviderChoices.AWS) + mock_get_bulk.assert_called_once_with(Provider.ProviderChoices.AWS) def test_caches_result_per_provider(self, reset_compliance_cache): - with patch("api.compliance.Compliance") as mock_compliance: - mock_compliance.get_bulk.return_value = {"cis_1.4_aws": MagicMock()} + with patch( + "api.compliance.get_bulk_compliance_frameworks_universal" + ) as mock_get_bulk: + mock_get_bulk.return_value = {"cis_1.4_aws": MagicMock()} get_compliance_frameworks(Provider.ProviderChoices.AWS) get_compliance_frameworks(Provider.ProviderChoices.AWS) # Cached after first call. - assert mock_compliance.get_bulk.call_count == 1 + assert mock_get_bulk.call_count == 1 @pytest.mark.parametrize( "provider_type", @@ -296,17 +312,105 @@ class TestGetComplianceFrameworks: ) def test_listing_is_subset_of_bulk(self, reset_compliance_cache, provider_type): """Regression for CLOUD-API-40S: every name returned by - ``get_compliance_frameworks`` must be loadable via ``Compliance.get_bulk``. + ``get_compliance_frameworks`` must be loadable via + ``get_bulk_compliance_frameworks_universal``. A divergence here is what produced ``KeyError: 'csa_ccm_4.0'`` in ``generate_outputs_task`` after universal/multi-provider compliance JSONs were introduced at the top-level ``prowler/compliance/`` path. """ - bulk_keys = set(Compliance.get_bulk(provider_type).keys()) + bulk_keys = set(get_bulk_compliance_frameworks_universal(provider_type).keys()) listed = set(get_compliance_frameworks(provider_type)) missing = listed - bulk_keys assert not missing, ( f"get_compliance_frameworks({provider_type!r}) returned names not " - f"loadable by Compliance.get_bulk: {sorted(missing)}" + f"loadable by get_bulk_compliance_frameworks_universal: " + f"{sorted(missing)}" ) + + +class TestWarmComplianceCaches: + def test_warms_all_provider_types_by_default(self, reset_compliance_cache): + provider_types = list(Provider.ProviderChoices.values) + with ( + patch("api.compliance.get_compliance_frameworks") as mock_frameworks, + patch("api.compliance._ensure_provider_loaded") as mock_ensure, + ): + warm_compliance_caches() + + warmed = {call.args[0] for call in mock_frameworks.call_args_list} + assert warmed == set(provider_types) + assert mock_frameworks.call_count == len(provider_types) + assert mock_ensure.call_count == len(provider_types) + + def test_warms_only_requested_provider_types(self, reset_compliance_cache): + with ( + patch("api.compliance.get_compliance_frameworks") as mock_frameworks, + patch("api.compliance._ensure_provider_loaded") as mock_ensure, + ): + warm_compliance_caches([Provider.ProviderChoices.AWS]) + + mock_frameworks.assert_called_once_with(Provider.ProviderChoices.AWS) + mock_ensure.assert_called_once_with(Provider.ProviderChoices.AWS) + + def test_populates_module_cache(self, reset_compliance_cache): + with ( + patch( + "api.compliance.get_bulk_compliance_frameworks_universal" + ) as mock_get_bulk, + patch("api.compliance._ensure_provider_loaded"), + ): + mock_get_bulk.return_value = {"cis_1.4_aws": MagicMock()} + warm_compliance_caches([Provider.ProviderChoices.AWS]) + + assert ( + Provider.ProviderChoices.AWS + in compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS + ) + + def test_failing_provider_does_not_abort_the_rest(self, reset_compliance_cache): + """A failing provider (even on SystemExit) is isolated; others warm.""" + providers = [Provider.ProviderChoices.AWS, Provider.ProviderChoices.OKTA] + + def fake_frameworks(provider_type): + if provider_type == Provider.ProviderChoices.OKTA: + raise SystemExit(1) + return [] + + with ( + patch( + "api.compliance.get_compliance_frameworks", side_effect=fake_frameworks + ), + patch("api.compliance._ensure_provider_loaded") as mock_ensure, + ): + failed = warm_compliance_caches(providers) + + assert failed == [Provider.ProviderChoices.OKTA] + mock_ensure.assert_called_once_with(Provider.ProviderChoices.AWS) + + def test_sets_readiness_flags(self, reset_compliance_cache): + assert not compliance_module.COMPLIANCE_WARMING_STARTED.is_set() + assert not compliance_module.COMPLIANCE_WARMED.is_set() + + with ( + patch("api.compliance.get_compliance_frameworks"), + patch("api.compliance._ensure_provider_loaded"), + ): + warm_compliance_caches([Provider.ProviderChoices.AWS]) + + assert compliance_module.COMPLIANCE_WARMING_STARTED.is_set() + assert compliance_module.COMPLIANCE_WARMED.is_set() + + def test_marks_warmed_even_when_a_provider_fails(self, reset_compliance_cache): + """A failed provider still leaves the caches flagged as warmed.""" + with ( + patch( + "api.compliance.get_compliance_frameworks", + side_effect=SystemExit(1), + ), + patch("api.compliance._ensure_provider_loaded"), + ): + warm_compliance_caches([Provider.ProviderChoices.AWS]) + + assert compliance_module.COMPLIANCE_WARMED.is_set() diff --git a/api/src/backend/api/tests/test_db_connection_labels.py b/api/src/backend/api/tests/test_db_connection_labels.py new file mode 100644 index 0000000000..a39e7d9051 --- /dev/null +++ b/api/src/backend/api/tests/test_db_connection_labels.py @@ -0,0 +1,55 @@ +from config.django.base import label_postgres_connections + + +class TestLabelPostgresConnections: + def test_labels_postgres_and_skips_neo4j(self, monkeypatch): + monkeypatch.setenv("DJANGO_APP_COMPONENT", "scan") + databases = { + "default": {"ENGINE": "psqlextra.backend"}, + "neo4j": {"HOST": "neo4j", "PORT": "7687"}, + } + + label_postgres_connections(databases) + + assert databases["default"]["OPTIONS"]["application_name"] == "scan:default" + assert "OPTIONS" not in databases["neo4j"] + + def test_labels_plain_postgresql_backend(self, monkeypatch): + monkeypatch.setenv("DJANGO_APP_COMPONENT", "api") + databases = {"saas": {"ENGINE": "django.db.backends.postgresql"}} + + label_postgres_connections(databases) + + assert databases["saas"]["OPTIONS"]["application_name"] == "api:saas" + + def test_defaults_component_to_api_when_unset(self, monkeypatch): + monkeypatch.delenv("DJANGO_APP_COMPONENT", raising=False) + databases = {"default": {"ENGINE": "psqlextra.backend"}} + + label_postgres_connections(databases) + + assert databases["default"]["OPTIONS"]["application_name"] == "api:default" + + def test_preserves_existing_options(self, monkeypatch): + monkeypatch.setenv("DJANGO_APP_COMPONENT", "worker") + databases = { + "replica": { + "ENGINE": "psqlextra.backend", + "OPTIONS": {"sslmode": "require"}, + } + } + + label_postgres_connections(databases) + + assert databases["replica"]["OPTIONS"] == { + "sslmode": "require", + "application_name": "worker:replica", + } + + def test_truncates_application_name_to_63_bytes(self, monkeypatch): + monkeypatch.setenv("DJANGO_APP_COMPONENT", "c" * 80) + databases = {"default": {"ENGINE": "psqlextra.backend"}} + + label_postgres_connections(databases) + + assert len(databases["default"]["OPTIONS"]["application_name"]) == 63 diff --git a/api/src/backend/api/tests/test_sse.py b/api/src/backend/api/tests/test_sse.py new file mode 100644 index 0000000000..beba821e64 --- /dev/null +++ b/api/src/backend/api/tests/test_sse.py @@ -0,0 +1,191 @@ +"""Tests for the platform SSE infrastructure (``api.sse``). + +Cover the two security-critical platform pieces — the channel-name +convention (:mod:`api.sse.utils`) and the tenant gate enforced by +:class:`api.sse.channelmanager.SSEChannelManager`. The SSE authentication +class lives in :mod:`api.authentication` with the rest of the auth stack, +so its tests live in ``test_authentication.py``. Per-feature SSE endpoints +add their own tests on top of these. +""" + +import uuid +from unittest.mock import MagicMock + +import pytest +from django.http import StreamingHttpResponse +from rest_framework.test import APIRequestFactory, force_authenticate + +from api.sse.base_views import BaseSSEViewSet +from api.sse.channelmanager import SSEChannelManager +from api.sse.utils import make_channel_name, tenant_id_from_channel + + +class TestMakeChannel: + def test_round_trips_tenant_id(self): + tenant_id = uuid.uuid4() + channel = make_channel_name("lighthouse-session", tenant_id, uuid.uuid4()) + assert tenant_id_from_channel(channel) == tenant_id + + def test_accepts_str_arguments(self): + tenant_id = uuid.uuid4() + channel = make_channel_name("lighthouse-session", str(tenant_id), "resource-1") + assert channel == f"lighthouse-session:{tenant_id}:resource-1" + + def test_prefix_with_hyphen_is_not_split(self): + # Prefixes contain hyphens but never colons, so the tenant id is + # always the second colon-separated segment. + tenant_id = uuid.uuid4() + channel = make_channel_name("a-long-hyphenated-prefix", tenant_id, "res") + assert tenant_id_from_channel(channel) == tenant_id + + @pytest.mark.parametrize( + "prefix, tenant_id, resource_id", + [ + ("evil:prefix", uuid.uuid4(), "res"), + ("prefix", uuid.uuid4(), "res:extra"), + ("prefix", "tenant:smuggled", "res"), + ], + ) + def test_rejects_separator_injection(self, prefix, tenant_id, resource_id): + # A colon in any segment would let a crafted name smuggle extra + # segments past the parser, so construction must fail loudly. + with pytest.raises(ValueError): + make_channel_name(prefix, tenant_id, resource_id) + + +class TestTenantIdFromChannel: + def test_returns_none_for_too_few_segments(self): + assert tenant_id_from_channel("prefix:only") is None + assert tenant_id_from_channel("garbage") is None + + def test_returns_none_for_too_many_segments(self): + # A valid tenant UUID in position 1 must not authorize a + # non-canonical name that carries extra segments. + tenant_id = uuid.uuid4() + assert tenant_id_from_channel(f"prefix:{tenant_id}:resource:extra") is None + + def test_returns_none_for_non_uuid_tenant_segment(self): + assert tenant_id_from_channel("prefix:not-a-uuid:resource") is None + + def test_parses_valid_channel(self): + tenant_id = uuid.uuid4() + assert tenant_id_from_channel(f"prefix:{tenant_id}:resource") == tenant_id + + +@pytest.mark.django_db +class TestSSEChannelManager: + def test_member_can_read_own_tenant_channel( + self, create_test_user, tenants_fixture + ): + tenant = tenants_fixture[0] + channel = make_channel_name("lighthouse-session", tenant.id, uuid.uuid4()) + assert SSEChannelManager().can_read_channel(create_test_user, channel) + + def test_non_member_cannot_read_other_tenant_channel( + self, create_test_user, tenants_fixture + ): + # create_test_user is a member of tenant1 and tenant2 but not tenant3. + foreign_tenant = tenants_fixture[2] + channel = make_channel_name( + "lighthouse-session", foreign_tenant.id, uuid.uuid4() + ) + assert not SSEChannelManager().can_read_channel(create_test_user, channel) + + def test_anonymous_user_is_rejected(self, tenants_fixture): + channel = make_channel_name( + "lighthouse-session", tenants_fixture[0].id, uuid.uuid4() + ) + assert not SSEChannelManager().can_read_channel(None, channel) + + anon = MagicMock(is_authenticated=False) + assert not SSEChannelManager().can_read_channel(anon, channel) + + def test_malformed_channel_is_rejected(self, create_test_user, tenants_fixture): + assert not SSEChannelManager().can_read_channel(create_test_user, "garbage") + + def test_get_channels_for_request_returns_active_tenant_channels(self): + tenant_id = uuid.uuid4() + own = make_channel_name("prefix", tenant_id, "resource") + request = MagicMock() + request.tenant_id = str(tenant_id) + request.sse_channels = {own} + assert SSEChannelManager().get_channels_for_request(request, {}) == {own} + + def test_get_channels_for_request_drops_other_tenant_channels(self): + # Fail-closed: a channel for a tenant other than the active JWT + # tenant is dropped before reaching django-eventstream, even if the + # viewset mistakenly stashed it. This is the primary tenant gate that + # binds authorization to request.tenant_id, not just membership. + active_tenant = uuid.uuid4() + own = make_channel_name("prefix", active_tenant, "resource") + foreign = make_channel_name("prefix", uuid.uuid4(), "resource") + request = MagicMock() + request.tenant_id = str(active_tenant) + request.sse_channels = {own, foreign} + assert SSEChannelManager().get_channels_for_request(request, {}) == {own} + + def test_get_channels_for_request_drops_malformed_channels(self): + request = MagicMock() + request.tenant_id = str(uuid.uuid4()) + request.sse_channels = {"garbage", "prefix:not-a-uuid:resource"} + assert SSEChannelManager().get_channels_for_request(request, {}) == set() + + def test_get_channels_for_request_without_tenant_returns_empty(self): + # No active tenant on the request (auth/RLS never ran) → fail closed, + # regardless of any channels stashed on it. + request = MagicMock(spec=[]) + assert SSEChannelManager().get_channels_for_request(request, {}) == set() + + def test_get_channels_for_request_defaults_to_empty(self): + # A request that never went through BaseSSEViewSet.list has no + # sse_channels attribute; the manager must not raise. + request = object() + assert SSEChannelManager().get_channels_for_request(request, {}) == set() + + def test_channel_is_not_reliable(self): + # v1 ships without server-side replay storage. + assert ( + SSEChannelManager().is_channel_reliable("prefix:tenant:resource") is False + ) + + +@pytest.mark.django_db +class TestBaseSSEViewSet: + """End-to-end check that the base viewset opens a stream. + + ``BaseSSEViewSet.list`` hands the DRF ``Request`` straight to + django-eventstream's ``events()``, which is written for a plain + Django request. This drives a real request through the full DRF + stack (authentication, RLS, content negotiation, channel manager) + and asserts the result is an SSE stream, so the DRF/Django request + mismatch cannot regress silently. + """ + + def test_list_opens_event_stream(self, create_test_user, tenants_fixture): + tenant = tenants_fixture[0] + channel = make_channel_name("test-sse", tenant.id, uuid.uuid4()) + seen_tenant_ids = [] + + class _StreamingSSEViewSet(BaseSSEViewSet): + def get_channels(self): + # Reached only after dispatch/initial ran, so the RLS + # tenant context is already on the request. + seen_tenant_ids.append(self.request.tenant_id) + return {channel} + + request = APIRequestFactory().get("/api/v1/test-sse/stream") + force_authenticate( + request, user=create_test_user, token={"tenant_id": str(tenant.id)} + ) + + view = _StreamingSSEViewSet.as_view({"get": "list"}) + response = view(request) + + # A StreamingHttpResponse (not the plain HttpResponse used for SSE + # error envelopes) means events() accepted the DRF request, the + # channel manager handed it a non-empty channel set, and the + # stream was opened end to end. + assert isinstance(response, StreamingHttpResponse) + assert response.status_code == 200 + assert response["Content-Type"] == "text/event-stream" + assert seen_tenant_ids == [str(tenant.id)] diff --git a/api/src/backend/api/tests/test_utils.py b/api/src/backend/api/tests/test_utils.py index 3a7f030a9c..3fd9235dac 100644 --- a/api/src/backend/api/tests/test_utils.py +++ b/api/src/backend/api/tests/test_utils.py @@ -357,6 +357,30 @@ class TestGetProwlerProviderKwargs: expected_result = {**secret_dict, **expected_extra_kwargs} assert result == expected_result + def test_get_prowler_provider_kwargs_oraclecloud_converts_region_string_to_set( + self, + ): + secret_dict = { + "user": "ocid1.user.oc1..fake", + "fingerprint": "00:11:22:33:44:55:66:77", + "key_content": "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----", + "tenancy": "ocid1.tenancy.oc1..fake", + "region": "us-ashburn-1", + "pass_phrase": "fake-passphrase", + } + secret_mock = MagicMock() + secret_mock.secret = secret_dict + + provider = MagicMock() + provider.provider = Provider.ProviderChoices.ORACLECLOUD.value + provider.secret = secret_mock + provider.uid = "ocid1.tenancy.oc1..fake" + + result = get_prowler_provider_kwargs(provider) + + expected_result = {**secret_dict, "region": {"us-ashburn-1"}} + assert result == expected_result + def test_get_prowler_provider_kwargs_with_mutelist(self): provider_uid = "provider_uid" secret_dict = {"key": "value"} diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index ebd9128407..35098c1907 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -24,9 +24,11 @@ from conftest import ( today_after_n_days, ) from django.conf import settings +from django.db import connection from django.db.models import Count from django.http import JsonResponse from django.test import RequestFactory +from django.test.utils import CaptureQueriesContext from django.urls import reverse from django_celery_results.models import TaskResult from rest_framework import status @@ -64,6 +66,7 @@ from api.models import ( ProviderSecret, Resource, ResourceFindingMapping, + ResourceTag, Role, RoleProviderGroupRelationship, SAMLConfiguration, @@ -1408,6 +1411,42 @@ class TestProviderViewSet: ) assert response.status_code == status.HTTP_400_BAD_REQUEST + def test_providers_filter_provider_groups( + self, + authenticated_client, + tenants_fixture, + providers_fixture, + provider_groups_fixture, + ): + tenant = tenants_fixture[0] + provider1, provider2, *_ = providers_fixture + group1, group2, *_ = provider_groups_fixture + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group1 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group2 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider2, provider_group=group2 + ) + + response = authenticated_client.get( + reverse("provider-list"), {"filter[provider_groups]": str(group1.id)} + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert [item["id"] for item in data] == [str(provider1.id)] + + response = authenticated_client.get( + reverse("provider-list"), + {"filter[provider_groups__in]": f"{group1.id},{group2.id}"}, + ) + assert response.status_code == status.HTTP_200_OK + provider_ids = {item["id"] for item in response.json()["data"]} + assert provider_ids == {str(provider1.id), str(provider2.id)} + assert len(response.json()["data"]) == 2 + def test_providers_disable_pagination( self, authenticated_client, providers_fixture, tenants_fixture ): @@ -3712,6 +3751,41 @@ class TestScanViewSet: assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == expected_count + def test_scans_filter_provider_groups( + self, + authenticated_client, + tenants_fixture, + scans_fixture, + provider_groups_fixture, + ): + tenant = tenants_fixture[0] + scan1, scan2, *_ = scans_fixture + group1, group2, *_ = provider_groups_fixture + ProviderGroupMembership.objects.create( + tenant=tenant, provider=scan1.provider, provider_group=group1 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=scan1.provider, provider_group=group2 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=scan2.provider, provider_group=group2 + ) + + response = authenticated_client.get( + reverse("scan-list"), {"filter[provider_groups]": str(group1.id)} + ) + assert response.status_code == status.HTTP_200_OK + assert {item["id"] for item in response.json()["data"]} == {str(scan1.id)} + + response = authenticated_client.get( + reverse("scan-list"), + {"filter[provider_groups__in]": f"{group1.id},{group2.id}"}, + ) + assert response.status_code == status.HTTP_200_OK + scan_ids = {item["id"] for item in response.json()["data"]} + assert scan_ids == {str(scan1.id), str(scan2.id), str(scans_fixture[2].id)} + assert len(response.json()["data"]) == 3 + @pytest.mark.parametrize( "filter_name", [ @@ -3856,16 +3930,20 @@ class TestScanViewSet: scan.output_location = "dummy" scan.save() - dummy_task = Task.objects.create(tenant_id=scan.tenant_id) - dummy_task.id = "dummy-task-id" - dummy_task_data = {"id": dummy_task.id, "state": StateChoices.EXECUTING} + task_result = TaskResult.objects.create( + task_id=str(uuid4()), + task_name="scan-report", + task_kwargs={"scan_id": str(scan.id)}, + ) + task = Task.objects.create( + tenant_id=scan.tenant_id, + task_runner_task=task_result, + ) + dummy_task_data = {"id": str(task.id), "state": StateChoices.EXECUTING} - with ( - patch("api.v1.views.Task.objects.get", return_value=dummy_task), - patch( - "api.v1.views.TaskSerializer", - return_value=type("DummySerializer", (), {"data": dummy_task_data}), - ), + with patch( + "api.v1.views.TaskSerializer", + return_value=type("DummySerializer", (), {"data": dummy_task_data}), ): url = reverse("scan-report", kwargs={"pk": scan.id}) response = authenticated_client.get(url) @@ -4186,6 +4264,88 @@ class TestScanViewSet: assert resp.status_code == status.HTTP_302_FOUND assert resp["Location"] == presigned_url + def test_compliance_s3_returns_latest_match( + self, authenticated_client, scans_fixture, monkeypatch + ): + """When several files match, the most recently modified one is served.""" + scan = scans_fixture[0] + bucket = "bucket" + scan.output_location = f"s3://{bucket}/path/scan.zip" + scan.state = StateChoices.COMPLETED + scan.save() + + monkeypatch.setattr( + "api.v1.views.env", + type("env", (), {"str": lambda self, *args, **kwargs: "test-bucket"})(), + ) + + old_key = "path/compliance/prowler-output-aws-20240101000000_cis_1.4_aws.csv" + latest_key = "path/compliance/prowler-output-aws-20240202000000_cis_1.4_aws.csv" + + class FakeS3Client: + def list_objects_v2(self, Bucket, Prefix): + return { + "Contents": [ + { + "Key": old_key, + "LastModified": datetime(2024, 1, 1, tzinfo=timezone.utc), + }, + { + "Key": latest_key, + "LastModified": datetime(2024, 2, 2, tzinfo=timezone.utc), + }, + ] + } + + def generate_presigned_url(self, ClientMethod, Params, ExpiresIn): + assert Params["Key"] == latest_key + return "https://test-bucket.s3.amazonaws.com/latest" + + monkeypatch.setattr("api.v1.views.get_s3_client", lambda: FakeS3Client()) + + url = reverse("scan-compliance", kwargs={"pk": scan.id, "name": "cis_1.4_aws"}) + resp = authenticated_client.get(url) + assert resp.status_code == status.HTTP_302_FOUND + assert resp["Location"].endswith("/latest") + + def test_compliance_local_returns_latest_match( + self, authenticated_client, scans_fixture, monkeypatch + ): + """The local branch serves the most recently modified matching file.""" + scan = scans_fixture[0] + scan.state = StateChoices.COMPLETED + + with tempfile.TemporaryDirectory() as tmp: + comp_dir = Path(tmp) / "reports" / "compliance" + comp_dir.mkdir(parents=True, exist_ok=True) + + old_file = comp_dir / "prowler-output-aws-20240101000000_cis_1.4_aws.csv" + old_file.write_bytes(b"old") + latest_file = comp_dir / "prowler-output-aws-20240202000000_cis_1.4_aws.csv" + latest_file.write_bytes(b"latest") + # Make `latest_file` newer regardless of creation order. + os.utime(old_file, (1_700_000_000, 1_700_000_000)) + os.utime(latest_file, (1_700_000_100, 1_700_000_100)) + + scan.output_location = str(Path(tmp) / "reports" / "scan.zip") + scan.save() + + monkeypatch.setattr( + glob, + "glob", + lambda p: [str(old_file), str(latest_file)], + ) + + url = reverse( + "scan-compliance", kwargs={"pk": scan.id, "name": "cis_1.4_aws"} + ) + resp = authenticated_client.get(url) + assert resp.status_code == status.HTTP_200_OK + assert resp.content == b"latest" + assert resp["Content-Disposition"].endswith( + f'filename="{latest_file.name}"' + ) + def test_compliance_s3_not_found( self, authenticated_client, scans_fixture, monkeypatch ): @@ -4294,18 +4454,24 @@ class TestScanViewSet: assert cd.startswith('attachment; filename="') assert cd.endswith(f'filename="{fname.name}"') - @patch("api.v1.views.Task.objects.get") @patch("api.v1.views.TaskSerializer") def test__get_task_status_returns_none_if_task_not_executing( - self, mock_task_serializer, mock_task_get, authenticated_client, scans_fixture + self, mock_task_serializer, authenticated_client, scans_fixture ): scan = scans_fixture[0] scan.state = StateChoices.COMPLETED scan.output_location = "dummy" scan.save() - task = Task.objects.create(tenant_id=scan.tenant_id) - mock_task_get.return_value = task + task_result = TaskResult.objects.create( + task_id=str(uuid4()), + task_name="scan-report", + task_kwargs={"scan_id": str(scan.id)}, + ) + task = Task.objects.create( + tenant_id=scan.tenant_id, + task_runner_task=task_result, + ) mock_task_serializer.return_value.data = { "id": str(task.id), "state": StateChoices.COMPLETED, @@ -4326,6 +4492,7 @@ class TestScanViewSet: scan.save() task_result = TaskResult.objects.create( + task_id=str(uuid4()), task_name="scan-report", task_kwargs={"scan_id": str(scan.id)}, ) @@ -4346,6 +4513,51 @@ class TestScanViewSet: assert response.status_code == status.HTTP_202_ACCEPTED assert response.data["id"] == str(task.id) + @patch("api.v1.views.TaskSerializer") + def test__get_task_status_returns_latest_task( + self, mock_task_serializer, authenticated_client, scans_fixture + ): + """With several scan-report tasks for the scan, the most recent is used.""" + scan = scans_fixture[0] + scan.state = StateChoices.COMPLETED + scan.output_location = "dummy" + scan.save() + + old_task = Task.objects.create( + tenant_id=scan.tenant_id, + task_runner_task=TaskResult.objects.create( + task_id=str(uuid4()), + task_name="scan-report", + task_kwargs={"scan_id": str(scan.id)}, + ), + ) + new_task = Task.objects.create( + tenant_id=scan.tenant_id, + task_runner_task=TaskResult.objects.create( + task_id=str(uuid4()), + task_name="scan-report", + task_kwargs={"scan_id": str(scan.id)}, + ), + ) + # `inserted_at` is `auto_now_add`, and within the test transaction the DB + # `now()` is constant, so force distinct timestamps to make order_by stable. + base = datetime(2024, 1, 1, tzinfo=timezone.utc) + Task.objects.filter(pk=old_task.pk).update(inserted_at=base) + Task.objects.filter(pk=new_task.pk).update( + inserted_at=base + timedelta(hours=1) + ) + + mock_task_serializer.side_effect = lambda instance, *a, **k: SimpleNamespace( + data={"id": str(instance.id), "state": StateChoices.EXECUTING} + ) + + url = reverse("scan-report", kwargs={"pk": scan.id}) + response = authenticated_client.get(url) + + assert response.status_code == status.HTTP_202_ACCEPTED + assert str(new_task.id) in response["Content-Location"] + assert str(old_task.id) not in response["Content-Location"] + @patch("api.v1.views.get_s3_client") @patch("api.v1.views.sentry_sdk.capture_exception") def test_compliance_list_objects_client_error( @@ -5855,6 +6067,49 @@ class TestResourceViewSet: assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == expected_count + def test_resource_filter_provider_groups( + self, + authenticated_client, + tenants_fixture, + resources_fixture, + provider_groups_fixture, + ): + tenant = tenants_fixture[0] + resource1, resource2, resource3, *_ = resources_fixture + group1, group2, *_ = provider_groups_fixture + ProviderGroupMembership.objects.create( + tenant=tenant, provider=resource1.provider, provider_group=group1 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=resource1.provider, provider_group=group2 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=resource3.provider, provider_group=group2 + ) + + response = authenticated_client.get( + reverse("resource-list"), + {"filter[updated_at]": TODAY, "filter[provider_groups]": str(group1.id)}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 2 + assert {item["id"] for item in response.json()["data"]} == { + str(resource1.id), + str(resource2.id), + } + + response = authenticated_client.get( + reverse("resource-list"), + { + "filter[updated_at]": TODAY, + "filter[provider_groups__in]": f"{group1.id},{group2.id}", + }, + ) + assert response.status_code == status.HTTP_200_OK + resource_ids = {item["id"] for item in response.json()["data"]} + assert resource_ids == {str(resource1.id), str(resource2.id), str(resource3.id)} + assert len(response.json()["data"]) == 3 + def test_resource_filter_by_scan_id( self, authenticated_client, resources_fixture, scans_fixture ): @@ -6916,6 +7171,80 @@ class TestFindingViewSet: == findings_fixture[0].status ) + def test_findings_list_resource_tags_no_n_plus_one( + self, authenticated_client, findings_fixture + ): + """Listing findings must load every resource's tags in a constant + number of queries, no matter how many findings/resources are returned. + + This guards ``FindingViewSet._optimize_tags_loading`` against + regressions that would reintroduce one extra query per resource (the + N+1 the prefetch was added to remove). + """ + scan = findings_fixture[0].scan + tenant_id = findings_fixture[0].tenant_id + provider = scan.provider + + def _create_finding_with_tagged_resource(index): + resource = Resource.objects.create( + tenant_id=tenant_id, + provider=provider, + uid=f"arn:aws:ec2:us-east-1:123456789012:instance/n-plus-one-{index}", + name=f"N+1 Instance {index}", + region="us-east-1", + service="ec2", + type="prowler-test", + ) + resource.upsert_or_delete_tags( + [ + ResourceTag.objects.create( + tenant_id=tenant_id, + key=f"key-{index}", + value=f"value-{index}", + ) + ] + ) + finding = Finding.objects.create( + tenant_id=tenant_id, + uid=f"n_plus_one_finding_{index}", + scan=scan, + status=Status.FAIL, + status_extended="n+1 status", + impact=Severity.medium, + severity=Severity.medium, + check_id="test_check_id", + check_metadata={"CheckId": "test_check_id", "servicename": "ec2"}, + first_seen_at="2024-01-02T00:00:00Z", + ) + finding.add_resources([resource]) + return finding + + params = {"filter[inserted_at]": TODAY, "include": "resources"} + + # Baseline: the two findings provided by the fixture. + with CaptureQueriesContext(connection) as baseline: + response = authenticated_client.get(reverse("finding-list"), params) + assert response.status_code == status.HTTP_200_OK + + # Add more findings, each with its own resource carrying tags. + extra_findings = 5 + for index in range(extra_findings): + _create_finding_with_tagged_resource(index) + + with CaptureQueriesContext(connection) as scaled: + response = authenticated_client.get(reverse("finding-list"), params) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == len(findings_fixture) + extra_findings + + # The query count must not grow with the number of findings/resources. + assert len(scaled.captured_queries) == len(baseline.captured_queries), ( + "Resource tags are not being prefetched: " + f"{len(baseline.captured_queries)} queries for {len(findings_fixture)} " + f"findings vs {len(scaled.captured_queries)} for " + f"{len(findings_fixture) + extra_findings}. Likely an N+1 regression " + "in FindingViewSet._optimize_tags_loading." + ) + @pytest.mark.parametrize( "include_values, expected_resources", [ @@ -7093,6 +7422,40 @@ class TestFindingViewSet: assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == 2 + def test_finding_filter_provider_groups( + self, + authenticated_client, + tenants_fixture, + findings_fixture, + provider_groups_fixture, + ): + tenant = tenants_fixture[0] + finding1, finding2, *_ = findings_fixture + group1, group2, *_ = provider_groups_fixture + ProviderGroupMembership.objects.create( + tenant=tenant, provider=finding1.scan.provider, provider_group=group1 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=finding1.scan.provider, provider_group=group2 + ) + + response = authenticated_client.get( + reverse("finding-list"), + {"filter[inserted_at]": TODAY, "filter[provider_groups]": str(group1.id)}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 2 + + response = authenticated_client.get( + reverse("finding-list"), + { + "filter[inserted_at]": TODAY, + "filter[provider_groups__in]": f"{group1.id},{group2.id}", + }, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 2 + @pytest.mark.parametrize( "filter_name", ( @@ -9063,6 +9426,118 @@ class TestComplianceOverviewViewSet: with patch("api.v1.views.backfill_compliance_summaries_task.delay") as mock: yield mock + def _create_completed_scan(self, provider, name): + return Scan.objects.create( + name=name, + provider=provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=provider.tenant_id, + started_at=datetime.now(timezone.utc), + completed_at=datetime.now(timezone.utc), + ) + + def _create_requirement( + self, + scan, + requirement_id, + status_choice, + region="eu-west-1", + compliance_id="cis_1.4_aws", + ): + passed = 1 if status_choice == StatusChoices.PASS else 0 + total = 1 if status_choice != StatusChoices.MANUAL else 0 + return ComplianceRequirementOverview.objects.create( + tenant_id=scan.tenant_id, + scan=scan, + compliance_id=compliance_id, + framework="CIS-1.4-AWS", + version="1.4", + description="CIS AWS Foundations Benchmark v1.4.0", + region=region, + requirement_id=requirement_id, + requirement_status=status_choice, + passed_checks=passed, + failed_checks=0 + if status_choice in (StatusChoices.PASS, StatusChoices.MANUAL) + else 1, + total_checks=total, + passed_findings=passed, + total_findings=total, + ) + + def _create_compliance_summary( + self, + scan, + *, + passed, + failed, + manual=0, + compliance_id="cis_1.4_aws", + ): + return ComplianceOverviewSummary.objects.create( + tenant_id=scan.tenant_id, + scan=scan, + compliance_id=compliance_id, + requirements_passed=passed, + requirements_failed=failed, + requirements_manual=manual, + total_requirements=passed + failed + manual, + ) + + def _overview_attrs_by_id(self, response): + assert response.status_code == status.HTTP_200_OK + return {item["id"]: item["attributes"] for item in response.json()["data"]} + + def _prepare_latest_compliance_data(self, providers_fixture): + provider1, provider2, provider3, *_ = providers_fixture + old_scan = self._create_completed_scan(provider1, "old aws compliance scan") + latest_scan1 = self._create_completed_scan( + provider1, "latest aws compliance scan 1" + ) + latest_scan2 = self._create_completed_scan( + provider2, "latest aws compliance scan 2" + ) + latest_gcp_scan = self._create_completed_scan( + provider3, "latest gcp compliance scan" + ) + + self._create_requirement(old_scan, "1.1", StatusChoices.FAIL) + self._create_requirement(old_scan, "1.2", StatusChoices.FAIL) + self._create_compliance_summary(old_scan, passed=0, failed=2) + + self._create_requirement( + latest_scan1, "1.1", StatusChoices.PASS, region="eu-west-1" + ) + self._create_requirement( + latest_scan1, "1.2", StatusChoices.PASS, region="eu-west-1" + ) + self._create_compliance_summary(latest_scan1, passed=2, failed=0) + + self._create_requirement( + latest_scan2, "1.1", StatusChoices.FAIL, region="us-east-1" + ) + self._create_requirement( + latest_scan2, "1.2", StatusChoices.PASS, region="us-east-1" + ) + self._create_compliance_summary(latest_scan2, passed=1, failed=1) + + self._create_requirement( + latest_gcp_scan, + "gcp-1.1", + StatusChoices.FAIL, + region="europe-west1", + compliance_id="cis_1.3_gcp", + ) + self._create_compliance_summary( + latest_gcp_scan, + passed=0, + failed=1, + compliance_id="cis_1.3_gcp", + ) + + return old_scan, latest_scan1, latest_scan2, latest_gcp_scan + def test_compliance_overview_list_none( self, authenticated_client, @@ -9210,6 +9685,283 @@ class TestComplianceOverviewViewSet: assert len(response.json()["data"]) >= 1 mock_backfill_task.assert_not_called() + def test_compliance_overview_provider_id_filter_uses_latest_scan( + self, + authenticated_client, + providers_fixture, + mock_backfill_task, + ): + _, latest_scan, *_ = self._prepare_latest_compliance_data(providers_fixture) + + response = authenticated_client.get( + reverse("complianceoverview-list"), + {"filter[provider_id]": str(latest_scan.provider_id)}, + ) + + attrs_by_id = self._overview_attrs_by_id(response) + assert attrs_by_id["cis_1.4_aws"]["requirements_passed"] == 2 + assert attrs_by_id["cis_1.4_aws"]["requirements_failed"] == 0 + assert "cis_1.3_gcp" not in attrs_by_id + mock_backfill_task.assert_not_called() + + def test_compliance_overview_provider_id_in_filter_aggregates_latest_scans( + self, + authenticated_client, + providers_fixture, + ): + _, latest_scan1, latest_scan2, *_ = self._prepare_latest_compliance_data( + providers_fixture + ) + + response = authenticated_client.get( + reverse("complianceoverview-list"), + { + "filter[provider_id__in]": ( + f"{latest_scan1.provider_id},{latest_scan2.provider_id}" + ) + }, + ) + + attrs_by_id = self._overview_attrs_by_id(response) + assert attrs_by_id["cis_1.4_aws"]["requirements_passed"] == 1 + assert attrs_by_id["cis_1.4_aws"]["requirements_failed"] == 1 + assert attrs_by_id["cis_1.4_aws"]["total_requirements"] == 2 + assert "cis_1.3_gcp" not in attrs_by_id + + def test_compliance_overview_provider_type_filter_uses_latest_scans( + self, + authenticated_client, + providers_fixture, + ): + self._prepare_latest_compliance_data(providers_fixture) + + response = authenticated_client.get( + reverse("complianceoverview-list"), + {"filter[provider_type]": Provider.ProviderChoices.AWS.value}, + ) + + attrs_by_id = self._overview_attrs_by_id(response) + assert attrs_by_id["cis_1.4_aws"]["requirements_passed"] == 1 + assert attrs_by_id["cis_1.4_aws"]["requirements_failed"] == 1 + assert attrs_by_id["cis_1.4_aws"]["total_requirements"] == 2 + assert "cis_1.3_gcp" not in attrs_by_id + + def test_compliance_overview_provider_groups_filters_use_latest_scans( + self, + authenticated_client, + providers_fixture, + provider_groups_fixture, + tenants_fixture, + ): + tenant = tenants_fixture[0] + provider1, provider2, *_ = providers_fixture + group1, group2, *_ = provider_groups_fixture + _, latest_scan1, latest_scan2, *_ = self._prepare_latest_compliance_data( + providers_fixture + ) + ProviderGroupMembership.objects.create( + tenant_id=tenant.id, + provider=provider1, + provider_group=group1, + ) + ProviderGroupMembership.objects.create( + tenant_id=tenant.id, + provider=provider2, + provider_group=group2, + ) + + response = authenticated_client.get( + reverse("complianceoverview-list"), + {"filter[provider_groups]": str(group1.id)}, + ) + + attrs_by_id = self._overview_attrs_by_id(response) + assert attrs_by_id["cis_1.4_aws"]["requirements_passed"] == 2 + assert attrs_by_id["cis_1.4_aws"]["requirements_failed"] == 0 + + response = authenticated_client.get( + reverse("complianceoverview-list"), + {"filter[provider_groups__in]": f"{group1.id},{group2.id}"}, + ) + + attrs_by_id = self._overview_attrs_by_id(response) + assert attrs_by_id["cis_1.4_aws"]["requirements_passed"] == 1 + assert attrs_by_id["cis_1.4_aws"]["requirements_failed"] == 1 + assert attrs_by_id["cis_1.4_aws"]["total_requirements"] == 2 + + def _assert_latest_provider_scan_task_response( + self, + authenticated_client, + endpoint, + scan, + query_params=None, + ): + query_params = {**(query_params or {})} + if not any(key.startswith("filter[provider_") for key in query_params): + query_params = { + "filter[provider_id]": str(scan.provider_id), + **query_params, + } + + with patch.object( + ComplianceOverviewViewSet, "get_task_response_if_running" + ) as mock_task_response: + mock_task_response.return_value = Response( + {"detail": "Task is running"}, status=status.HTTP_202_ACCEPTED + ) + + response = authenticated_client.get(reverse(endpoint), query_params) + + assert response.status_code == status.HTTP_202_ACCEPTED + mock_task_response.assert_called_once() + _, kwargs = mock_task_response.call_args + assert kwargs["task_name"] == "scan-compliance-overviews" + assert str(kwargs["task_kwargs"]["tenant_id"]) == str(scan.tenant_id) + assert str(kwargs["task_kwargs"]["scan_id"]) == str(scan.id) + assert kwargs["raise_on_not_found"] is False + + def test_compliance_overview_provider_filter_returns_running_task_without_data( + self, + authenticated_client, + providers_fixture, + ): + scan = self._create_completed_scan( + providers_fixture[0], "latest scan without compliance data" + ) + + self._assert_latest_provider_scan_task_response( + authenticated_client, + "complianceoverview-list", + scan, + ) + + def test_compliance_overview_provider_filter_returns_running_task_for_partial_data( + self, + authenticated_client, + providers_fixture, + ): + provider_with_data, provider_without_data, *_ = providers_fixture + scan_with_data = self._create_completed_scan( + provider_with_data, "latest scan with compliance data" + ) + scan_without_data = self._create_completed_scan( + provider_without_data, "latest scan without partial compliance data" + ) + self._create_requirement(scan_with_data, "1.1", StatusChoices.PASS) + + self._assert_latest_provider_scan_task_response( + authenticated_client, + "complianceoverview-list", + scan_without_data, + { + "filter[provider_id__in]": ( + f"{provider_with_data.id},{provider_without_data.id}" + ) + }, + ) + + def test_compliance_overview_provider_filter_empty_response_uses_scan_data_presence( + self, + authenticated_client, + providers_fixture, + ): + scan = self._create_completed_scan( + providers_fixture[0], "latest scan with filtered compliance data" + ) + self._create_requirement(scan, "1.1", StatusChoices.PASS, region="eu-west-1") + + with patch.object( + ComplianceOverviewViewSet, "get_task_response_if_running" + ) as mock_task_response: + mock_task_response.return_value = Response( + {"detail": "Task is running"}, status=status.HTTP_202_ACCEPTED + ) + + response = authenticated_client.get( + reverse("complianceoverview-list"), + { + "filter[provider_id]": str(scan.provider_id), + "filter[region]": "us-east-1", + }, + ) + + assert response.status_code == status.HTTP_200_OK + assert response.json()["data"] == [] + mock_task_response.assert_not_called() + + def test_compliance_overview_metadata_provider_filter_returns_running_task_without_data( + self, + authenticated_client, + providers_fixture, + ): + scan = self._create_completed_scan( + providers_fixture[0], "latest scan without compliance metadata" + ) + + self._assert_latest_provider_scan_task_response( + authenticated_client, + "complianceoverview-metadata", + scan, + ) + + def test_compliance_overview_requirements_provider_filter_returns_running_task_without_data( + self, + authenticated_client, + providers_fixture, + ): + scan = self._create_completed_scan( + providers_fixture[0], "latest scan without compliance requirements" + ) + + self._assert_latest_provider_scan_task_response( + authenticated_client, + "complianceoverview-requirements", + scan, + {"filter[compliance_id]": "cis_1.4_aws"}, + ) + + def test_compliance_overview_metadata_accepts_provider_filters( + self, + authenticated_client, + providers_fixture, + ): + _, latest_scan, *_ = self._prepare_latest_compliance_data(providers_fixture) + + response = authenticated_client.get( + reverse("complianceoverview-metadata"), + {"filter[provider_id]": str(latest_scan.provider_id)}, + ) + + assert response.status_code == status.HTTP_200_OK + regions = response.json()["data"]["attributes"]["regions"] + assert regions == ["eu-west-1"] + + def test_compliance_overview_requirements_accepts_provider_filters( + self, + authenticated_client, + providers_fixture, + ): + _, latest_scan1, latest_scan2, *_ = self._prepare_latest_compliance_data( + providers_fixture + ) + + response = authenticated_client.get( + reverse("complianceoverview-requirements"), + { + "filter[provider_id__in]": ( + f"{latest_scan1.provider_id},{latest_scan2.provider_id}" + ), + "filter[compliance_id]": "cis_1.4_aws", + }, + ) + + assert response.status_code == status.HTTP_200_OK + requirements_by_id = { + item["id"]: item["attributes"] for item in response.json()["data"] + } + assert requirements_by_id["1.1"]["status"] == "FAIL" + assert requirements_by_id["1.2"]["status"] == "PASS" + def test_compliance_overview_metadata( self, authenticated_client, compliance_requirements_overviews_fixture ): @@ -9345,6 +10097,198 @@ class TestComplianceOverviewViewSet: assert "platforms" in attributes["attributes"]["technique_details"] assert "technique_url" in attributes["attributes"]["technique_details"] + # Guard against the `_raw_attributes` wrapper leaking through — + # the UI reads metadata[i].Category / .AWSService directly. + metadata = attributes["attributes"]["metadata"] + assert isinstance(metadata, list) and len(metadata) > 0 + first_attr = metadata[0] + assert isinstance(first_attr, dict) + assert "_raw_attributes" not in first_attr + assert "Category" in first_attr + assert "AWSService" in first_attr + + def test_compliance_overview_attributes_resolves_provider_from_scan( + self, authenticated_client, tenants_fixture, providers_fixture + ): + # csa_ccm_4.0 is a multi-provider universal framework: a single + # compliance_id whose requirements expose different checks per provider. + # Passing a scan must return the check IDs for that scan's provider, + # otherwise the endpoint defaults to the first provider that declares the + # framework and azure/gcp requirements end up with check IDs that match + # no findings. + tenant = tenants_fixture[0] + gcp_provider = providers_fixture[2] + azure_provider = providers_fixture[4] + assert gcp_provider.provider == Provider.ProviderChoices.GCP.value + assert azure_provider.provider == Provider.ProviderChoices.AZURE.value + + now = datetime.now(timezone.utc) + gcp_scan = Scan.objects.create( + name="gcp scan", + provider=gcp_provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant.id, + started_at=now, + completed_at=now, + ) + azure_scan = Scan.objects.create( + name="azure scan", + provider=azure_provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant.id, + started_at=now, + completed_at=now, + ) + + def request_attributes(scan_id=None): + params = {"filter[compliance_id]": "csa_ccm_4.0"} + if scan_id is not None: + params["filter[scan_id]"] = str(scan_id) + return authenticated_client.get( + reverse("complianceoverview-attributes"), params + ) + + def collect_check_ids(scan_id=None): + response = request_attributes(scan_id) + assert response.status_code == status.HTTP_200_OK + check_ids = set() + for item in response.json()["data"]: + check_ids.update(item["attributes"]["attributes"]["check_ids"]) + return check_ids + + gcp_check_ids = collect_check_ids(gcp_scan.id) + azure_check_ids = collect_check_ids(azure_scan.id) + + # Each scan resolves to its own provider's checks, and they differ. + assert gcp_check_ids + assert azure_check_ids + assert gcp_check_ids != azure_check_ids + + # The returned check IDs belong to the SDK's per-provider definition. + from api.compliance import get_prowler_provider_compliance + + def expected_check_ids(provider_type): + framework = get_prowler_provider_compliance(provider_type)["csa_ccm_4.0"] + expected = set() + for requirement in framework.requirements: + expected.update(requirement.checks.get(provider_type, [])) + return expected + + assert gcp_check_ids <= expected_check_ids(Provider.ProviderChoices.GCP.value) + assert azure_check_ids <= expected_check_ids( + Provider.ProviderChoices.AZURE.value + ) + + # An explicit scan_id is authoritative: a non-existent scan must fail + # closed with 404 instead of silently falling back to another provider. + missing_response = request_attributes("00000000-0000-0000-0000-000000000000") + assert missing_response.status_code == status.HTTP_404_NOT_FOUND + + # A malformed scan_id is rejected with 404 as well. + malformed_response = request_attributes("not-a-uuid") + assert malformed_response.status_code == status.HTTP_404_NOT_FOUND + + # An empty value (filter[scan_id]=) must not fall back to the legacy + # provider picker: the explicit (if blank) selector fails closed. + empty_response = request_attributes("") + assert empty_response.status_code == status.HTTP_404_NOT_FOUND + + # A scan belonging to another tenant is not visible (RLS), so it must + # return 404 rather than leaking the fallback provider's check IDs. + other_tenant = Tenant.objects.create(name="Other Compliance Tenant") + foreign_provider = Provider.objects.create( + provider="gcp", + uid="foreign-gcp-test", + alias="foreign_gcp", + tenant_id=other_tenant.id, + ) + foreign_scan = Scan.objects.create( + name="foreign scan", + provider=foreign_provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=other_tenant.id, + started_at=now, + completed_at=now, + ) + foreign_response = request_attributes(foreign_scan.id) + assert foreign_response.status_code == status.HTTP_404_NOT_FOUND + + def test_compliance_overview_attributes_scan_scoped_by_provider_group( + self, + authenticated_client_no_permissions_rbac, + providers_fixture, + ): + # A user with limited visibility (no UNLIMITED_VISIBILITY) must only be + # able to resolve scans for providers in its provider groups. Tenant RLS + # alone is not enough here: both scans belong to the same tenant, so the + # endpoint has to scope the scan lookup by provider group, otherwise a + # restricted user could read another provider's compliance metadata. + client = authenticated_client_no_permissions_rbac + limited_user = client.user + membership = Membership.objects.filter(user=limited_user).first() + tenant = membership.tenant + + allowed_provider = providers_fixture[2] + denied_provider = providers_fixture[4] + assert allowed_provider.provider == Provider.ProviderChoices.GCP.value + assert denied_provider.provider == Provider.ProviderChoices.AZURE.value + + provider_group = ProviderGroup.objects.create( + name="limited-compliance-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, + ) + + now = datetime.now(timezone.utc) + allowed_scan = Scan.objects.create( + name="allowed scan", + provider=allowed_provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant.id, + started_at=now, + completed_at=now, + ) + denied_scan = Scan.objects.create( + name="denied scan", + provider=denied_provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant.id, + started_at=now, + completed_at=now, + ) + + def request_attributes(scan_id): + return client.get( + reverse("complianceoverview-attributes"), + { + "filter[compliance_id]": "csa_ccm_4.0", + "filter[scan_id]": str(scan_id), + }, + ) + + # The scan in the user's provider group resolves normally. + assert request_attributes(allowed_scan.id).status_code == status.HTTP_200_OK + + # The scan outside the user's provider group is invisible, so it fails + # closed with 404 instead of leaking the other provider's check IDs. + assert ( + request_attributes(denied_scan.id).status_code == status.HTTP_404_NOT_FOUND + ) + def test_compliance_overview_attributes_missing_compliance_id( self, authenticated_client ): @@ -9353,6 +10297,39 @@ class TestComplianceOverviewViewSet: ) assert response.status_code == status.HTTP_400_BAD_REQUEST + def test_compliance_overview_attributes_503_while_warming( + self, authenticated_client + ): + from api.compliance import COMPLIANCE_WARMED, COMPLIANCE_WARMING_STARTED + + COMPLIANCE_WARMING_STARTED.set() + COMPLIANCE_WARMED.clear() + try: + response = authenticated_client.get( + reverse("complianceoverview-attributes"), + {"filter[compliance_id]": "aws_account_security_onboarding_aws"}, + ) + finally: + COMPLIANCE_WARMING_STARTED.clear() + + assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE + assert response.json()["errors"][0]["code"] == "compliance_warming" + + def test_compliance_overview_attributes_serves_when_warming_not_started( + self, authenticated_client + ): + # Dev fallback: under runserver warming never runs, so the guard must + # not refuse — the endpoint lazily loads and serves as before. + from api.compliance import COMPLIANCE_WARMED, COMPLIANCE_WARMING_STARTED + + COMPLIANCE_WARMING_STARTED.clear() + COMPLIANCE_WARMED.clear() + response = authenticated_client.get( + reverse("complianceoverview-attributes"), + {"filter[compliance_id]": "aws_account_security_onboarding_aws"}, + ) + assert response.status_code == status.HTTP_200_OK + def test_compliance_overview_task_management_integration( self, authenticated_client, compliance_requirements_overviews_fixture ): @@ -9591,6 +10568,40 @@ class TestOverviewViewSet: for entry in grouped_data: assert "findings" not in entry["attributes"] + def test_overview_providers_count_applies_limited_visibility( + self, + authenticated_client_no_permissions_rbac, + providers_fixture, + provider_groups_fixture, + tenants_fixture, + ): + tenant = tenants_fixture[0] + client = authenticated_client_no_permissions_rbac + allowed_provider = providers_fixture[2] + denied_provider = providers_fixture[4] + provider_group = provider_groups_fixture[0] + + ProviderGroupMembership.objects.create( + tenant_id=tenant.id, + provider_group=provider_group, + provider=allowed_provider, + ) + RoleProviderGroupRelationship.objects.create( + tenant_id=tenant.id, + role=client.user.roles.first(), + provider_group=provider_group, + ) + + response = client.get(reverse("overview-providers-count")) + + assert response.status_code == status.HTTP_200_OK + aggregated = { + entry["id"]: entry["attributes"]["count"] + for entry in response.json()["data"] + } + assert aggregated == {allowed_provider.provider: 1} + assert denied_provider.provider not in aggregated + def _create_scan(self, tenant, provider, name, started_at=None): scan_started = started_at or datetime.now(timezone.utc) - timedelta(hours=1) return Scan.objects.create( @@ -10138,6 +11149,87 @@ class TestOverviewViewSet: assert combined_attributes["muted"] == 3 assert combined_attributes["total"] == 14 + def test_overview_findings_provider_groups_filter( + self, + authenticated_client, + tenants_fixture, + providers_fixture, + provider_groups_fixture, + ): + tenant = tenants_fixture[0] + provider1, provider2, *_ = providers_fixture + group1, group2, *_ = provider_groups_fixture + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group1 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group2 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider2, provider_group=group2 + ) + + scan1 = Scan.objects.create( + name="scan-provider-group-one", + provider=provider1, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + ) + scan2 = Scan.objects.create( + name="scan-provider-group-two", + provider=provider2, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + ) + ScanSummary.objects.create( + tenant=tenant, + scan=scan1, + check_id="check-provider-group-one", + service="service-a", + severity="high", + region="region-a", + _pass=5, + fail=1, + muted=2, + total=8, + ) + ScanSummary.objects.create( + tenant=tenant, + scan=scan2, + check_id="check-provider-group-two", + service="service-b", + severity="medium", + region="region-b", + _pass=2, + fail=3, + muted=1, + total=6, + ) + + response = authenticated_client.get( + reverse("overview-findings"), + {"filter[provider_groups]": str(group1.id)}, + ) + assert response.status_code == status.HTTP_200_OK + attributes = response.json()["data"]["attributes"] + assert attributes["pass"] == 5 + assert attributes["fail"] == 1 + assert attributes["muted"] == 2 + assert attributes["total"] == 8 + + response = authenticated_client.get( + reverse("overview-findings"), + {"filter[provider_groups__in]": f"{group1.id},{group2.id}"}, + ) + assert response.status_code == status.HTTP_200_OK + attributes = response.json()["data"]["attributes"] + assert attributes["pass"] == 7 + assert attributes["fail"] == 4 + assert attributes["muted"] == 3 + assert attributes["total"] == 14 + def test_overview_findings_severity_provider_id_in_filter( self, authenticated_client, tenants_fixture, providers_fixture ): @@ -10906,9 +11998,21 @@ class TestOverviewViewSet: @pytest.mark.parametrize( "filter_key,filter_value_fn,expected_total,expected_failed", [ - ("filter[provider_id]", lambda p1, _: str(p1.id), 10, 5), + ("filter[provider_id]", lambda p1, *_: str(p1.id), 10, 5), ("filter[provider_type]", lambda *_: "aws", 10, 5), ("filter[provider_type__in]", lambda *_: "aws,gcp", 30, 20), + ( + "filter[provider_groups]", + lambda p1, _, group1, __: str(group1.id), + 10, + 5, + ), + ( + "filter[provider_groups__in]", + lambda p1, _, group1, group2: f"{group1.id},{group2.id}", + 30, + 20, + ), ], ) def test_overview_categories_filters( @@ -10916,6 +12020,7 @@ class TestOverviewViewSet: authenticated_client, tenants_fixture, providers_fixture, + provider_groups_fixture, create_scan_category_summary, filter_key, filter_value_fn, @@ -10924,6 +12029,16 @@ class TestOverviewViewSet: ): tenant = tenants_fixture[0] provider1, _, gcp_provider, *_ = providers_fixture + group1, group2, *_ = provider_groups_fixture + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group1 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group2 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=gcp_provider, provider_group=group2 + ) scan1 = Scan.objects.create( name="categories-scan-1", @@ -10949,7 +12064,7 @@ class TestOverviewViewSet: response = authenticated_client.get( reverse("overview-categories"), - {filter_key: filter_value_fn(provider1, gcp_provider)}, + {filter_key: filter_value_fn(provider1, gcp_provider, group1, group2)}, ) assert response.status_code == status.HTTP_200_OK data = response.json()["data"] @@ -11123,10 +12238,22 @@ class TestOverviewViewSet: @pytest.mark.parametrize( "filter_key,filter_value_fn,expected_total,expected_failed", [ - ("filter[provider_id]", lambda p1, p2: str(p1.id), 10, 5), - ("filter[provider_id__in]", lambda p1, p2: f"{p1.id},{p2.id}", 25, 12), - ("filter[provider_type]", lambda p1, p2: "aws", 10, 5), - ("filter[provider_type__in]", lambda p1, p2: "aws,gcp", 25, 12), + ("filter[provider_id]", lambda p1, *_: str(p1.id), 10, 5), + ("filter[provider_id__in]", lambda p1, p2, *_: f"{p1.id},{p2.id}", 25, 12), + ("filter[provider_type]", lambda *_: "aws", 10, 5), + ("filter[provider_type__in]", lambda *_: "aws,gcp", 25, 12), + ( + "filter[provider_groups]", + lambda p1, p2, group1, group2: str(group1.id), + 10, + 5, + ), + ( + "filter[provider_groups__in]", + lambda p1, p2, group1, group2: f"{group1.id},{group2.id}", + 25, + 12, + ), ], ) def test_overview_groups_provider_filters( @@ -11134,6 +12261,7 @@ class TestOverviewViewSet: authenticated_client, tenants_fixture, providers_fixture, + provider_groups_fixture, create_scan_resource_group_summary, filter_key, filter_value_fn, @@ -11143,6 +12271,16 @@ class TestOverviewViewSet: tenant = tenants_fixture[0] provider1 = providers_fixture[0] # AWS gcp_provider = providers_fixture[2] # GCP + group1, group2, *_ = provider_groups_fixture + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group1 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group2 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=gcp_provider, provider_group=group2 + ) scan1 = Scan.objects.create( name="aws-rg-scan", @@ -11168,7 +12306,7 @@ class TestOverviewViewSet: response = authenticated_client.get( reverse("overview-resource-groups"), - {filter_key: filter_value_fn(provider1, gcp_provider)}, + {filter_key: filter_value_fn(provider1, gcp_provider, group1, group2)}, ) assert response.status_code == status.HTTP_200_OK data = response.json()["data"] @@ -11343,6 +12481,49 @@ class TestOverviewViewSet: data = response.json()["data"] assert len(data) >= 1 + def test_compliance_watchlist_provider_groups_filter( + self, + authenticated_client, + provider_compliance_scores_fixture, + providers_fixture, + provider_groups_fixture, + tenants_fixture, + ): + tenant = tenants_fixture[0] + provider1, provider2, *_ = providers_fixture + group1, group2, *_ = provider_groups_fixture + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group1 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group2 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider2, provider_group=group2 + ) + + response = authenticated_client.get( + reverse("overview-compliance-watchlist"), + {"filter[provider_groups]": str(group1.id)}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + by_id = {item["id"]: item["attributes"] for item in data} + assert by_id["aws_cis_2.0"]["requirements_passed"] == 1 + assert by_id["aws_cis_2.0"]["requirements_failed"] == 1 + assert by_id["aws_cis_2.0"]["requirements_manual"] == 1 + + response = authenticated_client.get( + reverse("overview-compliance-watchlist"), + {"filter[provider_groups__in]": f"{group1.id},{group2.id}"}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + by_id = {item["id"]: item["attributes"] for item in data} + assert by_id["aws_cis_2.0"]["requirements_passed"] == 0 + assert by_id["aws_cis_2.0"]["requirements_failed"] == 2 + assert by_id["aws_cis_2.0"]["requirements_manual"] == 1 + def test_compliance_watchlist_empty_result(self, authenticated_client): response = authenticated_client.get(reverse("overview-compliance-watchlist")) assert response.status_code == status.HTTP_200_OK @@ -12400,7 +13581,7 @@ class TestTenantFinishACSView: "firstName": ["John"], "lastName": ["Doe"], "organization": ["testing_company"], - "userType": ["no_permissions"], + "userType": ["platform_team"], }, ) @@ -12455,12 +13636,21 @@ class TestTenantFinishACSView: assert user.name == "John Doe" assert user.company_name == "testing_company" - role = Role.objects.using(MainRouter.admin_db).get(name="no_permissions") + role = Role.objects.using(MainRouter.admin_db).get( + name="platform_team", tenant=tenants_fixture[0] + ) assert role.tenant == tenants_fixture[0] + assert not role.manage_users + assert not role.manage_account + assert not role.manage_billing + assert not role.manage_providers + assert not role.manage_integrations + assert not role.manage_scans + assert role.unlimited_visibility assert ( UserRoleRelationship.objects.using(MainRouter.admin_db) - .filter(user=user, tenant_id=tenants_fixture[0].id) + .filter(user=user, role=role, tenant_id=tenants_fixture[0].id) .exists() ) @@ -12513,7 +13703,7 @@ class TestTenantFinishACSView: assert response.status_code == 302 assert "sso_saml_failed=true" in response.url - def test_dispatch_skips_role_mapping_when_single_manage_account_user( + def test_dispatch_keeps_existing_roles_when_usertype_missing( self, create_test_user, tenants_fixture, @@ -12522,7 +13712,7 @@ class TestTenantFinishACSView: settings, monkeypatch, ): - """Test that role mapping is skipped when tenant has only one user with MANAGE_ACCOUNT role""" + """Test that roles are left untouched when the IdP does not send userType""" monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete") user = create_test_user tenant = tenants_fixture[0] @@ -12531,6 +13721,7 @@ class TestTenantFinishACSView: UserRoleRelationship.objects.using(MainRouter.admin_db).create( user=user, role=admin_role, tenant_id=tenant.id ) + roles_before = Role.objects.using(MainRouter.admin_db).count() social_account = SocialAccount( user=user, @@ -12539,7 +13730,6 @@ class TestTenantFinishACSView: "firstName": ["John"], "lastName": ["Doe"], "organization": ["testing_company"], - "userType": ["no_permissions"], # This should be ignored }, ) @@ -12577,24 +13767,162 @@ class TestTenantFinishACSView: assert response.status_code == 302 - # Verify the admin role is still assigned (not changed to no_permissions) + # Verify the existing role assignment was not modified assert ( UserRoleRelationship.objects.using(MainRouter.admin_db) .filter(user=user, role=admin_role, tenant_id=tenant.id) .exists() ) - # Verify no_permissions role was NOT created in the database - assert ( - not Role.objects.using(MainRouter.admin_db) - .filter(name="no_permissions", tenant=tenant) + # Verify no new role was created + assert Role.objects.using(MainRouter.admin_db).count() == roles_before + + def test_dispatch_assigns_no_role_to_new_user_when_usertype_missing( + self, + create_test_user, + tenants_fixture, + saml_setup, + settings, + monkeypatch, + ): + """Test that a user without roles gets none assigned when userType is missing""" + monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete") + user = create_test_user + tenant = tenants_fixture[0] + roles_before = Role.objects.using(MainRouter.admin_db).count() + + social_account = SocialAccount( + user=user, + provider="saml", + extra_data={ + "firstName": ["John"], + "lastName": ["Doe"], + "organization": ["testing_company"], + }, + ) + + request = RequestFactory().get( + reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"}) + ) + request.user = user + request.session = {} + + with ( + patch( + "allauth.socialaccount.providers.saml.views.get_app_or_404" + ) as mock_get_app_or_404, + patch( + "allauth.socialaccount.models.SocialApp.objects.get" + ) as mock_socialapp_get, + patch( + "allauth.socialaccount.models.SocialAccount.objects.get" + ) as mock_sa_get, + patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get, + patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get, + patch("api.models.User.objects.get") as mock_user_get, + ): + mock_get_app_or_404.return_value = MagicMock( + provider="saml", client_id="testtenant", name="Test App", settings={} + ) + mock_sa_get.return_value = social_account + mock_socialapp_get.return_value = MagicMock(provider_id="saml") + mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id) + mock_saml_config_get.return_value = MagicMock() + mock_user_get.return_value = user + + view = TenantFinishACSView.as_view() + response = view(request, organization_slug="testtenant") + + assert response.status_code == 302 + + # Verify no role was created or assigned + assert Role.objects.using(MainRouter.admin_db).count() == roles_before + assert not ( + UserRoleRelationship.objects.using(MainRouter.admin_db) + .filter(user=user, tenant_id=tenant.id) .exists() ) - # Verify no_permissions role was NOT assigned to the user - assert not ( + # Membership is still created so the user belongs to the tenant + assert ( + Membership.objects.using(MainRouter.admin_db) + .filter(user=user, tenant=tenant) + .exists() + ) + + def test_dispatch_skips_role_mapping_when_last_manage_account_user_maps_to_new_role( + self, + create_test_user, + tenants_fixture, + admin_role_fixture, + saml_setup, + settings, + monkeypatch, + ): + """Test that a new read-only role is neither created nor assigned if it would remove the last MANAGE_ACCOUNT user""" + monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete") + user = create_test_user + tenant = tenants_fixture[0] + + admin_role = admin_role_fixture + UserRoleRelationship.objects.using(MainRouter.admin_db).create( + user=user, role=admin_role, tenant_id=tenant.id + ) + + social_account = SocialAccount( + user=user, + provider="saml", + extra_data={ + "firstName": ["John"], + "lastName": ["Doe"], + "organization": ["testing_company"], + "userType": ["brand_new_role"], + }, + ) + + request = RequestFactory().get( + reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"}) + ) + request.user = user + request.session = {} + + with ( + patch( + "allauth.socialaccount.providers.saml.views.get_app_or_404" + ) as mock_get_app_or_404, + patch( + "allauth.socialaccount.models.SocialApp.objects.get" + ) as mock_socialapp_get, + patch( + "allauth.socialaccount.models.SocialAccount.objects.get" + ) as mock_sa_get, + patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get, + patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get, + patch("api.models.User.objects.get") as mock_user_get, + ): + mock_get_app_or_404.return_value = MagicMock( + provider="saml", client_id="testtenant", name="Test App", settings={} + ) + mock_sa_get.return_value = social_account + mock_socialapp_get.return_value = MagicMock(provider_id="saml") + mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id) + mock_saml_config_get.return_value = MagicMock() + mock_user_get.return_value = user + + view = TenantFinishACSView.as_view() + response = view(request, organization_slug="testtenant") + + assert response.status_code == 302 + + # The admin role is still assigned and the new role was not created + assert ( UserRoleRelationship.objects.using(MainRouter.admin_db) - .filter(user=user, role__name="no_permissions", tenant_id=tenant.id) + .filter(user=user, role=admin_role, tenant_id=tenant.id) + .exists() + ) + assert ( + not Role.objects.using(MainRouter.admin_db) + .filter(name="brand_new_role", tenant=tenant) .exists() ) @@ -16495,6 +17823,44 @@ class TestFindingGroupViewSet: # All fixture findings are from AWS provider assert len(response.json()["data"]) == 5 + def test_finding_groups_provider_groups_filter( + self, + authenticated_client, + tenants_fixture, + finding_groups_fixture, + providers_fixture, + provider_groups_fixture, + ): + tenant = tenants_fixture[0] + provider1, provider2, *_ = providers_fixture + group1, group2, *_ = provider_groups_fixture + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group1 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group2 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider2, provider_group=group2 + ) + + response = authenticated_client.get( + reverse("finding-group-list"), + {"filter[inserted_at]": TODAY, "filter[provider_groups]": str(group1.id)}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 4 + + response = authenticated_client.get( + reverse("finding-group-list"), + { + "filter[inserted_at]": TODAY, + "filter[provider_groups__in]": f"{group1.id},{group2.id}", + }, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 5 + def test_finding_groups_check_id_filter( self, authenticated_client, finding_groups_fixture ): @@ -17405,6 +18771,41 @@ class TestFindingGroupViewSet: # All providers in fixture are AWS assert len(data) == 5 + def test_finding_groups_latest_provider_groups_filter( + self, + authenticated_client, + tenants_fixture, + finding_groups_fixture, + providers_fixture, + provider_groups_fixture, + ): + tenant = tenants_fixture[0] + provider1, provider2, *_ = providers_fixture + group1, group2, *_ = provider_groups_fixture + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group1 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group2 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider2, provider_group=group2 + ) + + response = authenticated_client.get( + reverse("finding-group-latest"), + {"filter[provider_groups]": str(group1.id)}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 4 + + response = authenticated_client.get( + reverse("finding-group-latest"), + {"filter[provider_groups__in]": f"{group1.id},{group2.id}"}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 5 + def test_finding_groups_latest_check_id_filter( self, authenticated_client, finding_groups_fixture ): diff --git a/api/src/backend/api/utils.py b/api/src/backend/api/utils.py index 8da8d91226..678ed24772 100644 --- a/api/src/backend/api/utils.py +++ b/api/src/backend/api/utils.py @@ -243,6 +243,12 @@ def get_prowler_provider_kwargs( **prowler_provider_kwargs, "filter_accounts": [provider.uid], } + elif provider.provider == Provider.ProviderChoices.ORACLECLOUD.value: + if isinstance(prowler_provider_kwargs.get("region"), str): + prowler_provider_kwargs = { + **prowler_provider_kwargs, + "region": {prowler_provider_kwargs["region"]}, + } elif provider.provider == Provider.ProviderChoices.OPENSTACK.value: # clouds_yaml_content, clouds_yaml_cloud and provider_id are validated # in the provider itself, so it's not needed here. diff --git a/api/src/backend/api/v1/mixins.py b/api/src/backend/api/v1/mixins.py index e1a5d3470f..849c788366 100644 --- a/api/src/backend/api/v1/mixins.py +++ b/api/src/backend/api/v1/mixins.py @@ -1,6 +1,10 @@ +import uuid + +from django.http import QueryDict from django.urls import reverse from django_celery_results.models import TaskResult from rest_framework import status +from rest_framework.exceptions import ValidationError from rest_framework.response import Response from api.exceptions import ( @@ -8,7 +12,7 @@ from api.exceptions import ( TaskInProgressException, TaskNotFoundException, ) -from api.models import StateChoices, Task +from api.models import Provider, StateChoices, Task from api.v1.serializers import TaskSerializer @@ -74,6 +78,162 @@ class PaginateByPkMixin: return self.get_paginated_response(serialized) +class JsonApiFilterMixin: + """Shared helpers for manually applying django-filter to JSON:API params.""" + + jsonapi_filter_replace_dots = False + + def _normalize_jsonapi_params( + self, + query_params, + exclude_keys=None, + replace_dots=None, + ): + exclude_keys = exclude_keys or set() + if replace_dots is None: + replace_dots = self.jsonapi_filter_replace_dots + + normalized = QueryDict(mutable=True) + for key, values in query_params.lists(): + normalized_key = ( + key[7:-1] if key.startswith("filter[") and key.endswith("]") else key + ) + if replace_dots: + normalized_key = normalized_key.replace(".", "__") + if normalized_key not in exclude_keys: + normalized.setlist(normalized_key, values) + return normalized + + def _apply_filterset( + self, + queryset, + filterset_class, + exclude_keys=None, + replace_dots=None, + ): + normalized_params = self._normalize_jsonapi_params( + self.request.query_params, + exclude_keys=set(exclude_keys or []), + replace_dots=replace_dots, + ) + filterset = filterset_class(normalized_params, queryset=queryset) + if not filterset.is_valid(): + raise ValidationError(filterset.errors) + return filterset.qs + + +class ProviderFilterParamsMixin(JsonApiFilterMixin): + """Shared extraction of provider filters from JSON:API query params.""" + + PROVIDER_FILTER_KEYS = frozenset( + { + "provider_id", + "provider_id__in", + "provider_type", + "provider_type__in", + "provider_groups", + "provider_groups__in", + } + ) + PROVIDER_FILTER_DOT_ALIAS_KEYS = frozenset( + { + "provider_id.in", + "provider_type.in", + "provider_groups.in", + } + ) + PROVIDER_FILTER_QUERY_KEYS = PROVIDER_FILTER_KEYS | PROVIDER_FILTER_DOT_ALIAS_KEYS + + def _csv_filter_values(self, value): + return [item.strip() for item in value.split(",") if item.strip()] + + def _validate_uuid_filter_values(self, field_name, values): + try: + for value in values: + uuid.UUID(str(value)) + except (TypeError, ValueError, AttributeError): + raise ValidationError({field_name: ["Enter a valid UUID."]}) + + def _has_provider_filters(self, include_dot_aliases=False): + provider_filter_keys = ( + self.PROVIDER_FILTER_QUERY_KEYS + if include_dot_aliases + else self.PROVIDER_FILTER_KEYS + ) + return any( + self.request.query_params.get(f"filter[{key}]") + for key in provider_filter_keys + ) + + def _extract_provider_filters_from_params( + self, + *, + validate_uuids=False, + include_dot_aliases=False, + ): + params = self.request.query_params + filters = {} + valid_provider_types = { + choice[0] for choice in Provider.ProviderChoices.choices + } + + provider_id = params.get("filter[provider_id]") + if provider_id: + if validate_uuids: + self._validate_uuid_filter_values("provider_id", [provider_id]) + filters["provider_id"] = provider_id + + provider_id_in = params.get("filter[provider_id__in]") + if include_dot_aliases: + provider_id_in = provider_id_in or params.get("filter[provider_id.in]") + if provider_id_in: + values = self._csv_filter_values(provider_id_in) + if validate_uuids: + self._validate_uuid_filter_values("provider_id__in", values) + filters["provider_id__in"] = values + + provider_type = params.get("filter[provider_type]") + if provider_type: + if provider_type not in valid_provider_types: + raise ValidationError( + {"provider_type": f"Invalid choice: {provider_type}"} + ) + filters["provider__provider"] = provider_type + + provider_type_in = params.get("filter[provider_type__in]") + if include_dot_aliases: + provider_type_in = provider_type_in or params.get( + "filter[provider_type.in]" + ) + if provider_type_in: + values = self._csv_filter_values(provider_type_in) + invalid = [value for value in values if value not in valid_provider_types] + if invalid: + raise ValidationError( + {"provider_type__in": f"Invalid choices: {', '.join(invalid)}"} + ) + filters["provider__provider__in"] = values + + provider_groups = params.get("filter[provider_groups]") + if provider_groups: + if validate_uuids: + self._validate_uuid_filter_values("provider_groups", [provider_groups]) + filters["provider__provider_groups__id"] = provider_groups + + provider_groups_in = params.get("filter[provider_groups__in]") + if include_dot_aliases: + provider_groups_in = provider_groups_in or params.get( + "filter[provider_groups.in]" + ) + if provider_groups_in: + values = self._csv_filter_values(provider_groups_in) + if validate_uuids: + self._validate_uuid_filter_values("provider_groups__in", values) + filters["provider__provider_groups__id__in"] = values + + return filters + + class TaskManagementMixin: """ Mixin to manage task status checking. diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 067a0403a3..4cf39cdce1 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -30,6 +30,7 @@ from dj_rest_auth.registration.views import SocialLoginView from django.conf import settings as django_settings from django.contrib.postgres.aggregates import ArrayAgg, BoolAnd, StringAgg from django.contrib.postgres.search import SearchQuery +from django.core.exceptions import ValidationError as DjangoValidationError from django.db import transaction from django.db.models import ( BooleanField, @@ -114,13 +115,17 @@ from api.attack_paths import get_queries_for_provider, get_query_by_id from api.attack_paths import views_helpers as attack_paths_views_helpers from api.base_views import BaseRLSViewSet, BaseTenantViewset, BaseUserViewset from api.compliance import ( + COMPLIANCE_WARMED, + COMPLIANCE_WARMING_STARTED, PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE, get_compliance_frameworks, + get_prowler_provider_compliance, ) from api.constants import SEVERITY_ORDER from api.db_router import MainRouter from api.db_utils import rls_transaction from api.exceptions import ( + ComplianceWarmingError, TaskFailedException, UpstreamAccessDeniedError, UpstreamAuthenticationError, @@ -223,7 +228,13 @@ from api.utils import ( validate_invitation, ) from api.uuid_utils import datetime_to_uuid7, uuid7_start -from api.v1.mixins import DisablePaginationMixin, PaginateByPkMixin, TaskManagementMixin +from api.v1.mixins import ( + DisablePaginationMixin, + JsonApiFilterMixin, + PaginateByPkMixin, + ProviderFilterParamsMixin, + TaskManagementMixin, +) from api.v1.serializers import ( AttackPathsCartographySchemaSerializer, AttackPathsCustomQueryRunRequestSerializer, @@ -796,60 +807,70 @@ class TenantFinishACSView(FinishACSView): .tenant ) + # Only remap roles when the IdP provides a userType attribute. + # Without it, the user's current roles are left untouched. role_name = ( - extra.get("userType", ["no_permissions"])[0].strip() - if extra.get("userType") - else "no_permissions" + extra.get("userType", [""])[0].strip() if extra.get("userType") else "" ) - role = ( - Role.objects.using(MainRouter.admin_db) - .filter(name=role_name, tenant=tenant) - .first() - ) - - # Only skip mapping if it would remove the last MANAGE_ACCOUNT user - remaining_manage_account_users = ( - UserRoleRelationship.objects.using(MainRouter.admin_db) - .filter(role__manage_account=True, tenant_id=tenant.id) - .exclude(user_id=user_id) - .values("user") - .distinct() - .count() - ) - user_has_manage_account = ( - UserRoleRelationship.objects.using(MainRouter.admin_db) - .filter(role__manage_account=True, tenant_id=tenant.id, user_id=user_id) - .exists() - ) - role_manage_account = role.manage_account if role else False - would_remove_last_manage_account = ( - user_has_manage_account - and remaining_manage_account_users == 0 - and not role_manage_account - ) - - if not would_remove_last_manage_account: - if role is None: - role = Role.objects.using(MainRouter.admin_db).create( - name=role_name, - tenant=tenant, - manage_users=False, - manage_account=False, - manage_billing=False, - manage_providers=False, - manage_integrations=False, - manage_scans=False, - unlimited_visibility=False, + if role_name: + with transaction.atomic(using=MainRouter.admin_db): + role = ( + Role.objects.using(MainRouter.admin_db) + .filter(name=role_name, tenant=tenant) + .first() ) - UserRoleRelationship.objects.using(MainRouter.admin_db).filter( - user=user, - tenant_id=tenant.id, - ).delete() - UserRoleRelationship.objects.using(MainRouter.admin_db).create( - user=user, - role=role, - tenant_id=tenant.id, - ) + + # Only skip mapping if it would remove the last MANAGE_ACCOUNT user + remaining_manage_account_users = ( + UserRoleRelationship.objects.using(MainRouter.admin_db) + .filter(role__manage_account=True, tenant_id=tenant.id) + .exclude(user_id=user_id) + .values("user") + .distinct() + .count() + ) + user_has_manage_account = ( + UserRoleRelationship.objects.using(MainRouter.admin_db) + .filter( + role__manage_account=True, + tenant_id=tenant.id, + user_id=user_id, + ) + .exists() + ) + role_manage_account = role.manage_account if role else False + would_remove_last_manage_account = ( + user_has_manage_account + and remaining_manage_account_users == 0 + and not role_manage_account + ) + + if not would_remove_last_manage_account: + if role is None: + # Roles auto-created from userType get read-only access: + # visibility over all providers, no management permissions + role, _ = Role.objects.using(MainRouter.admin_db).get_or_create( + name=role_name, + tenant=tenant, + defaults={ + "manage_users": False, + "manage_account": False, + "manage_billing": False, + "manage_providers": False, + "manage_integrations": False, + "manage_scans": False, + "unlimited_visibility": True, + }, + ) + UserRoleRelationship.objects.using(MainRouter.admin_db).filter( + user=user, + tenant_id=tenant.id, + ).delete() + UserRoleRelationship.objects.using(MainRouter.admin_db).create( + user=user, + role=role, + tenant_id=tenant.id, + ) membership, _ = Membership.objects.using(MainRouter.admin_db).get_or_create( user=user, tenant=tenant, @@ -1849,7 +1870,42 @@ class ProviderViewSet(DisablePaginationMixin, BaseRLSViewSet): 200: OpenApiResponse( description="CSV file containing the compliance report" ), - 404: OpenApiResponse(description="Compliance report not found"), + 202: OpenApiResponse(description="The task is in progress"), + 403: OpenApiResponse(description="There is a problem with credentials"), + 404: OpenApiResponse( + description="Compliance report not found, or the scan has no reports yet" + ), + }, + request=None, + ), + compliance_ocsf=extend_schema( + tags=["Scan"], + summary="Retrieve compliance report as OCSF JSON", + description=( + "Download a specific compliance report as an OCSF JSON file. " + "Only universal frameworks that declare an output configuration " + "produce this artifact (currently 'dora_2022_2554' and 'csa_ccm_4.0'); any " + "other framework returns 404." + ), + parameters=[ + OpenApiParameter( + name="name", + type=str, + location=OpenApiParameter.PATH, + required=True, + description="The compliance report name, like 'dora_2022_2554'", + ), + ], + responses={ + 200: OpenApiResponse( + description="OCSF JSON file containing the compliance report" + ), + 202: OpenApiResponse(description="The task is in progress"), + 403: OpenApiResponse(description="There is a problem with credentials"), + 404: OpenApiResponse( + description="Compliance report not found, the framework does " + "not provide an OCSF export, or the scan has no reports yet" + ), }, request=None, ), @@ -1992,35 +2048,23 @@ class ScanViewSet(BaseRLSViewSet): return queryset.select_related("provider", "task") def get_serializer_class(self): - if self.action == "create": - if hasattr(self, "response_serializer_class"): - return self.response_serializer_class - return ScanCreateSerializer - elif self.action == "partial_update": + if self.action == "partial_update": return ScanUpdateSerializer - elif self.action == "report": - if hasattr(self, "response_serializer_class"): - return self.response_serializer_class - return ScanReportSerializer - elif self.action == "compliance": - if hasattr(self, "response_serializer_class"): - return self.response_serializer_class - return ScanComplianceReportSerializer - elif self.action == "threatscore": - if hasattr(self, "response_serializer_class"): - return self.response_serializer_class - elif self.action == "ens": - if hasattr(self, "response_serializer_class"): - return self.response_serializer_class - elif self.action == "nis2": - if hasattr(self, "response_serializer_class"): - return self.response_serializer_class - elif self.action == "csa": - if hasattr(self, "response_serializer_class"): - return self.response_serializer_class - elif self.action == "cis": + + action_defaults = { + "create": ScanCreateSerializer, + "report": ScanReportSerializer, + "compliance": ScanComplianceReportSerializer, + "compliance_ocsf": ScanComplianceReportSerializer, + } + response_only_actions = {"threatscore", "ens", "nis2", "csa", "cis"} + + if self.action in action_defaults or self.action in response_only_actions: if hasattr(self, "response_serializer_class"): return self.response_serializer_class + if self.action in action_defaults: + return action_defaults[self.action] + return super().get_serializer_class() def partial_update(self, request, *args, **kwargs): @@ -2059,12 +2103,17 @@ class ScanViewSet(BaseRLSViewSet): if scan_instance.state == StateChoices.EXECUTING and scan_instance.task: task = scan_instance.task else: - try: - task = Task.objects.get( + # A scan can have several `scan-report` tasks (e.g. re-runs); take the + # most recent one. `.first()` also avoids `MultipleObjectsReturned`. + task = ( + Task.objects.filter( task_runner_task__task_name="scan-report", task_runner_task__task_kwargs__contains=str(scan_instance.id), ) - except Task.DoesNotExist: + .order_by("-inserted_at") + .first() + ) + if task is None: return None self.response_serializer_class = TaskSerializer @@ -2139,27 +2188,32 @@ class ScanViewSet(BaseRLSViewSet): status=status.HTTP_502_BAD_GATEWAY, ) contents = resp.get("Contents", []) - keys = [] + matches = [] for obj in contents: key = obj["Key"] key_basename = os.path.basename(key) if any(ch in suffix for ch in ("*", "?", "[")): if fnmatch.fnmatch(key_basename, suffix): - keys.append(key) + matches.append(obj) elif key_basename == suffix: - keys.append(key) + matches.append(obj) elif key.endswith(suffix): # Backward compatibility if suffix already includes directories - keys.append(key) - if not keys: + matches.append(obj) + if not matches: return Response( { "detail": f"No compliance file found for name '{os.path.splitext(suffix)[0]}'." }, status=status.HTTP_404_NOT_FOUND, ) - # path_pattern here is prefix, but in compliance we build correct suffix check before - key = keys[0] + # Return the most recently modified match (latest report) when + # several files share the prefix/suffix. `list_objects_v2` always + # returns `LastModified`; the fallback keeps ordering deterministic + # if it is ever absent. + key = max(matches, key=lambda o: (o.get("LastModified", ""), o["Key"]))[ + "Key" + ] else: # path_pattern is exact key; HEAD before presigning to preserve the 404 contract. key = path_pattern @@ -2209,7 +2263,9 @@ class ScanViewSet(BaseRLSViewSet): }, status=status.HTTP_404_NOT_FOUND, ) - filepath = files[0] + # Return the most recently modified match (latest report) when the + # pattern resolves to several files. + filepath = max(files, key=os.path.getmtime) with open(filepath, "rb") as f: content = f.read() filename = os.path.basename(filepath) @@ -2257,20 +2313,16 @@ class ScanViewSet(BaseRLSViewSet): content, filename = loader return self._serve_file(content, filename, "application/x-zip-compressed") - @action( - detail=True, - methods=["get"], - url_path="compliance/(?P[^/]+)", - url_name="compliance", - ) - def compliance(self, request, pk=None, name=None): - scan = self.get_object() - if name not in get_compliance_frameworks(scan.provider.provider): - return Response( - {"detail": f"Compliance '{name}' not found."}, - status=status.HTTP_404_NOT_FOUND, - ) + def _serve_compliance_artifact(self, scan, name, file_extension, content_type): + """Resolve and serve a per-framework compliance artifact from disk/S3. + Shared by the CSV and OCSF compliance download actions. Both are + path-based (no query params) on purpose: ``get_object`` runs + ``filter_queryset``, which triggers JSON:API's + ``QueryParameterValidationFilter`` and 400s on any non-JSON:API + query param, so a ``?format=`` / ``?type=`` selector is not viable + here — the format is encoded in the route instead. + """ running_resp = self._get_task_status(scan) if running_resp: return running_resp @@ -2287,25 +2339,66 @@ class ScanViewSet(BaseRLSViewSet): bucket = env.str("DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET", "") key_prefix = scan.output_location.removeprefix(f"s3://{bucket}/") prefix = os.path.join( - os.path.dirname(key_prefix), "compliance", f"{name}.csv" + os.path.dirname(key_prefix), "compliance", f"{name}.{file_extension}" ) loader = self._load_file( prefix, s3=True, bucket=bucket, list_objects=True, - content_type="text/csv", + content_type=content_type, ) else: base = os.path.dirname(scan.output_location) - pattern = os.path.join(base, "compliance", f"*_{name}.csv") + pattern = os.path.join(base, "compliance", f"*_{name}.{file_extension}") loader = self._load_file(pattern, s3=False) if isinstance(loader, HttpResponseBase): return loader content, filename = loader - return self._serve_file(content, filename, "text/csv") + return self._serve_file(content, filename, content_type) + + @action( + detail=True, + methods=["get"], + url_path="compliance/(?P[^/]+)", + url_name="compliance", + ) + def compliance(self, request, pk=None, name=None): + scan = self.get_object() + if name not in get_compliance_frameworks(scan.provider.provider): + return Response( + {"detail": f"Compliance '{name}' not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + return self._serve_compliance_artifact(scan, name, "csv", "text/csv") + + @action( + detail=True, + methods=["get"], + url_path="compliance/(?P[^/]+)/ocsf", + url_name="compliance-ocsf", + ) + def compliance_ocsf(self, request, pk=None, name=None): + scan = self.get_object() + if name not in get_compliance_frameworks(scan.provider.provider): + return Response( + {"detail": f"Compliance '{name}' not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + universal_bulk = get_prowler_provider_compliance(scan.provider.provider) + framework_obj = universal_bulk.get(name) + if not (framework_obj and getattr(framework_obj, "outputs", None)): + return Response( + {"detail": f"Compliance '{name}' does not provide an OCSF export."}, + status=status.HTTP_404_NOT_FOUND, + ) + + return self._serve_compliance_artifact( + scan, name, "ocsf.json", "application/json" + ) @action( detail=True, @@ -3749,6 +3842,16 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet): return queryset return super().filter_queryset(queryset) + def _optimize_tags_loading(self, queryset): + """Prefetch resource tags to avoid N+1 queries when serializing findings""" + return queryset.prefetch_related( + Prefetch( + "resources__tags", + queryset=ResourceTag.objects.filter(tenant_id=self.request.tenant_id), + to_attr="prefetched_tags", + ) + ) + def list(self, request, *args, **kwargs): filtered_queryset = self.filter_queryset(self.get_queryset()) return self.paginate_by_pk( @@ -4459,15 +4562,19 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet): @extend_schema_view( list=extend_schema( tags=["Compliance Overview"], - summary="List compliance overviews for a scan", - description="Retrieve an overview of all the compliance in a given scan.", + summary="List compliance overviews", + description=( + "Retrieve compliance overview data for a scan. When provider filters " + "are provided, the endpoint uses the latest completed scan for each " + "matching provider." + ), parameters=[ OpenApiParameter( name="filter[scan_id]", - required=True, + required=False, type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, - description="Related scan ID.", + description="Related scan ID. Required unless a provider filter is provided.", ), ], responses={ @@ -4482,19 +4589,23 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet): description="Compliance overviews generation task failed" ), }, + filters=True, ), metadata=extend_schema( tags=["Compliance Overview"], summary="Retrieve metadata values from compliance overviews", - description="Fetch unique metadata values from a set of compliance overviews. This is useful for dynamic " - "filtering.", + description=( + "Fetch unique metadata values from compliance overviews. This is useful " + "for dynamic filtering. When provider filters are provided, metadata is " + "computed from the latest completed scan for each matching provider." + ), parameters=[ OpenApiParameter( name="filter[scan_id]", - required=True, + required=False, type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, - description="Related scan ID.", + description="Related scan ID. Required unless a provider filter is provided.", ), ], responses={ @@ -4509,19 +4620,24 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet): description="Compliance overviews generation task failed" ), }, + filters=True, ), requirements=extend_schema( tags=["Compliance Overview"], - summary="List compliance requirements overview for a scan", - description="Retrieve a detailed overview of compliance requirements in a given scan, grouped by compliance " - "framework. This endpoint provides requirement-level details and aggregates status across regions.", + summary="List compliance requirements overview", + description=( + "Retrieve a detailed overview of compliance requirements, grouped by " + "compliance framework. This endpoint provides requirement-level details " + "and aggregates status across regions. When provider filters are provided, " + "the endpoint uses the latest completed scan for each matching provider." + ), parameters=[ OpenApiParameter( name="filter[scan_id]", - required=True, + required=False, type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, - description="Related scan ID.", + description="Related scan ID. Required unless a provider filter is provided.", ), OpenApiParameter( name="filter[compliance_id]", @@ -4558,6 +4674,16 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet): location=OpenApiParameter.QUERY, description="Compliance framework ID to get attributes for.", ), + OpenApiParameter( + name="filter[scan_id]", + required=False, + type=OpenApiTypes.UUID, + location=OpenApiParameter.QUERY, + description="Scan ID used to resolve the provider for " + "multi-provider universal frameworks (e.g. CSA CCM), so " + "the returned check IDs match the scan's provider. When omitted, " + "the first provider that declares the framework is used.", + ), ], responses={ 200: OpenApiResponse( @@ -4570,7 +4696,10 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet): @method_decorator(CACHE_DECORATOR, name="list") @method_decorator(CACHE_DECORATOR, name="requirements") @method_decorator(CACHE_DECORATOR, name="attributes") -class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): +class ComplianceOverviewViewSet( + ProviderFilterParamsMixin, BaseRLSViewSet, TaskManagementMixin +): + jsonapi_filter_replace_dots = True pagination_class = ComplianceOverviewPagination queryset = ComplianceRequirementOverview.objects.all() serializer_class = ComplianceOverviewSerializer @@ -4584,28 +4713,22 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): required_permissions = [] def get_queryset(self): + if getattr(self, "swagger_fake_view", False): + return ComplianceRequirementOverview.objects.none() + role = get_role(self.request.user, self.request.tenant_id) unlimited_visibility = getattr( role, Permissions.UNLIMITED_VISIBILITY.value, False ) - if unlimited_visibility: - base_queryset = self.filter_queryset( - ComplianceRequirementOverview.objects.filter( - tenant_id=self.request.tenant_id - ) - ) - else: - providers = Provider.objects.filter( - provider_groups__in=role.provider_groups.all() - ).distinct() - base_queryset = self.filter_queryset( - ComplianceRequirementOverview.objects.filter( - tenant_id=self.request.tenant_id, scan__provider__in=providers - ) - ) + base_queryset = ComplianceRequirementOverview.objects.filter( + tenant_id=self.request.tenant_id + ) - return base_queryset + if unlimited_visibility: + return base_queryset + + return base_queryset.filter(scan__provider__in=get_providers(role)) def get_serializer_class(self): if hasattr(self, "response_serializer_class"): @@ -4643,6 +4766,72 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): return summaries + def _validate_scan_selection(self, scan_id, has_provider_filters): + if scan_id and has_provider_filters: + raise ValidationError( + [ + { + "detail": "Use either filter[scan_id] or provider filters.", + "status": 400, + "source": {"pointer": "filter[scan_id]"}, + "code": "invalid", + } + ] + ) + + if scan_id: + self._validate_uuid_filter_values("scan_id", [scan_id]) + return + + if has_provider_filters: + return + + raise ValidationError( + [ + { + "detail": "This query parameter is required unless a provider filter is provided.", + "status": 400, + "source": {"pointer": "filter[scan_id]"}, + "code": "required", + } + ] + ) + + def _latest_scan_ids_for_provider_filters(self): + role = get_role(self.request.user, self.request.tenant_id) + scans = Scan.all_objects.filter( + tenant_id=self.request.tenant_id, + state=StateChoices.COMPLETED, + ) + + if not getattr(role, Permissions.UNLIMITED_VISIBILITY.value, False): + scans = scans.filter(provider__in=get_providers(role)) + + provider_filters = self._extract_provider_filters_from_params( + validate_uuids=True, + include_dot_aliases=True, + ) + 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) + ) + + def _filtered_queryset_for_latest_provider_scans(self, latest_scan_ids=None): + if latest_scan_ids is None: + latest_scan_ids = self._latest_scan_ids_for_provider_filters() + queryset = self.get_queryset().filter(scan_id__in=latest_scan_ids) + # Provider filters stay on the filterset for OpenAPI docs, but runtime + # filtering happens on Scan first so compliance queries use scan IDs. + return self._apply_filterset( + queryset, + self.filterset_class, + exclude_keys=self.PROVIDER_FILTER_KEYS | {"scan_id"}, + ) + def _get_compliance_template(self, *, provider=None, scan_id=None): """Return the compliance template for the given provider or scan.""" if provider is None and scan_id is not None: @@ -4758,6 +4947,36 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) + def _task_response_for_latest_provider_scans(self, latest_scan_ids): + for scan_id in latest_scan_ids: + task_response = self._task_response_if_running(str(scan_id)) + if task_response: + return task_response + return None + + def _latest_provider_scan_ids_without_data(self, latest_scan_ids): + data_presence_queryset = self.get_queryset().filter(scan_id__in=latest_scan_ids) + scan_ids_with_data = { + str(scan_id) + for scan_id in data_presence_queryset.values_list( + "scan_id", flat=True + ).distinct() + } + return [ + scan_id + for scan_id in latest_scan_ids + if str(scan_id) not in scan_ids_with_data + ] + + def _task_response_for_latest_provider_scans_without_data( + self, + latest_scan_ids, + ): + scan_ids_to_check = self._latest_provider_scan_ids_without_data( + latest_scan_ids, + ) + return self._task_response_for_latest_provider_scans(scan_ids_to_check) + def _list_with_region_filter(self, scan_id, region_filter): """ Fall back to detailed ComplianceRequirementOverview query when region filter is applied. @@ -4798,8 +5017,25 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): return Response(data) + def _list_with_latest_provider_filters(self): + latest_scan_ids = self._latest_scan_ids_for_provider_filters() + queryset = self._filtered_queryset_for_latest_provider_scans(latest_scan_ids) + data = self._aggregate_compliance_overview(queryset) + task_response = self._task_response_for_latest_provider_scans_without_data( + latest_scan_ids, + ) + if task_response: + return task_response + + return Response(data) + def list(self, request, *args, **kwargs): scan_id = request.query_params.get("filter[scan_id]") + has_provider_filters = self._has_provider_filters(include_dot_aliases=True) + self._validate_scan_selection(scan_id, has_provider_filters) + + if has_provider_filters: + return self._list_with_latest_provider_filters() # Specific scan requested - use optimized summaries with region support region_filter = request.query_params.get( @@ -4845,33 +5081,34 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): @action(detail=False, methods=["get"], url_name="metadata") def metadata(self, request): scan_id = request.query_params.get("filter[scan_id]") - if not scan_id: - raise ValidationError( - [ - { - "detail": "This query parameter is required.", - "status": 400, - "source": {"pointer": "filter[scan_id]"}, - "code": "required", - } - ] + has_provider_filters = self._has_provider_filters(include_dot_aliases=True) + self._validate_scan_selection(scan_id, has_provider_filters) + + latest_scan_ids = None + if has_provider_filters: + latest_scan_ids = self._latest_scan_ids_for_provider_filters() + queryset = self._filtered_queryset_for_latest_provider_scans( + latest_scan_ids ) + else: + queryset = self._apply_filterset(self.get_queryset(), self.filterset_class) + regions = list( - self.get_queryset() - .filter(scan_id=scan_id) - .values_list("region", flat=True) - .order_by("region") - .distinct() + queryset.values_list("region", flat=True).order_by("region").distinct() ) result = {"regions": regions} - if regions: - serializer = self.get_serializer(data=result) - serializer.is_valid(raise_exception=True) - return Response(serializer.data, status=status.HTTP_200_OK) + task_response = None + if has_provider_filters: + task_response = self._task_response_for_latest_provider_scans_without_data( + latest_scan_ids, + ) + elif not regions: + task_response = self._task_response_if_running(scan_id) + if task_response: + return task_response - task_response = self._task_response_if_running(scan_id) - if task_response: + if has_provider_filters and task_response: return task_response serializer = self.get_serializer(data=result) @@ -4881,19 +5118,10 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): @action(detail=False, methods=["get"], url_name="requirements") def requirements(self, request): scan_id = request.query_params.get("filter[scan_id]") + has_provider_filters = self._has_provider_filters(include_dot_aliases=True) compliance_id = request.query_params.get("filter[compliance_id]") - if not scan_id: - raise ValidationError( - [ - { - "detail": "This query parameter is required.", - "status": 400, - "source": {"pointer": "filter[scan_id]"}, - "code": "required", - } - ] - ) + self._validate_scan_selection(scan_id, has_provider_filters) if not compliance_id: raise ValidationError( @@ -4906,7 +5134,16 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): } ] ) - filtered_queryset = self.filter_queryset(self.get_queryset()) + latest_scan_ids = None + if has_provider_filters: + latest_scan_ids = self._latest_scan_ids_for_provider_filters() + filtered_queryset = self._filtered_queryset_for_latest_provider_scans( + latest_scan_ids + ) + else: + filtered_queryset = self._apply_filterset( + self.get_queryset(), self.filterset_class + ) all_requirements = filtered_queryset.values( "requirement_id", @@ -4965,17 +5202,33 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): requirements_summary, many=True ) + task_response = None + if has_provider_filters: + task_response = self._task_response_for_latest_provider_scans_without_data( + latest_scan_ids, + ) + elif not requirements_summary: + task_response = self._task_response_if_running(scan_id) + if task_response: + return task_response + + if has_provider_filters and task_response: + return task_response + if requirements_summary: return Response(serializer.data, status=status.HTTP_200_OK) - task_response = self._task_response_if_running(scan_id) - if task_response: - return task_response - return Response(serializer.data, status=status.HTTP_200_OK) @action(detail=False, methods=["get"], url_name="attributes") def attributes(self, request): + # While the background warm-up is in progress, refuse immediately + # instead of falling through to the slow cold load on the request + # thread (which would trip the Gunicorn worker timeout). `is_set()` is + # a non-blocking flag read, so this never touches the loader. + if COMPLIANCE_WARMING_STARTED.is_set() and not COMPLIANCE_WARMED.is_set(): + raise ComplianceWarmingError() + compliance_id = request.query_params.get("filter[compliance_id]") if not compliance_id: raise ValidationError( @@ -4991,7 +5244,51 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): provider_type = None - # If we couldn't determine from database, try each provider type + # When a scan is provided, resolve the provider from it. Multi-provider + # universal frameworks (e.g. CSA CCM) share a single compliance_id + # across providers but expose different checks per provider, so the + # metadata (and therefore the check IDs the UI uses to fetch findings) + # must be returned for the scan's provider. Without this, the endpoint + # falls back to the first provider that declares the framework and + # returns its check IDs, leaving azure/gcp/... requirements with no + # matching findings. + scan_id = request.query_params.get("filter[scan_id]") + if "filter[scan_id]" in request.query_params: + # An explicit scan_id is authoritative: fail closed instead of + # falling back to another provider. Otherwise an invalid, empty + # (filter[scan_id]=) or inaccessible scan would silently return the + # first provider's check IDs, recreating the multi-provider mismatch + # this endpoint fixes. + if not scan_id: + raise NotFound(detail=f"Scan '{scan_id}' not found.") + + # Tenant isolation is already enforced by Postgres RLS on the + # connection (see BaseRLSViewSet). Scope the lookup by provider + # group as well so a user with limited visibility can't resolve + # another provider's scan and read its compliance metadata, mirroring + # the RBAC scoping get_queryset() applies to the rest of the ViewSet. + role = get_role(request.user, request.tenant_id) + if getattr(role, Permissions.UNLIMITED_VISIBILITY.value, False): + scan_queryset = Scan.objects.filter(tenant_id=request.tenant_id) + else: + scan_queryset = Scan.objects.filter(provider__in=get_providers(role)) + + try: + scan = scan_queryset.select_related("provider").get(id=scan_id) + except (Scan.DoesNotExist, DjangoValidationError, ValueError): + raise NotFound(detail=f"Scan '{scan_id}' not found.") + + provider_type = scan.provider.provider + if compliance_id not in get_compliance_frameworks(provider_type): + raise NotFound( + detail=( + f"Compliance framework '{compliance_id}' is not " + f"available for scan '{scan_id}'." + ) + ) + + # Fall back to the first provider that declares the framework. Keeps the + # endpoint working for provider-agnostic callers that omit the scan. if not provider_type: for pt in Provider.ProviderChoices.values: if compliance_id in get_compliance_frameworks(pt): @@ -5159,7 +5456,7 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): ), ) @method_decorator(CACHE_DECORATOR, name="list") -class OverviewViewSet(BaseRLSViewSet): +class OverviewViewSet(ProviderFilterParamsMixin, BaseRLSViewSet): queryset = ScanSummary.objects.all() http_method_names = ["get"] ordering = ["-inserted_at"] @@ -5276,18 +5573,6 @@ class OverviewViewSet(BaseRLSViewSet): tenant_id=tenant_id, scan_id__in=latest_scan_ids ) - def _normalize_jsonapi_params(self, query_params, exclude_keys=None): - """Convert JSON:API filter params (filter[X]) to flat params (X).""" - exclude_keys = exclude_keys or set() - normalized = QueryDict(mutable=True) - for key, values in query_params.lists(): - normalized_key = ( - key[7:-1] if key.startswith("filter[") and key.endswith("]") else key - ) - if normalized_key not in exclude_keys: - normalized.setlist(normalized_key, values) - return normalized - def _ensure_allowed_providers(self): """Populate allowed providers for RBAC-aware queries once per request.""" if getattr(self, "_providers_initialized", False): @@ -5307,15 +5592,6 @@ class OverviewViewSet(BaseRLSViewSet): return queryset.filter(**provider_filter) return queryset - def _apply_filterset(self, queryset, filterset_class, exclude_keys=None): - normalized_params = self._normalize_jsonapi_params( - self.request.query_params, exclude_keys=set(exclude_keys or []) - ) - filterset = filterset_class(normalized_params, queryset=queryset) - if not filterset.is_valid(): - raise ValidationError(filterset.errors) - return filterset.qs - def _latest_scan_ids_for_allowed_providers(self, tenant_id, provider_filters=None): provider_filter = self._get_provider_filter() queryset = Scan.all_objects.filter( @@ -5329,40 +5605,6 @@ class OverviewViewSet(BaseRLSViewSet): .values_list("id", flat=True) ) - def _extract_provider_filters_from_params(self): - """Extract and validate provider filters from query params.""" - params = self.request.query_params - filters = {} - valid_provider_types = {c[0] for c in Provider.ProviderChoices.choices} - - provider_id = params.get("filter[provider_id]") - if provider_id: - filters["provider_id"] = provider_id - - provider_id_in = params.get("filter[provider_id__in]") - if provider_id_in: - filters["provider_id__in"] = provider_id_in.split(",") - - provider_type = params.get("filter[provider_type]") - if provider_type: - if provider_type not in valid_provider_types: - raise ValidationError( - {"provider_type": f"Invalid choice: {provider_type}"} - ) - filters["provider__provider"] = provider_type - - provider_type_in = params.get("filter[provider_type__in]") - if provider_type_in: - types = provider_type_in.split(",") - invalid = [t for t in types if t not in valid_provider_types] - if invalid: - raise ValidationError( - {"provider_type__in": f"Invalid choices: {', '.join(invalid)}"} - ) - filters["provider__provider__in"] = types - - return filters - @action(detail=False, methods=["get"], url_name="providers") def providers(self, request): tenant_id = self.request.tenant_id @@ -5433,15 +5675,11 @@ class OverviewViewSet(BaseRLSViewSet): tenant_id = self.request.tenant_id providers_qs = Provider.objects.filter(tenant_id=tenant_id) + self._ensure_allowed_providers() if hasattr(self, "allowed_providers"): - allowed_ids = list(self.allowed_providers.values_list("id", flat=True)) - if not allowed_ids: - overview = [] - return Response( - self.get_serializer(overview, many=True).data, - status=status.HTTP_200_OK, - ) - providers_qs = providers_qs.filter(id__in=allowed_ids) + providers_qs = providers_qs.filter( + id__in=self.allowed_providers.values("id") + ) overview = ( providers_qs.values("provider") @@ -5657,29 +5895,41 @@ class OverviewViewSet(BaseRLSViewSet): description="Retrieve a specific snapshot by ID. If not provided, returns latest snapshots.", ), OpenApiParameter( - name="provider_id", + name="filter[provider_id]", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, description="Filter by specific provider ID", ), OpenApiParameter( - name="provider_id__in", + name="filter[provider_id__in]", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, description="Filter by multiple provider IDs (comma-separated UUIDs)", ), OpenApiParameter( - name="provider_type", + name="filter[provider_type]", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, description="Filter by provider type (aws, azure, gcp, etc.)", ), OpenApiParameter( - name="provider_type__in", + name="filter[provider_type__in]", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, description="Filter by multiple provider types (comma-separated)", ), + OpenApiParameter( + name="filter[provider_groups]", + type=OpenApiTypes.UUID, + location=OpenApiParameter.QUERY, + description="Filter by provider group ID", + ), + OpenApiParameter( + name="filter[provider_groups__in]", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Filter by multiple provider group IDs (comma-separated UUIDs)", + ), ], ) @action(detail=False, methods=["get"], url_name="threatscore") @@ -6021,6 +6271,8 @@ class OverviewViewSet(BaseRLSViewSet): "provider_id__in", "provider_type", "provider_type__in", + "provider_groups", + "provider_groups__in", } filtered_queryset = self._apply_filterset( base_queryset, CategoryOverviewFilter, exclude_keys=provider_filter_keys @@ -6090,6 +6342,8 @@ class OverviewViewSet(BaseRLSViewSet): "provider_id__in", "provider_type", "provider_type__in", + "provider_groups", + "provider_groups__in", } filtered_queryset = self._apply_filterset( base_queryset, @@ -7140,7 +7394,7 @@ SEVERITY_ORDER_REVERSE = {v: k for k, v in SEVERITY_ORDER.items()} ), retrieve=extend_schema(exclude=True), ) -class FindingGroupViewSet(BaseRLSViewSet): +class FindingGroupViewSet(JsonApiFilterMixin, BaseRLSViewSet): """ ViewSet for Finding Groups - aggregates findings by check_id. @@ -7156,6 +7410,7 @@ class FindingGroupViewSet(BaseRLSViewSet): queryset = FindingGroupDailySummary.objects.all() serializer_class = FindingGroupSerializer filterset_class = FindingGroupFilter + jsonapi_filter_replace_dots = True filter_backends = [ jsonapi_filters.QueryParameterValidationFilter, jsonapi_filters.OrderingFilter, @@ -7206,18 +7461,6 @@ class FindingGroupViewSet(BaseRLSViewSet): return queryset - def _normalize_jsonapi_params(self, query_params): - """Convert JSON:API filter params (filter[X]) to flat params (X).""" - normalized = QueryDict(mutable=True) - for key, values in query_params.lists(): - normalized_key = ( - key[7:-1] if key.startswith("filter[") and key.endswith("]") else key - ) - # Convert JSON:API dot notation to Django double underscore - normalized_key = normalized_key.replace(".", "__") - normalized.setlist(normalized_key, values) - return normalized - @extend_schema(exclude=True) def retrieve(self, request, *args, **kwargs): raise MethodNotAllowed(method="GET") @@ -8336,9 +8579,10 @@ class FindingGroupViewSet(BaseRLSViewSet): This endpoint returns finding groups without requiring date filters, automatically using the latest available data per check_id. - All other filters (provider_id, provider_type, check_id) are still supported. + Provider, provider group, check, and computed filters are still supported. """, tags=["Finding Groups"], + filters=True, ) @action(detail=False, methods=["get"], url_name="latest") def latest(self, request): diff --git a/api/src/backend/config/celery.py b/api/src/backend/config/celery.py index c46c8a426c..5d246395a5 100644 --- a/api/src/backend/config/celery.py +++ b/api/src/backend/config/celery.py @@ -26,6 +26,61 @@ celery_app.conf.result_backend_transport_options = { } celery_app.conf.visibility_timeout = BROKER_VISIBILITY_TIMEOUT +# Durable delivery: keep the message until the task finishes, so a worker killed +# mid-task (deploy/OOM/eviction) does not silently drop it. Reserve one task at a +# time so a crash exposes at most one extra reserved message. +celery_app.conf.task_acks_late = True +celery_app.conf.task_reject_on_worker_lost = True +celery_app.conf.worker_prefetch_multiplier = env.int( + "DJANGO_CELERY_WORKER_PREFETCH_MULTIPLIER", default=1 +) +# On SIGTERM, give the worker time to finish or re-queue in-flight tasks before +# it is forcefully killed (Celery 5.5+ soft shutdown). +celery_app.conf.worker_soft_shutdown_timeout = env.int( + "DJANGO_CELERY_WORKER_SOFT_SHUTDOWN_TIMEOUT", default=60 +) +# Bound execution so a blocked task cannot pin a worker forever. Connection +# checks get a tight limit; scans and provider/tenant deletions can legitimately +# run for more than a day on large tenants, so they get a much higher cap. +# The default for every other task is set as the global limit, not as a "*" +# annotation: Celery applies the "*" entry AFTER the per-task one, so a "*" in +# task_annotations would silently overwrite every specific limit defined below. +_TASK_HARD_LIMIT = env.int("DJANGO_CELERY_TASK_TIME_LIMIT", default=6 * 60 * 60) +_TASK_SOFT_LIMIT = env.int( + "DJANGO_CELERY_TASK_SOFT_TIME_LIMIT", default=_TASK_HARD_LIMIT - 600 +) +_LONG_TASK_HARD_LIMIT = env.int( + "DJANGO_CELERY_LONG_TASK_TIME_LIMIT", default=48 * 60 * 60 +) +_LONG_TASK_SOFT_LIMIT = env.int( + "DJANGO_CELERY_LONG_TASK_SOFT_TIME_LIMIT", default=_LONG_TASK_HARD_LIMIT - 600 +) +celery_app.conf.task_time_limit = _TASK_HARD_LIMIT +celery_app.conf.task_soft_time_limit = _TASK_SOFT_LIMIT +celery_app.conf.task_annotations = { + **{ + name: {"soft_time_limit": 60, "time_limit": 120} + for name in ( + "provider-connection-check", + "integration-connection-check", + "lighthouse-connection-check", + "lighthouse-provider-connection-check", + ) + }, + **{ + name: { + "soft_time_limit": _LONG_TASK_SOFT_LIMIT, + "time_limit": _LONG_TASK_HARD_LIMIT, + } + for name in ( + "scan-perform", + "scan-perform-scheduled", + "provider-deletion", + "tenant-deletion", + ) + }, +} + celery_app.autodiscover_tasks(["api"]) diff --git a/api/src/backend/config/django/base.py b/api/src/backend/config/django/base.py index 317fe4fbfb..31a9537b5f 100644 --- a/api/src/backend/config/django/base.py +++ b/api/src/backend/config/django/base.py @@ -3,6 +3,7 @@ from datetime import timedelta from config.custom_logging import LOGGING # noqa from config.env import BASE_DIR, env # noqa from config.settings.celery import * # noqa +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 @@ -44,9 +45,11 @@ INSTALLED_APPS = [ "dj_rest_auth.registration", "rest_framework.authtoken", "drf_simple_apikey", + "django_eventstream", ] MIDDLEWARE = [ + "api.middleware.CloseDBConnectionsMiddleware", "django_guid.middleware.guid_middleware", "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", @@ -136,6 +139,7 @@ SPECTACULAR_SETTINGS = { } WSGI_APPLICATION = "config.wsgi.application" +ASGI_APPLICATION = "config.asgi.application" DJANGO_GUID = { "GUID_HEADER_NAME": "Transaction-ID", @@ -306,3 +310,32 @@ SESSION_COOKIE_SECURE = True ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES = env.int( "ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES", 2880 ) # 48h + +# Orphan task recovery feature flags. The master switch is OFF by default, so task +# recovery is opt-in; enable it with DJANGO_TASK_RECOVERY_ENABLED=true. The per-group +# toggles default to enabled, so once the master is on every group recovers unless a +# group is explicitly turned off. +TASK_RECOVERY_ENABLED = env.bool("DJANGO_TASK_RECOVERY_ENABLED", False) +TASK_RECOVERY_SUMMARIES_ENABLED = env.bool( + "DJANGO_TASK_RECOVERY_SUMMARIES_ENABLED", True +) +TASK_RECOVERY_DELETIONS_ENABLED = env.bool( + "DJANGO_TASK_RECOVERY_DELETIONS_ENABLED", True +) + + +def label_postgres_connections(databases): + """Tag each Postgres connection with ``application_name=":"`` + so connections are attributable by component in ``pg_stat_activity`` (and any + tooling that surfaces ``application_name``). The component (api / worker / + scan / ...) is injected per process by the container entrypoint via + ``DJANGO_APP_COMPONENT``; the alias distinguishes which pool inside the + process owns the connection. The neo4j entry is skipped (not a Postgres + backend). Postgres truncates ``application_name`` at 63 bytes. + """ + component = env.str("DJANGO_APP_COMPONENT", default="api") + for alias, config in databases.items(): + engine = config.get("ENGINE", "") + if engine.startswith("psqlextra") or "postgresql" in engine: + name = f"{component}:{alias}"[:63] + config.setdefault("OPTIONS", {})["application_name"] = name diff --git a/api/src/backend/config/django/devel.py b/api/src/backend/config/django/devel.py index 9c83557b77..6921790ca3 100644 --- a/api/src/backend/config/django/devel.py +++ b/api/src/backend/config/django/devel.py @@ -54,6 +54,8 @@ DATABASES = { DATABASES["default"] = DATABASES["prowler_user"] +label_postgres_connections(DATABASES) # noqa: F405 + REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"] = tuple( # noqa: F405 render_class for render_class in REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"] # noqa: F405 diff --git a/api/src/backend/config/django/production.py b/api/src/backend/config/django/production.py index 91bd50d0d1..cb651f6e76 100644 --- a/api/src/backend/config/django/production.py +++ b/api/src/backend/config/django/production.py @@ -58,3 +58,5 @@ DATABASES = { } DATABASES["default"] = DATABASES["prowler_user"] + +label_postgres_connections(DATABASES) # noqa: F405 diff --git a/api/src/backend/config/django/testing.py b/api/src/backend/config/django/testing.py index 75779f5a68..a1e5c29fb3 100644 --- a/api/src/backend/config/django/testing.py +++ b/api/src/backend/config/django/testing.py @@ -34,3 +34,8 @@ DRF_API_KEY = { # JWT SIMPLE_JWT["ALGORITHM"] = "HS256" # noqa: F405 +# pyjwt >= 2.13.0 rejects an empty HMAC signing key, so HS256 tests need a real +# key (>= 32 bytes also avoids the InsecureKeyLengthWarning). Production uses RS256. +SIMPLE_JWT["SIGNING_KEY"] = env.str( # noqa: F405 + "DJANGO_TOKEN_SIGNING_KEY", "insecure-testing-jwt-signing-key-do-not-use-in-prod" +) diff --git a/api/src/backend/config/guniconf.py b/api/src/backend/config/guniconf.py index 536fd97abb..63472feec8 100644 --- a/api/src/backend/config/guniconf.py +++ b/api/src/backend/config/guniconf.py @@ -1,6 +1,7 @@ import logging import multiprocessing import os +import threading from config.env import env @@ -11,6 +12,7 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.django.production") import django # noqa: E402 django.setup() +from api.compliance import warm_compliance_caches # noqa: E402 from config.django.production import LOGGING as DJANGO_LOGGERS, DEBUG # noqa: E402 from config.custom_logging import BackendLogger # noqa: E402 @@ -23,6 +25,37 @@ bind = f"{BIND_ADDRESS}:{PORT}" workers = env.int("DJANGO_WORKERS", default=multiprocessing.cpu_count() * 2 + 1) reload = DEBUG +# Native ASGI worker (gunicorn 24+). Required so SSE endpoints can keep the +# event loop alive while waiting for events. +worker_class = env("DJANGO_WORKER_CLASS", default="asgi") + +# Lifespan protocol. Django's ASGIHandler (config.asgi:application) serves only +# HTTP scopes and raises "Django can only handle ASGI/HTTP connections, not +# lifespan." gunicorn's default ("auto") probes the app with a lifespan scope +# to detect support, which triggers that error. We use no lifespan startup or +# shutdown hooks, so disable the protocol entirely. +asgi_lifespan = env("DJANGO_ASGI_LIFESPAN", default="off") + +# Event loop for the ASGI worker. "auto" uses uvloop when it is installed and +# falls back to the stdlib asyncio loop otherwise; uvloop gives the SSE event +# loop more headroom under many concurrent open streams. +asgi_loop = env("DJANGO_ASGI_LOOP", default="uvloop") + +# Max concurrent connections per ASGI worker. Each open SSE stream holds one +# connection for its whole lifetime, so this caps simultaneous SSE clients per +# worker (gunicorn's default is 1000). The sync-only `threads` option has no +# effect on ASGI workers. +worker_connections = env.int("DJANGO_WORKER_CONNECTIONS", default=1000) + +# Preload the application before forking workers in production: the app is +# imported once in the master and workers fork from it. In development, disable +# preload so the server restarts on code changes. +preload_app = not DEBUG + +# Worker timeout in seconds. Increased from the default 30s to handle requests +# that may take longer, such as complex API operations. +timeout = env.int("GUNICORN_TIMEOUT", default=120) + # Logging logconfig_dict = DJANGO_LOGGERS gunicorn_logger = logging.getLogger(BackendLogger.GUNICORN) @@ -41,3 +74,26 @@ def on_reload(_): def when_ready(_): gunicorn_logger.info("Gunicorn server is ready") + + +def _warm_compliance_caches_in_background(): + """Warm compliance caches off the request path and log the outcome.""" + failed = warm_compliance_caches() + if failed: + gunicorn_logger.warning("Compliance caches warmed (skipped: %s)", failed) + else: + gunicorn_logger.info("Compliance caches warmed") + + +def post_fork(_server, worker): + """Warm compliance caches after each worker fork. + + Warm compliance caches in a background thread so the worker becomes ready + immediately. A request for a not-yet-warmed provider lazily loads just that + provider, which stays well under the worker timeout. + """ + threading.Thread( + target=_warm_compliance_caches_in_background, + name="warm-compliance-caches", + daemon=True, + ).start() diff --git a/api/src/backend/config/settings/eventstream.py b/api/src/backend/config/settings/eventstream.py new file mode 100644 index 0000000000..470062050b --- /dev/null +++ b/api/src/backend/config/settings/eventstream.py @@ -0,0 +1,41 @@ +"""Server-Sent Events (SSE) configuration. + +Wires django-eventstream into the platform: Valkey Pub/Sub backend on a +dedicated DB (separate from the Celery broker), the platform channel +manager, and headers that match the existing CORS allowlist. +""" + +from config.env import env +from config.settings.celery import ( + VALKEY_HOST, + VALKEY_PASSWORD, + VALKEY_PORT, + VALKEY_SCHEME, + VALKEY_USERNAME, +) + +# Dedicated Valkey DB for the SSE Pub/Sub bus. Kept distinct from the +# Celery broker DB so a noisy broker can't shoulder out streaming +# traffic on the same keyspace. +EVENTSTREAM_VALKEY_DB = env.int("EVENTSTREAM_VALKEY_DB", default=2) + +EVENTSTREAM_REDIS: dict = { + "host": VALKEY_HOST, + "port": int(VALKEY_PORT), + "db": EVENTSTREAM_VALKEY_DB, +} +if VALKEY_PASSWORD: + EVENTSTREAM_REDIS["password"] = VALKEY_PASSWORD +if VALKEY_USERNAME: + EVENTSTREAM_REDIS["username"] = VALKEY_USERNAME +if VALKEY_SCHEME == "rediss": + EVENTSTREAM_REDIS["ssl"] = True + +# Platform channel manager — performs the per-feature authorization and +# rewrites the placeholder channel from the URL into the canonical +# tenant-scoped channel name. See ``api.sse.channelmanager``. +EVENTSTREAM_CHANNELMANAGER_CLASS = "api.sse.channelmanager.SSEChannelManager" + +# Headers a browser EventSource may legitimately send. Keep tight; the +# stream itself reads no body, so no permissive defaults. +EVENTSTREAM_ALLOW_HEADERS = "Cache-Control, Last-Event-ID" diff --git a/api/src/backend/config/settings/sentry.py b/api/src/backend/config/settings/sentry.py index 580821f7b2..1f449f782e 100644 --- a/api/src/backend/config/settings/sentry.py +++ b/api/src/backend/config/settings/sentry.py @@ -76,6 +76,8 @@ IGNORED_EXCEPTIONS = [ # PowerShell Errors in User Authentication "Microsoft Teams User Auth connection failed: Please check your permissions and try again.", "Exchange Online User Auth connection failed: Please check your permissions and try again.", + # ASGI: Client disconnected before the response finished (health-check probes on /health/live) + "RequestAborted", ] diff --git a/api/src/backend/tasks/jobs/attack_paths/cleanup.py b/api/src/backend/tasks/jobs/attack_paths/cleanup.py index 65ba583a3e..fa7670afaa 100644 --- a/api/src/backend/tasks/jobs/attack_paths/cleanup.py +++ b/api/src/backend/tasks/jobs/attack_paths/cleanup.py @@ -1,12 +1,14 @@ from datetime import datetime, timedelta, timezone -from celery import current_app, states +from celery import states from celery.utils.log import get_task_logger from config.django.base import ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES from tasks.jobs.attack_paths.db_utils import ( _mark_scan_finished, recover_graph_data_ready, ) +from tasks.jobs.orphan_recovery import is_worker_alive as _is_worker_alive +from tasks.jobs.orphan_recovery import revoke_task as _revoke_task from api.attack_paths import database as graph_database from api.db_router import MainRouter @@ -150,32 +152,6 @@ def _cleanup_stale_scheduled_scans(cutoff: datetime) -> list[str]: return cleaned_up -def _is_worker_alive(worker: str) -> bool: - """Ping a specific Celery worker. Returns `True` if it responds or on error.""" - try: - response = current_app.control.inspect(destination=[worker], timeout=1.0).ping() - return response is not None and worker in response - except Exception: - logger.exception(f"Failed to ping worker {worker}, treating as alive") - return True - - -def _revoke_task(task_result, terminate: bool = True) -> None: - """Revoke a Celery task. Non-fatal on failure. - - `terminate=True` SIGTERMs the worker if the task is mid-execution; use - for EXECUTING cleanup. `terminate=False` only marks the task id revoked - across workers, so any worker pulling the queued message discards it; - use for SCHEDULED cleanup where the task hasn't run yet. - """ - try: - kwargs = {"terminate": True, "signal": "SIGTERM"} if terminate else {} - current_app.control.revoke(task_result.task_id, **kwargs) - logger.info(f"Revoked task {task_result.task_id}") - except Exception: - logger.exception(f"Failed to revoke task {task_result.task_id}") - - def _cleanup_scan(scan, task_result, reason: str) -> bool: """ Clean up a single stale `AttackPathsScan`: diff --git a/api/src/backend/tasks/jobs/export.py b/api/src/backend/tasks/jobs/export.py index 1b9295cc67..96bd03ee6c 100644 --- a/api/src/backend/tasks/jobs/export.py +++ b/api/src/backend/tasks/jobs/export.py @@ -39,11 +39,6 @@ from prowler.lib.outputs.compliance.cis.cis_oraclecloud import OracleCloudCIS from prowler.lib.outputs.compliance.cisa_scuba.cisa_scuba_googleworkspace import ( GoogleWorkspaceCISASCuBA, ) -from prowler.lib.outputs.compliance.csa.csa_alibabacloud import AlibabaCloudCSA -from prowler.lib.outputs.compliance.csa.csa_aws import AWSCSA -from prowler.lib.outputs.compliance.csa.csa_azure import AzureCSA -from prowler.lib.outputs.compliance.csa.csa_gcp import GCPCSA -from prowler.lib.outputs.compliance.csa.csa_oraclecloud import OracleCloudCSA from prowler.lib.outputs.compliance.ens.ens_aws import AWSENS from prowler.lib.outputs.compliance.ens.ens_azure import AzureENS from prowler.lib.outputs.compliance.ens.ens_gcp import GCPENS @@ -63,6 +58,9 @@ from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_azure import ( AzureMitreAttack, ) from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_gcp import GCPMitreAttack +from prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig_okta import ( + OktaIDaaSSTIG, +) from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_alibaba import ( ProwlerThreatScoreAlibaba, ) @@ -102,7 +100,6 @@ COMPLIANCE_CLASS_MAP = { (lambda name: name == "prowler_threatscore_aws", ProwlerThreatScoreAWS), (lambda name: name.startswith("ccc_"), CCC_AWS), (lambda name: name.startswith("c5_"), AWSC5), - (lambda name: name.startswith("csa_"), AWSCSA), (lambda name: name == "asd_essential_eight_aws", ASDEssentialEightAWS), ], "azure": [ @@ -113,7 +110,6 @@ COMPLIANCE_CLASS_MAP = { (lambda name: name.startswith("ccc_"), CCC_Azure), (lambda name: name == "prowler_threatscore_azure", ProwlerThreatScoreAzure), (lambda name: name == "c5_azure", AzureC5), - (lambda name: name.startswith("csa_"), AzureCSA), ], "gcp": [ (lambda name: name.startswith("cis_"), GCPCIS), @@ -123,7 +119,6 @@ COMPLIANCE_CLASS_MAP = { (lambda name: name == "prowler_threatscore_gcp", ProwlerThreatScoreGCP), (lambda name: name.startswith("ccc_"), CCC_GCP), (lambda name: name == "c5_gcp", GCPC5), - (lambda name: name.startswith("csa_"), GCPCSA), ], "kubernetes": [ (lambda name: name.startswith("cis_"), KubernetesCIS), @@ -152,16 +147,17 @@ COMPLIANCE_CLASS_MAP = { "image": [], "oraclecloud": [ (lambda name: name.startswith("cis_"), OracleCloudCIS), - (lambda name: name.startswith("csa_"), OracleCloudCSA), ], "alibabacloud": [ (lambda name: name.startswith("cis_"), AlibabaCloudCIS), - (lambda name: name.startswith("csa_"), AlibabaCloudCSA), ( lambda name: name == "prowler_threatscore_alibabacloud", ProwlerThreatScoreAlibaba, ), ], + "okta": [ + (lambda name: name.startswith("okta_idaas_stig"), OktaIDaaSSTIG), + ], } diff --git a/api/src/backend/tasks/jobs/orphan_recovery.py b/api/src/backend/tasks/jobs/orphan_recovery.py new file mode 100644 index 0000000000..1bf5c95df2 --- /dev/null +++ b/api/src/backend/tasks/jobs/orphan_recovery.py @@ -0,0 +1,341 @@ +"""Detect and recover orphaned Celery tasks. + +A task is "orphaned" when its result row is non-terminal (STARTED/RECEIVED) but the +worker that was running it is gone (deploy, OOM, eviction). We tell a real orphan +from a still-running task by pinging the worker recorded on its `TaskResult`: + +- worker responds -> the task is in flight, leave it alone (never double-run); +- worker is gone -> real orphan: mark the stale result terminal (so pending/started + alerts clear), then re-enqueue the task from its stored name + kwargs. + +This recovers only allowlisted tasks with local, proven idempotency. Celery's +`result_extended=True` gives us the stored `task_name`/`task_kwargs`/`worker` once +the task starts, but external side-effect tasks are failed instead of blindly +re-run. A small recovery cap stops a task that repeatedly kills its worker from +looping forever. + +This is the shared engine behind both the periodic Beat watchdog and the +`reconcile_orphan_tasks` management command. +""" + +import ast +import json +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone +from uuid import uuid4 + +from celery import current_app, states +from celery.utils.log import get_task_logger +from django.db import connections + +logger = get_task_logger(__name__) + +# Arbitrary constant key for pg_try_advisory_lock so only one reconciliation +# runs at a time across replicas / the watchdog / the command. +ORPHAN_RECOVERY_LOCK_KEY = 0x70726F77 # "prow" + +# Non-terminal states that mean "a worker had this and may have died with it". +IN_FLIGHT_STATES = (states.STARTED, states.RECEIVED) + +# Tasks with proven idempotency are eligible for auto re-enqueue, grouped so each +# group can be toggled independently by a feature flag (see config.django.base). +# Summaries clear and rewrite their own rows and deletions are idempotent. Tasks with +# external side effects are never eligible: integration-jira would create duplicate +# issues, integration-s3 rebuilds its upload from worker-local files that do not +# survive a crash, and report/Security Hub recovery is out of scope. +RECOVERY_TASK_GROUPS = { + "summaries": { + "scan-summary", + "scan-compliance-overviews", + "scan-provider-compliance-scores", + "scan-daily-severity", + "scan-finding-group-summaries", + "scan-reset-ephemeral-resources", + }, + "deletions": {"provider-deletion", "tenant-deletion"}, +} + + +def reenqueueable_tasks() -> set[str]: + """Task names eligible for auto re-enqueue, honoring the per-group feature flags. + + A group whose flag is disabled is dropped, so its orphaned tasks are marked + terminal instead of re-enqueued. + """ + from django.conf import settings + + group_enabled = { + "summaries": settings.TASK_RECOVERY_SUMMARIES_ENABLED, + "deletions": settings.TASK_RECOVERY_DELETIONS_ENABLED, + } + return { + task + for group, tasks in RECOVERY_TASK_GROUPS.items() + if group_enabled[group] + for task in tasks + } + + +# Tasks the watchdog ignores entirely (not even marked terminal): scan tasks are not +# auto-recovered, since re-running a scan is not safe to do automatically; attack-paths +# scans are handled by their own stale-cleanup (which also drops the temp Neo4j db); +# and the maintenance tasks must not self-recover (they run again on their own schedule). +_SKIP_RECOVERY = { + "scan-perform", + "scan-perform-scheduled", + "attack-paths-scan-perform", + "attack-paths-cleanup-stale-scans", + "reconcile-orphan-tasks", +} + + +@contextmanager +def advisory_lock(key: int = ORPHAN_RECOVERY_LOCK_KEY, using: str = "default"): + """Yield True if this session won a Postgres advisory lock, else False. + + Non-blocking: losers get False and should no-op. The lock is released on + exit (and implicitly if the session dies). + """ + with connections[using].cursor() as cursor: + cursor.execute("SELECT pg_try_advisory_lock(%s)", [key]) + acquired = bool(cursor.fetchone()[0]) + try: + yield acquired + finally: + if acquired: + cursor.execute("SELECT pg_advisory_unlock(%s)", [key]) + + +def is_worker_alive(worker: str, timeout: float = 1.0) -> bool: + """Ping a specific Celery worker. Returns True if it responds, or on error. + + Erring on the side of "alive" means an unreachable control bus never causes + a still-running task to be re-enqueued. + """ + try: + response = current_app.control.inspect( + destination=[worker], timeout=timeout + ).ping() + return response is not None and worker in response + except Exception: + logger.exception(f"Failed to ping worker {worker}, treating as alive") + return True + + +def revoke_task(task_result, terminate: bool = True) -> None: + """Revoke a Celery task by its TaskResult. Non-fatal on failure. + + terminate=True SIGTERMs the worker if the task is mid-execution; terminate=False + only marks the id revoked so any worker pulling the queued message discards it + (use before re-enqueuing, so a later broker redelivery of the stale message is + dropped). + """ + try: + kwargs = {"terminate": True, "signal": "SIGTERM"} if terminate else {} + current_app.control.revoke(task_result.task_id, **kwargs) + logger.info(f"Revoked task {task_result.task_id}") + except Exception: + logger.exception(f"Failed to revoke task {task_result.task_id}") + + +def _decode_celery_field(value, default): + """Decode django-celery-results' stored task_args/task_kwargs to a Python object. + + The backend stores them as a (sometimes double-encoded) repr/JSON string. An + empty or missing field returns ``default``; a non-empty value that cannot be + decoded raises ``ValueError`` so the caller can avoid re-enqueuing a task with + the wrong arguments. + """ + obj = value + for _ in range(2): # values can be double-encoded (a string holding a repr) + if not isinstance(obj, str): + break + text = obj.strip() + if not text: + return default + parsed = None + for parser in (ast.literal_eval, json.loads): + try: + parsed = parser(text) + break + except (ValueError, SyntaxError, TypeError): + continue + if parsed is None: + raise ValueError(f"undecodable celery field: {text[:120]!r}") + obj = parsed + return default if obj is None else obj + + +def reconcile_orphans( + grace_minutes: int = 2, + max_attempts: int = 3, + window_hours: int = 6, + dry_run: bool = False, +) -> dict: + """Run the full orphan sweep under a single-flight advisory lock. + + Recovers any orphaned in-flight task and delegates attack-paths scans that + never reached a worker to their existing stale-cleanup. Returns a summary; + a no-op (lock not won) is reported too. + """ + with advisory_lock() as acquired: + if not acquired: + logger.info("Orphan reconcile skipped: another run holds the lock") + return {"acquired": False} + + from django.conf import settings + + if settings.TASK_RECOVERY_ENABLED: + # Populate the task registry so we can re-enqueue any task by name. + import tasks.tasks # noqa: F401 + + result = _reconcile_task_results( + grace_minutes=grace_minutes, + max_attempts=max_attempts, + window_hours=window_hours, + dry_run=dry_run, + ) + result["enabled"] = True + else: + logger.info("Orphan task recovery disabled by feature flag") + result = {"recovered": [], "failed": [], "skipped": [], "enabled": False} + + if not dry_run: + from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans + + result["attack_paths"] = cleanup_stale_attack_paths_scans() + + return {"acquired": True, **result} + + +def _reconcile_task_results( + grace_minutes: int, max_attempts: int, window_hours: int, dry_run: bool +) -> dict: + from django_celery_results.models import TaskResult + + cutoff = datetime.now(tz=timezone.utc) - timedelta(minutes=grace_minutes) + candidates = list( + TaskResult.objects.filter(status__in=IN_FLIGHT_STATES, date_created__lt=cutoff) + .exclude(worker__isnull=True) + .exclude(worker="") + .exclude(task_name__in=_SKIP_RECOVERY) + ) + + # Ping each distinct worker at most once. + worker_alive = {w: is_worker_alive(w) for w in {tr.worker for tr in candidates}} + + recovered, failed, skipped = [], [], [] + for task_result in candidates: + if worker_alive.get(task_result.worker, True): + skipped.append(task_result.task_id) # in flight, do not double-run + continue + if dry_run: + recovered.append(task_result.task_id) + continue + outcome = _recover_task(task_result, max_attempts, window_hours) + (recovered if outcome == "recovered" else failed).append(task_result.task_id) + + logger.info( + "Orphan reconcile: recovered=%d failed=%d skipped(in-flight)=%d", + len(recovered), + len(failed), + len(skipped), + ) + return {"recovered": recovered, "failed": failed, "skipped": skipped} + + +def _recovery_attempt_count(name: str, kwargs_repr, window_hours: int) -> int: + """Increment and return the recovery count for this (task, kwargs) within the + window. Backed by Valkey so it survives result-row churn (a worker processing + the revoke can blank the TaskResult fields). Fail-open if Valkey is down (the + broker being unreachable means nothing is running anyway). + """ + import hashlib + + from django.conf import settings + + try: + import redis + + client = redis.from_url(settings.CELERY_BROKER_URL) + signature = f"{name}|{kwargs_repr}".encode() + key = ( + "orphan-recovery:" + + hashlib.sha1(signature, usedforsecurity=False).hexdigest() + ) + count = client.incr(key) + if count == 1: + client.expire(key, max(1, window_hours) * 3600) + return int(count) + except Exception: + logger.exception("Recovery-attempt counter unavailable; allowing recovery") + return 1 + + +def _recover_task(task_result, max_attempts: int, window_hours: int) -> str: + """Recover one orphaned task. Returns 'recovered' or 'failed'.""" + # Capture name/args/kwargs now: revoking can let a worker blank the row. + name = task_result.task_name + args_repr = task_result.task_args + kwargs_repr = task_result.task_kwargs + now = datetime.now(tz=timezone.utc) + + # Drop any future broker redelivery of the stale message. + revoke_task(task_result, terminate=False) + + # Mark the stale result terminal so "pending/started forever" alerts clear. + task_result.status = states.REVOKED + task_result.date_done = now + task_result.save(update_fields=["status", "date_done"]) + + if name not in reenqueueable_tasks(): + logger.warning( + "Orphan %s (%s) not re-enqueued: not allowlisted for auto recovery", + task_result.task_id, + name, + ) + return "failed" + + # Count the attempt only once the task is allowlisted, so a task sitting in a + # disabled group does not burn its recovery budget while the flag is off (and is + # not already over the cap the moment the group is re-enabled). + attempt = _recovery_attempt_count(name, kwargs_repr, window_hours) + if attempt > max_attempts: + logger.warning( + "Orphan %s (%s) not re-enqueued: recovery cap reached (%d/%d)", + task_result.task_id, + name, + attempt, + max_attempts, + ) + return "failed" + + task_obj = current_app.tasks.get(name) + if task_obj is None: + logger.error( + "Orphan %s: task %s not registered, cannot re-enqueue", + task_result.task_id, + name, + ) + return "failed" + + try: + args = _decode_celery_field(args_repr, []) + kwargs = _decode_celery_field(kwargs_repr, {}) + except ValueError: + logger.error( + "Orphan %s (%s): could not decode stored args/kwargs, not re-enqueuing", + task_result.task_id, + name, + ) + return "failed" + new_task_id = str(uuid4()) + task_obj.apply_async( + args=list(args) if isinstance(args, (list, tuple)) else [], + kwargs=kwargs if isinstance(kwargs, dict) else {}, + task_id=new_task_id, + ) + logger.info( + "Re-enqueued orphan %s (%s) as %s", task_result.task_id, name, new_task_id + ) + return "recovered" diff --git a/api/src/backend/tasks/jobs/report.py b/api/src/backend/tasks/jobs/report.py index 4cc3074bcd..36e47829c5 100644 --- a/api/src/backend/tasks/jobs/report.py +++ b/api/src/backend/tasks/jobs/report.py @@ -29,7 +29,10 @@ from api.db_router import READ_REPLICA_ALIAS, MainRouter from api.db_utils import rls_transaction from api.models import Provider, Scan, ScanSummary, StateChoices, ThreatScoreSnapshot from api.utils import initialize_prowler_provider -from prowler.lib.check.compliance_models import Compliance +from prowler.lib.check.compliance_models import ( + Compliance, + get_bulk_compliance_frameworks_universal, +) from prowler.lib.outputs.finding import Finding as FindingOutput logger = get_task_logger(__name__) @@ -571,7 +574,7 @@ def generate_csa_report( Args: tenant_id: The tenant ID for Row-Level Security context. scan_id: ID of the scan executed by Prowler. - compliance_id: ID of the compliance framework (e.g., "csa_ccm_4.0_aws"). + compliance_id: ID of the compliance framework (e.g., "csa_ccm_4.0"). output_path: Output PDF file path. provider_id: Provider ID for the scan. only_failed: If True, only include failed requirements in detailed section. @@ -883,9 +886,11 @@ def generate_compliance_reports( frameworks_bulk.get(f"nis2_{provider_type}") ) if generate_csa: - pending_checks_by_framework["csa"] = _get_compliance_check_ids( - frameworks_bulk.get(f"csa_ccm_4.0_{provider_type}") - ) + # csa_ccm_4.0 lives at the top level, not under compliance/{provider}/. + csa_framework = frameworks_bulk.get( + "csa_ccm_4.0" + ) or get_bulk_compliance_frameworks_universal(provider_type).get("csa_ccm_4.0") + pending_checks_by_framework["csa"] = _get_compliance_check_ids(csa_framework) if generate_cis and latest_cis: pending_checks_by_framework["cis"] = _get_compliance_check_ids( frameworks_bulk.get(latest_cis) @@ -1183,7 +1188,7 @@ def generate_compliance_reports( if generate_csa: generated_report_keys.append("csa") csa_path = output_paths["csa"] - compliance_id_csa = f"csa_ccm_4.0_{provider_type}" + compliance_id_csa = "csa_ccm_4.0" pdf_path_csa = f"{csa_path}_csa_report.pdf" logger.info("Generating CSA CCM report with compliance %s", compliance_id_csa) diff --git a/api/src/backend/tasks/jobs/reports/base.py b/api/src/backend/tasks/jobs/reports/base.py index 4180c847d8..27d1defff4 100644 --- a/api/src/backend/tasks/jobs/reports/base.py +++ b/api/src/backend/tasks/jobs/reports/base.py @@ -5,6 +5,7 @@ import time from abc import ABC, abstractmethod from contextlib import contextmanager from dataclasses import dataclass, field +from types import SimpleNamespace from typing import Any from celery.utils.log import get_task_logger @@ -26,7 +27,10 @@ from api.db_router import READ_REPLICA_ALIAS from api.db_utils import rls_transaction from api.models import Provider, StatusChoices from api.utils import initialize_prowler_provider -from prowler.lib.check.compliance_models import Compliance +from prowler.lib.check.compliance_models import ( + Compliance, + get_bulk_compliance_frameworks_universal, +) from prowler.lib.outputs.finding import Finding as FindingOutput from .components import ( @@ -222,6 +226,46 @@ def get_requirement_metadata( return None +def _universal_attributes_to_list(attributes) -> list: + """Flatten a universal requirement's ``attributes`` into a list of objects + with attribute access. MITRE wraps its list under ``_raw_attributes``.""" + if isinstance(attributes, dict) and "_raw_attributes" in attributes: + entries = attributes.get("_raw_attributes") or [] + return [ + SimpleNamespace(**entry) for entry in entries if isinstance(entry, dict) + ] + if isinstance(attributes, dict): + return [SimpleNamespace(**attributes)] if attributes else [] + return list(attributes or []) + + +def _adapt_universal_to_legacy(framework, provider_type: str) -> SimpleNamespace: + """Expose a universal ``ComplianceFramework`` under the legacy ``Compliance`` + attribute names used by the PDF pipeline.""" + provider_key = (provider_type or "").lower() + requirements = [] + for requirement in framework.requirements: + checks_by_provider = ( + requirement.checks if isinstance(requirement.checks, dict) else {} + ) + requirements.append( + SimpleNamespace( + Id=requirement.id, + Description=requirement.description or "", + Checks=list(checks_by_provider.get(provider_key, [])), + Attributes=_universal_attributes_to_list(requirement.attributes), + ) + ) + return SimpleNamespace( + Framework=framework.framework, + Name=framework.name, + Version=framework.version or "", + Description=framework.description or "", + Provider=framework.provider or provider_type, + Requirements=requirements, + ) + + # ============================================================================= # PDF Styles Cache # ============================================================================= @@ -869,9 +913,18 @@ class BaseComplianceReportGenerator(ABC): prowler_provider = initialize_prowler_provider(provider_obj) provider_type = provider_obj.provider - # Load compliance framework - frameworks_bulk = Compliance.get_bulk(provider_type) - compliance_obj = frameworks_bulk.get(compliance_id) + # Load compliance framework — fall back to the universal loader + # for top-level JSONs (e.g. csa_ccm_4.0) that Compliance.get_bulk + # does not scan. + compliance_obj = Compliance.get_bulk(provider_type).get(compliance_id) + if not compliance_obj: + universal_framework = get_bulk_compliance_frameworks_universal( + provider_type + ).get(compliance_id) + if universal_framework: + compliance_obj = _adapt_universal_to_legacy( + universal_framework, provider_type + ) if not compliance_obj: raise ValueError(f"Compliance framework not found: {compliance_id}") diff --git a/api/src/backend/tasks/jobs/scan.py b/api/src/backend/tasks/jobs/scan.py index a772173a7f..f6ae3e6402 100644 --- a/api/src/backend/tasks/jobs/scan.py +++ b/api/src/backend/tasks/jobs/scan.py @@ -5,6 +5,7 @@ import re import time import uuid from collections import defaultdict +from collections.abc import Iterable from datetime import datetime, timezone from typing import Any @@ -22,7 +23,6 @@ from django.db.models import ( Max, Min, OuterRef, - Prefetch, Q, Sum, When, @@ -269,6 +269,7 @@ def _store_resources( provider=provider_instance, uid=finding.resource_uid, defaults={ + "name": finding.resource_name, "region": finding.region, "service": finding.service_name, "type": finding.resource_type, @@ -276,6 +277,7 @@ def _store_resources( ) if not created: + resource_instance.name = finding.resource_name resource_instance.region = finding.region resource_instance.service = finding.service_name resource_instance.type = finding.resource_type @@ -355,68 +357,71 @@ def _copy_compliance_requirement_rows( def _persist_compliance_requirement_rows( - tenant_id: str, rows: list[dict[str, Any]], batch_size: int = 10000 -) -> None: + tenant_id: str, rows: Iterable[dict[str, Any]], batch_size: int = 10000 +) -> int: """Persist compliance requirement rows using batched COPY with ORM fallback. - Splits large row sets into batches to reduce lock duration and improve concurrency. + ``rows`` is consumed lazily in batches, so peak memory stays at ~``batch_size`` + rows instead of the full set. A batch that fails COPY falls back to an ORM + ``bulk_create`` of just that batch. Args: tenant_id: Target tenant UUID. - rows: Precomputed row dictionaries that reflect the compliance - overview state for a scan. + rows: Iterable of row dictionaries reflecting the compliance overview + state for a scan. batch_size: Number of rows per COPY batch (default: 10000). + + Returns: + int: total number of rows persisted. """ - if not rows: - return - - total_rows = len(rows) - total_batches = (total_rows + batch_size - 1) // batch_size - - try: - # Process rows in batches to reduce lock duration - for batch_num in range(total_batches): - start_idx = batch_num * batch_size - end_idx = min(start_idx + batch_size, total_rows) - batch = rows[start_idx:end_idx] + total_rows = 0 + batch_num = 0 + for batch, _is_last in batched(rows, batch_size): + if not batch: + continue + batch_num += 1 + try: _copy_compliance_requirement_rows(tenant_id, batch) + except Exception as error: + logger.exception( + f"COPY bulk insert for compliance requirements batch {batch_num} " + "failed; falling back to ORM bulk_create for this batch", + exc_info=error, + ) + fallback_objects = [ + ComplianceRequirementOverview( + id=row["id"], + tenant_id=row["tenant_id"], + inserted_at=row["inserted_at"], + compliance_id=row["compliance_id"], + framework=row["framework"], + version=row["version"], + description=row["description"], + region=row["region"], + requirement_id=row["requirement_id"], + requirement_status=row["requirement_status"], + passed_checks=row["passed_checks"], + failed_checks=row["failed_checks"], + total_checks=row["total_checks"], + passed_findings=row.get("passed_findings", 0), + total_findings=row.get("total_findings", 0), + scan_id=row["scan_id"], + ) + for row in batch + ] + with rls_transaction(tenant_id): + ComplianceRequirementOverview.objects.bulk_create( + fallback_objects, batch_size=500 + ) - logger.info( - f"Compliance COPY batch {batch_num + 1}/{total_batches}: " - f"inserted {len(batch)} rows ({start_idx + len(batch)}/{total_rows} total)" - ) - except Exception as error: - logger.exception( - "COPY bulk insert for compliance requirements failed; falling back to ORM bulk_create", - exc_info=error, + total_rows += len(batch) + logger.info( + f"Compliance COPY batch {batch_num}: inserted {len(batch)} rows " + f"({total_rows} total)" ) - # Fallback: use ORM bulk_create for all remaining rows - fallback_objects = [ - ComplianceRequirementOverview( - id=row["id"], - tenant_id=row["tenant_id"], - inserted_at=row["inserted_at"], - compliance_id=row["compliance_id"], - framework=row["framework"], - version=row["version"], - description=row["description"], - region=row["region"], - requirement_id=row["requirement_id"], - requirement_status=row["requirement_status"], - passed_checks=row["passed_checks"], - failed_checks=row["failed_checks"], - total_checks=row["total_checks"], - passed_findings=row.get("passed_findings", 0), - total_findings=row.get("total_findings", 0), - scan_id=row["scan_id"], - ) - for row in rows - ] - with rls_transaction(tenant_id): - ComplianceRequirementOverview.objects.bulk_create( - fallback_objects, batch_size=500 - ) + + return total_rows def _create_compliance_summaries( @@ -476,9 +481,12 @@ def _create_compliance_summaries( ) ) - # Bulk insert summaries - if summary_objects: - with rls_transaction(tenant_id): + # Idempotent re-run: clear this scan's prior summaries before re-inserting, so a + # recovered scan-compliance-overviews run reflects its own re-derived rows instead + # of keeping a stale one (bulk_create ignore_conflicts alone would keep the old). + with rls_transaction(tenant_id): + ComplianceOverviewSummary.objects.filter(scan_id=scan_id).delete() + if summary_objects: ComplianceOverviewSummary.objects.bulk_create( summary_objects, batch_size=500, ignore_conflicts=True ) @@ -701,6 +709,12 @@ def _process_finding_micro_batch( if finding.region and resource_instance.region != finding.region: resource_instance.region = finding.region updated = True + if ( + finding.resource_name + and resource_instance.name != finding.resource_name + ): + resource_instance.name = finding.resource_name + updated = True if resource_instance.service != finding.service_name: resource_instance.service = finding.service_name updated = True @@ -942,6 +956,7 @@ def _process_finding_micro_batch( Resource.objects.bulk_update( resources_to_bulk_update, [ + "name", "metadata", "details", "partition", @@ -1433,9 +1448,13 @@ def _aggregate_findings_by_region( tenant_id: str, scan_id: str, modeled_threatscore_compliance_id: str ) -> tuple[dict, dict]: """ - Aggregate findings by region using optimized ORM queries. + Aggregate findings by region using streaming, column-scoped ORM reads. - Replaces nested Python loops with efficient queries and aggregation. + 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 @@ -1447,12 +1466,12 @@ def _aggregate_findings_by_region( - check_status_by_region: {region: {check_id: status}} - findings_count_by_compliance: {region: {normalized_id: {requirement_id: {total, pass}}}} """ - check_status_by_region = {} - findings_count_by_compliance = {} + 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): - # Fetch only PASS/FAIL findings (optimized query reduces data transfer) - # Other statuses are not needed for check_status or ThreatScore calculation findings = ( Finding.all_objects.filter( tenant_id=tenant_id, @@ -1460,42 +1479,28 @@ def _aggregate_findings_by_region( muted=False, status__in=["PASS", "FAIL"], ) - .only("id", "check_id", "status", "compliance") - .prefetch_related( - Prefetch( - "resources", - queryset=Resource.objects.only("id", "region"), - to_attr="small_resources", - ) + .values_list("check_id", "status", "resource_regions", "compliance") + .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 ) - ) - # Process findings in a single pass (more efficient than original nested loops) - normalized_id = re.sub( - r"[^a-z0-9]", "", modeled_threatscore_compliance_id.lower() - ) - - for finding in findings: - status = finding.status - - for resource in finding.small_resources: - region = resource.region - - # Aggregate check status by region - current_status = check_status_by_region.setdefault(region, {}) + for region in resource_regions or (): # Priority: FAIL > any other status - if current_status.get(finding.check_id) != "FAIL": - current_status[finding.check_id] = status + current_status = check_status_by_region.setdefault(region, {}) + if current_status.get(check_id) != "FAIL": + current_status[check_id] = status # Aggregate ThreatScore compliance counts - if modeled_threatscore_compliance_id in (finding.compliance or {}): + if threatscore_requirements: compliance_key = findings_count_by_compliance.setdefault( region, {} ).setdefault(normalized_id, {}) - for requirement_id in finding.compliance[ - modeled_threatscore_compliance_id - ]: + for requirement_id in threatscore_requirements: requirement_stats = compliance_key.setdefault( requirement_id, {"total": 0, "pass": 0} ) @@ -1542,8 +1547,8 @@ def create_compliance_requirements(tenant_id: str, scan_id: str): (compliance_id, requirement_id) ) - compliance_requirement_rows: list[dict[str, Any]] = [] regions = [] + requirements_created = 0 requirement_statuses = defaultdict( lambda: {"fail_count": 0, "pass_count": 0, "total_count": 0} ) @@ -1583,44 +1588,93 @@ def create_compliance_requirements(tenant_id: str, scan_id: str): else: requirement_stats["failed_checks"] += 1 - # Prepare compliance requirement rows and compute summaries in single pass utc_datetime_now = datetime.now(tz=timezone.utc) - - # Pre-compute shared strings (optimization: reduces string conversions) tenant_id_str = str(tenant_id) scan_id_str = str(scan_instance.id) - for region in regions: - region_stats = region_requirement_stats.get(region, {}) - for compliance_id, compliance in compliance_template.items(): - modeled_compliance_id = _normalized_compliance_key( - compliance["framework"], compliance["version"] + # Per-framework constants that don't depend on the region. + compliance_plan = [] + for compliance_id, compliance in compliance_template.items(): + modeled_compliance_id = _normalized_compliance_key( + compliance["framework"], compliance["version"] + ) + framework = compliance["framework"] + version = compliance["version"] or "" + requirements = [ + ( + requirement_id, + requirement.get("description") or "", + len(requirement["checks"]), ) - compliance_stats = region_stats.get(compliance_id, {}) - # Create an overview record for each requirement within each compliance framework for requirement_id, requirement in compliance[ "requirements" - ].items(): - stats = compliance_stats.get(requirement_id) - passed_checks = stats["passed_checks"] if stats else 0 - failed_checks = stats["failed_checks"] if stats else 0 - total_checks = len(requirement["checks"]) - if total_checks == 0: - requirement_status = "MANUAL" - elif failed_checks > 0: - requirement_status = "FAIL" - else: - requirement_status = "PASS" + ].items() + ] + compliance_plan.append( + ( + compliance_id, + framework, + version, + modeled_compliance_id, + requirements, + ) + ) - compliance_requirement_rows.append( - { + # Yield rows lazily (consumed batch-by-batch by COPY) so peak memory + # stays bounded; tally requirement_statuses in the same pass. + def _iter_compliance_requirement_rows(): + 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 requirement_id, description, total_checks in requirements: + stats = compliance_stats.get(requirement_id) + if stats: + passed_checks = stats["passed_checks"] + failed_checks = stats["failed_checks"] + else: + passed_checks = 0 + failed_checks = 0 + if total_checks == 0: + requirement_status = "MANUAL" + elif failed_checks > 0: + requirement_status = "FAIL" + else: + requirement_status = "PASS" + + finding_counts = compliance_findings.get(requirement_id) + if finding_counts: + passed_findings = finding_counts.get("pass", 0) + total_findings = finding_counts.get("total", 0) + else: + passed_findings = 0 + total_findings = 0 + + key = (compliance_id, requirement_id) + requirement_statuses[key]["total_count"] += 1 + if requirement_status == "FAIL": + requirement_statuses[key]["fail_count"] += 1 + elif requirement_status == "PASS": + requirement_statuses[key]["pass_count"] += 1 + + yield { "id": uuid.uuid4(), "tenant_id": tenant_id_str, "inserted_at": utc_datetime_now, "compliance_id": compliance_id, - "framework": compliance["framework"], - "version": compliance["version"] or "", - "description": requirement.get("description") or "", + "framework": framework, + "version": version, + "description": description, "region": region, "requirement_id": requirement_id, "requirement_status": requirement_status, @@ -1628,37 +1682,23 @@ def create_compliance_requirements(tenant_id: str, scan_id: str): "failed_checks": failed_checks, "total_checks": total_checks, "scan_id": scan_id_str, - "passed_findings": findings_count_by_compliance.get( - region, {} - ) - .get(modeled_compliance_id, {}) - .get(requirement_id, {}) - .get("pass", 0), - "total_findings": findings_count_by_compliance.get( - region, {} - ) - .get(modeled_compliance_id, {}) - .get(requirement_id, {}) - .get("total", 0), + "passed_findings": passed_findings, + "total_findings": total_findings, } - ) - # Update summary tracking (single-pass optimization) - key = (compliance_id, requirement_id) - requirement_statuses[key]["total_count"] += 1 - if requirement_status == "FAIL": - requirement_statuses[key]["fail_count"] += 1 - elif requirement_status == "PASS": - requirement_statuses[key]["pass_count"] += 1 + # Idempotent re-run: clear this scan's rows before re-inserting. + with rls_transaction(tenant_id): + ComplianceRequirementOverview.objects.filter(scan_id=scan_id).delete() - # Bulk create requirement records using PostgreSQL COPY - _persist_compliance_requirement_rows(tenant_id, compliance_requirement_rows) + requirements_created = _persist_compliance_requirement_rows( + tenant_id, _iter_compliance_requirement_rows() + ) # Create pre-aggregated summaries for fast compliance overview lookups _create_compliance_summaries(tenant_id, scan_id, requirement_statuses) return { - "requirements_created": len(compliance_requirement_rows), + "requirements_created": requirements_created, "regions_processed": list(regions), "compliance_frameworks": ( list(compliance_template.keys()) if regions else [] diff --git a/api/src/backend/tasks/jobs/threatscore_utils.py b/api/src/backend/tasks/jobs/threatscore_utils.py index 7be32c6ade..35fb0faeb3 100644 --- a/api/src/backend/tasks/jobs/threatscore_utils.py +++ b/api/src/backend/tasks/jobs/threatscore_utils.py @@ -359,35 +359,40 @@ def _load_findings_for_requirement_checks( def _get_compliance_check_ids(compliance_obj) -> set[str]: """Return the union of all check_ids referenced by a compliance framework. - Used by the master report orchestrator to know which checks each - framework consumes from the shared ``findings_cache``, so that once a - framework finishes the entries no other pending framework needs can be - evicted from the cache (PROWLER-1733). + Used by the master report orchestrator to evict entries from + ``findings_cache`` once no pending framework needs them (PROWLER-1733). - Args: - compliance_obj: A loaded Compliance framework object exposing a - ``Requirements`` iterable, each requirement carrying ``Checks``. - ``None`` is treated as "no checks" rather than raising, so the - caller can pass ``frameworks_bulk.get(...)`` directly without - an extra existence check. - - Returns: - Set of check_id strings (empty if ``compliance_obj`` is ``None``). + Accepts the legacy ``Compliance`` shape (``Requirements`` / ``Checks`` + lists) and the universal ``ComplianceFramework`` shape (``requirements`` + / ``checks`` dict keyed by provider). ``None`` returns an empty set so + callers can pass ``frameworks_bulk.get(...)`` directly. """ if compliance_obj is None: return set() - checks: set[str] = set() - requirements = getattr(compliance_obj, "Requirements", None) or [] + + requirements = getattr(compliance_obj, "Requirements", None) or getattr( + compliance_obj, "requirements", None + ) + if not requirements: + return set() + + check_ids: set[str] = set() try: - # Defensive: Mock objects (used in unit tests) return another Mock - # for any attribute access, which is truthy but not iterable. Treat - # any non-iterable Requirements value as "no checks". - for req in requirements: - req_checks = getattr(req, "Checks", None) or [] + # Mock objects in unit tests return another Mock for any attribute + # access — truthy but not iterable. Treat that as "no checks". + for requirement in requirements: + requirement_checks = getattr(requirement, "Checks", None) + if requirement_checks is None: + checks_by_provider = getattr(requirement, "checks", None) or {} + requirement_checks = [ + check_id + for check_ids_list in checks_by_provider.values() + for check_id in check_ids_list + ] try: - checks.update(req_checks) + check_ids.update(requirement_checks) except TypeError: continue except TypeError: return set() - return checks + return check_ids diff --git a/api/src/backend/tasks/tasks.py b/api/src/backend/tasks/tasks.py index 0fda2999a9..e1e7100ae6 100644 --- a/api/src/backend/tasks/tasks.py +++ b/api/src/backend/tasks/tasks.py @@ -46,6 +46,7 @@ from tasks.jobs.lighthouse_providers import ( refresh_lighthouse_provider_models, ) from tasks.jobs.muting import mute_historical_findings +from tasks.jobs.orphan_recovery import reconcile_orphans from tasks.jobs.report import ( STALE_TMP_OUTPUT_MAX_AGE_HOURS, _cleanup_stale_tmp_output_directories, @@ -67,7 +68,10 @@ from tasks.utils import ( get_next_execution_datetime, ) -from api.compliance import get_compliance_frameworks +from api.compliance import ( + get_compliance_frameworks, + get_prowler_provider_compliance, +) from api.db_router import READ_REPLICA_ALIAS from api.db_utils import delete_related_daily_task, rls_transaction from api.decorators import handle_provider_deletion, set_tenant @@ -75,6 +79,9 @@ from api.models import Finding, Integration, Provider, Scan, ScanSummary, StateC from api.utils import initialize_prowler_provider from api.v1.serializers import ScanTaskSerializer from prowler.lib.check.compliance_models import Compliance +from prowler.lib.outputs.compliance.compliance import ( + process_universal_compliance_frameworks, +) from prowler.lib.outputs.compliance.generic.generic import GenericCompliance from prowler.lib.outputs.finding import Finding as FindingOutput @@ -253,7 +260,9 @@ def delete_provider_task(provider_id: str, tenant_id: str): return delete_provider(tenant_id=tenant_id, pk=provider_id) -@shared_task(base=RLSTask, name="scan-perform", queue="scans") +# acks_late=False: a re-run would duplicate findings and the task is not auto-recovered, +# so a crashed scan is dropped rather than redelivered by the broker (as before #11416). +@shared_task(base=RLSTask, name="scan-perform", queue="scans", acks_late=False) @handle_provider_deletion def perform_scan_task( tenant_id: str, scan_id: str, provider_id: str, checks_to_execute: list[str] = None @@ -297,7 +306,14 @@ def perform_scan_task( return result -@shared_task(base=RLSTask, bind=True, name="scan-perform-scheduled", queue="scans") +# acks_late=False: like scan-perform; a dropped run is re-fired by Beat on the next tick. +@shared_task( + base=RLSTask, + bind=True, + name="scan-perform-scheduled", + queue="scans", + acks_late=False, +) @handle_provider_deletion def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str): """ @@ -462,13 +478,42 @@ def cleanup_stale_attack_paths_scans_task(): return cleanup_stale_attack_paths_scans() +@shared_task(name="reconcile-orphan-tasks", queue="celery") +def reconcile_orphan_tasks_task(): + """Periodic watchdog: recover tasks whose worker is gone (deploys, crashes).""" + return reconcile_orphans() + + @shared_task(name="tenant-deletion", queue="deletion", autoretry_for=(Exception,)) def delete_tenant_task(tenant_id: str): return delete_tenant(pk=tenant_id) +def _scan_tmp_output_directory(tenant_id: str, scan_id: str) -> Path: + """Root tmp output directory for a scan ({tmp}/{tenant_id}/{scan_id}).""" + return Path(DJANGO_TMP_OUTPUT_DIRECTORY) / str(tenant_id) / str(scan_id) + + +class ScanReportRLSTask(RLSTask): + """ + RLS task that removes the scan's tmp output directory when the task fails. + + Covers failures both inside and outside the task body (e.g. ENOSPC mid-write, + or setup errors) so partial artifacts do not accumulate on the worker disk. + """ + + def on_failure(self, exc, task_id, args, kwargs, _einfo): # noqa: ARG002 + del args # Required by Celery's Task.on_failure signature; not used. + tenant_id = kwargs.get("tenant_id") + scan_id = kwargs.get("scan_id") + + if tenant_id and scan_id: + logger.error(f"Scan report task {task_id} failed: {exc}") + rmtree(_scan_tmp_output_directory(tenant_id, scan_id), ignore_errors=True) + + @shared_task( - base=RLSTask, + base=ScanReportRLSTask, name="scan-report", queue="scan-reports", ) @@ -513,11 +558,23 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str): provider_uid = provider_obj.uid provider_type = provider_obj.provider + # Per-framework exporters in `COMPLIANCE_CLASS_MAP` consume the legacy bulk. frameworks_bulk = Compliance.get_bulk(provider_type) + # Universal-only frameworks (top-level JSONs like `dora_2022_2554.json`) are emitted + # via `process_universal_compliance_frameworks` below. + universal_bulk = get_prowler_provider_compliance(provider_type) + universal_only_names = { + name + for name in universal_bulk + if name not in frameworks_bulk and universal_bulk[name].outputs + } frameworks_avail = get_compliance_frameworks(provider_type) out_dir, comp_dir = _generate_output_directory( DJANGO_TMP_OUTPUT_DIRECTORY, provider_uid, tenant_id, scan_id ) + # Removed on success here and on failure by ScanReportRLSTask.on_failure, + # so partial artifacts do not accumulate and fill the disk (ENOSPC). + scan_tmp_dir = _scan_tmp_output_directory(tenant_id, scan_id) def get_writer(writer_map, name, factory, is_last): """ @@ -535,6 +592,10 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str): output_writers = {} compliance_writers = {} + # Shared across batches so universal writers are created once and reused. + universal_compliance_state: dict[str, list] = {"compliance": []} + universal_base_dir = os.path.dirname(out_dir) + universal_output_filename = os.path.basename(out_dir) scan_summary = FindingOutput._transform_findings_stats( ScanSummary.objects.filter(scan_id=scan_id) @@ -589,8 +650,30 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str): writer.batch_write_data_to_file(**extra) writer._data.clear() - # Compliance CSVs + # Universal-only frameworks (e.g. `dora_2022_2554.json`). + if universal_only_names: + process_universal_compliance_frameworks( + input_compliance_frameworks=universal_only_names, + universal_frameworks=universal_bulk, + finding_outputs=fos, + output_directory=universal_base_dir, + output_filename=universal_output_filename, + provider=provider_type, + generated_outputs=universal_compliance_state, + from_cli=False, + is_last=is_last, + ) + + # Compliance CSVs (per-framework exporters). for name in frameworks_avail: + if name in universal_only_names: + continue + if name not in frameworks_bulk: + logger.warning( + "Compliance framework '%s' missing from bulk; skipping CSV export", + name, + ) + continue compliance_obj = frameworks_bulk[name] klass = GenericCompliance @@ -666,7 +749,7 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str): # TODO: We need to create a new periodic task to delete the output files # This task shouldn't be responsible for deleting the output files try: - rmtree(Path(compressed).parent, ignore_errors=True) + rmtree(scan_tmp_dir, ignore_errors=True) except Exception as e: logger.error(f"Error deleting output files: {e}") final_location, did_upload = upload_uri, True @@ -1077,10 +1160,13 @@ def security_hub_integration_task( return upload_security_hub_integration(tenant_id, provider_id, scan_id) +# acks_late=False: Jira sends are not deduplicated and the task is not auto-recovered, +# so a crashed send is dropped rather than redelivered (avoids duplicate Jira issues). @shared_task( base=RLSTask, name="integration-jira", queue="integrations", + acks_late=False, ) def jira_integration_task( tenant_id: str, diff --git a/api/src/backend/tasks/tests/test_orphan_recovery.py b/api/src/backend/tasks/tests/test_orphan_recovery.py new file mode 100644 index 0000000000..abfa920b8e --- /dev/null +++ b/api/src/backend/tasks/tests/test_orphan_recovery.py @@ -0,0 +1,408 @@ +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock, patch +from uuid import uuid4 + +import pytest +from celery import states +from django.test import override_settings +from django_celery_results.models import TaskResult + +from tasks.jobs.orphan_recovery import ( + _decode_celery_field, + _reconcile_task_results, + _recovery_attempt_count, + advisory_lock, + is_worker_alive, + reconcile_orphans, + reenqueueable_tasks, +) + + +def _orphan_result(*, name, kwargs, worker, created_minutes_ago, status=states.STARTED): + """Create a TaskResult mimicking an in-flight task, backdated past the grace.""" + tr = TaskResult.objects.create( + task_id=str(uuid4()), + status=status, + task_name=name, + worker=worker, + task_kwargs=repr(kwargs), + task_args=repr([]), + ) + TaskResult.objects.filter(pk=tr.pk).update( + date_created=datetime.now(tz=timezone.utc) + - timedelta(minutes=created_minutes_ago) + ) + tr.refresh_from_db() + return tr + + +@pytest.mark.django_db +class TestDecodeCeleryField: + def test_decodes_single_encoded_repr(self): + assert _decode_celery_field("{'tenant_id': 'abc'}", {}) == {"tenant_id": "abc"} + + def test_decodes_double_encoded(self): + import json + + stored = json.dumps(repr({"tenant_id": "abc", "scan_id": "s1"})) + assert _decode_celery_field(stored, {}) == {"tenant_id": "abc", "scan_id": "s1"} + + def test_empty_returns_default(self): + assert _decode_celery_field(None, {}) == {} + assert _decode_celery_field("", []) == [] + + def test_unparseable_raises(self): + with pytest.raises(ValueError): + _decode_celery_field("<>", {}) + + +@pytest.mark.django_db +class TestReconcileTaskResults: + def _patches(self, alive): + """Patch worker liveness, revoke, and the task registry for re-enqueue.""" + mock_app = MagicMock() + mock_task = MagicMock() + mock_app.tasks.get.return_value = mock_task + return ( + patch("tasks.jobs.orphan_recovery.is_worker_alive", return_value=alive), + patch("tasks.jobs.orphan_recovery.revoke_task"), + patch("tasks.jobs.orphan_recovery.current_app", mock_app), + mock_task, + ) + + def test_recovers_non_scan_task(self, tenants_fixture): + """A NON-scan task (tenant-deletion) left orphaned is re-enqueued too.""" + tenant = tenants_fixture[0] + tr = _orphan_result( + name="tenant-deletion", + kwargs={"tenant_id": str(tenant.id)}, + worker="dead@gone", + created_minutes_ago=60, + ) + p_alive, p_revoke, p_app, mock_task = self._patches(alive=False) + with ( + p_alive, + p_revoke, + p_app, + patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1), + ): + result = _reconcile_task_results( + grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False + ) + + assert tr.task_id in result["recovered"] + tr.refresh_from_db() + assert tr.status == states.REVOKED # stale result cleared (no pending alert) + mock_task.apply_async.assert_called_once() + call = mock_task.apply_async.call_args.kwargs + assert call["kwargs"] == {"tenant_id": str(tenant.id)} + assert call["task_id"] != tr.task_id # fresh task id + + def test_external_integration_task_is_not_reenqueued_by_default( + self, tenants_fixture + ): + """External side-effect tasks without proven idempotency stay terminal. + + integration-s3 rebuilds its upload from worker-local files that do not + survive the crash, so re-enqueuing it would upload nothing. + """ + tr = _orphan_result( + name="integration-s3", + kwargs={ + "tenant_id": str(tenants_fixture[0].id), + "provider_id": str(uuid4()), + "output_directory": "/tmp/gone", + }, + worker="dead@gone", + created_minutes_ago=60, + ) + p_alive, p_revoke, p_app, mock_task = self._patches(alive=False) + with ( + p_alive, + p_revoke, + p_app, + patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1), + ): + result = _reconcile_task_results( + grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False + ) + + assert tr.task_id in result["failed"] + mock_task.apply_async.assert_not_called() + + @override_settings(TASK_RECOVERY_SUMMARIES_ENABLED=False) + def test_disabled_group_task_is_not_reenqueued(self, tenants_fixture): + """A task whose group feature flag is off stays terminal, not re-enqueued.""" + tr = _orphan_result( + name="scan-summary", + kwargs={ + "tenant_id": str(tenants_fixture[0].id), + "scan_id": str(uuid4()), + }, + worker="dead@gone", + created_minutes_ago=60, + ) + p_alive, p_revoke, p_app, mock_task = self._patches(alive=False) + with ( + p_alive, + p_revoke, + p_app, + patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1), + ): + result = _reconcile_task_results( + grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False + ) + + assert tr.task_id in result["failed"] + mock_task.apply_async.assert_not_called() + + @override_settings(TASK_RECOVERY_SUMMARIES_ENABLED=False) + def test_disabled_group_task_does_not_consume_recovery_attempt( + self, tenants_fixture + ): + """A disabled-group task is failed without incrementing its Valkey attempt + counter, so re-enabling the group does not start it at the cap.""" + tr = _orphan_result( + name="scan-summary", + kwargs={"tenant_id": str(tenants_fixture[0].id), "scan_id": str(uuid4())}, + worker="dead@gone", + created_minutes_ago=60, + ) + p_alive, p_revoke, p_app, mock_task = self._patches(alive=False) + with ( + p_alive, + p_revoke, + p_app, + patch("tasks.jobs.orphan_recovery._recovery_attempt_count") as mock_count, + ): + result = _reconcile_task_results( + grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False + ) + + assert tr.task_id in result["failed"] + mock_count.assert_not_called() + + def test_scan_task_is_skipped_entirely(self, tenants_fixture): + """Scan tasks are excluded from recovery: the watchdog never touches them.""" + tr = _orphan_result( + name="scan-perform", + kwargs={ + "tenant_id": str(tenants_fixture[0].id), + "scan_id": str(uuid4()), + }, + worker="dead@gone", + created_minutes_ago=60, + ) + p_alive, p_revoke, p_app, mock_task = self._patches(alive=False) + with p_alive, p_revoke, p_app: + result = _reconcile_task_results( + grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False + ) + + assert tr.task_id not in result["recovered"] + assert tr.task_id not in result["failed"] + assert tr.task_id not in result["skipped"] + mock_task.apply_async.assert_not_called() + + def test_jira_integration_task_is_not_reenqueued(self, tenants_fixture): + """integration-jira stays terminal: re-running it would create duplicate Jira + issues, so an orphaned send is failed instead of re-enqueued.""" + tenant = tenants_fixture[0] + kwargs = { + "tenant_id": str(tenant.id), + "integration_id": str(uuid4()), + "project_key": "PROWLER", + "issue_type": "Task", + "finding_ids": [str(uuid4()), str(uuid4())], + } + tr = _orphan_result( + name="integration-jira", + kwargs=kwargs, + worker="dead@gone", + created_minutes_ago=60, + ) + p_alive, p_revoke, p_app, mock_task = self._patches(alive=False) + with ( + p_alive, + p_revoke, + p_app, + patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1), + ): + result = _reconcile_task_results( + grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False + ) + + assert tr.task_id in result["failed"] + tr.refresh_from_db() + assert tr.status == states.REVOKED # stale result cleared (no pending alert) + mock_task.apply_async.assert_not_called() + + def test_skips_live_worker(self, tenants_fixture): + tr = _orphan_result( + name="tenant-deletion", + kwargs={"tenant_id": str(tenants_fixture[0].id)}, + worker="alive@host", + created_minutes_ago=60, + ) + p_alive, p_revoke, p_app, mock_task = self._patches(alive=True) + with p_alive, p_revoke, p_app: + result = _reconcile_task_results( + grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False + ) + + assert tr.task_id in result["skipped"] + mock_task.apply_async.assert_not_called() + + def test_skips_recently_created(self, tenants_fixture): + tr = _orphan_result( + name="tenant-deletion", + kwargs={"tenant_id": str(tenants_fixture[0].id)}, + worker="dead@gone", + created_minutes_ago=0, + ) + p_alive, p_revoke, p_app, mock_task = self._patches(alive=False) + with p_alive, p_revoke, p_app: + result = _reconcile_task_results( + grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False + ) + + # too recent: excluded by the grace window (not even a candidate) + assert tr.task_id not in result["recovered"] + mock_task.apply_async.assert_not_called() + + def test_denylisted_task_failed_not_reenqueued(self, tenants_fixture): + """A non-allowlisted task is failed, never blind re-run.""" + tr = _orphan_result( + name="some-non-idempotent-task", + kwargs={"tenant_id": str(tenants_fixture[0].id)}, + worker="dead@gone", + created_minutes_ago=60, + ) + p_alive, p_revoke, p_app, mock_task = self._patches(alive=False) + with ( + p_alive, + p_revoke, + p_app, + patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1), + ): + result = _reconcile_task_results( + grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False + ) + + assert tr.task_id in result["failed"] + tr.refresh_from_db() + assert tr.status == states.REVOKED + mock_task.apply_async.assert_not_called() + + def test_recovery_cap_marks_failed(self, tenants_fixture): + """When the recovery counter exceeds the cap, the task is failed not re-run.""" + tr = _orphan_result( + name="tenant-deletion", + kwargs={"tenant_id": str(tenants_fixture[0].id)}, + worker="dead@gone", + created_minutes_ago=60, + ) + p_alive, p_revoke, p_app, mock_task = self._patches(alive=False) + with ( + p_alive, + p_revoke, + p_app, + patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=4), + ): + result = _reconcile_task_results( + grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False + ) + + assert tr.task_id in result["failed"] + mock_task.apply_async.assert_not_called() + + +@pytest.mark.django_db +class TestOrphanRecoveryHelpers: + def test_advisory_lock_acquires_and_releases(self): + with advisory_lock() as acquired: + assert acquired is True + + def test_is_worker_alive_true_when_responds(self): + inspect = MagicMock() + inspect.ping.return_value = {"w@h": {"ok": "pong"}} + with patch( + "tasks.jobs.orphan_recovery.current_app.control.inspect", + return_value=inspect, + ): + assert is_worker_alive("w@h") is True + + def test_is_worker_alive_false_when_silent(self): + inspect = MagicMock() + inspect.ping.return_value = None + with patch( + "tasks.jobs.orphan_recovery.current_app.control.inspect", + return_value=inspect, + ): + assert is_worker_alive("w@h") is False + + def test_recovery_attempt_count_increments(self): + # Unique signature so the Valkey counter starts fresh for this test. + kwargs_repr = repr({"probe": str(uuid4())}) + redis_client = MagicMock() + redis_client.incr.side_effect = [1, 2] + with patch("redis.from_url", return_value=redis_client): + assert _recovery_attempt_count("probe-task", kwargs_repr, 6) == 1 + assert _recovery_attempt_count("probe-task", kwargs_repr, 6) == 2 + + +class TestRecoveryFeatureFlags: + def test_all_groups_enabled_by_default(self): + tasks = reenqueueable_tasks() + assert "scan-summary" in tasks + assert {"provider-deletion", "tenant-deletion"} <= tasks + + @override_settings(TASK_RECOVERY_SUMMARIES_ENABLED=False) + def test_summaries_group_flag_excludes_summary_tasks(self): + tasks = reenqueueable_tasks() + assert "scan-summary" not in tasks + assert "scan-compliance-overviews" not in tasks + assert "provider-deletion" in tasks + + @override_settings(TASK_RECOVERY_DELETIONS_ENABLED=False) + def test_deletions_group_flag_excludes_deletion_tasks(self): + tasks = reenqueueable_tasks() + assert "provider-deletion" not in tasks + assert "tenant-deletion" not in tasks + assert "scan-summary" in tasks + + +@pytest.mark.django_db +class TestRecoveryMasterFlag: + @override_settings(TASK_RECOVERY_ENABLED=False) + def test_master_flag_disables_task_recovery(self): + with ( + patch( + "tasks.jobs.orphan_recovery._reconcile_task_results" + ) as mock_reconcile, + patch( + "tasks.jobs.attack_paths.cleanup.cleanup_stale_attack_paths_scans", + return_value={}, + ), + ): + result = reconcile_orphans(grace_minutes=2, max_attempts=3, dry_run=False) + + mock_reconcile.assert_not_called() + assert result["acquired"] is True + assert result["enabled"] is False + + @override_settings(TASK_RECOVERY_ENABLED=True) + def test_master_flag_enabled_runs_task_recovery(self): + with ( + patch( + "tasks.jobs.orphan_recovery._reconcile_task_results", + return_value={"recovered": [], "failed": [], "skipped": []}, + ) as mock_reconcile, + patch( + "tasks.jobs.attack_paths.cleanup.cleanup_stale_attack_paths_scans", + return_value={}, + ), + ): + reconcile_orphans(grace_minutes=2, max_attempts=3, dry_run=False) + + mock_reconcile.assert_called_once() diff --git a/api/src/backend/tasks/tests/test_reports_csa.py b/api/src/backend/tasks/tests/test_reports_csa.py index 602b9bb28e..2e61e9ef84 100644 --- a/api/src/backend/tasks/tests/test_reports_csa.py +++ b/api/src/backend/tasks/tests/test_reports_csa.py @@ -80,7 +80,7 @@ def basic_csa_compliance_data(): tenant_id="tenant-123", scan_id="scan-456", provider_id="provider-789", - compliance_id="csa_ccm_4.0_aws", + compliance_id="csa_ccm_4.0", framework="CSA-CCM", name="CSA Cloud Controls Matrix v4.0", version="4.0", diff --git a/api/src/backend/tasks/tests/test_scan.py b/api/src/backend/tasks/tests/test_scan.py index 0a7193cd4d..130d8ae118 100644 --- a/api/src/backend/tasks/tests/test_scan.py +++ b/api/src/backend/tasks/tests/test_scan.py @@ -315,6 +315,7 @@ class TestPerformScan: provider=provider_instance, uid=finding.resource_uid, defaults={ + "name": finding.resource_name, "region": finding.region, "service": finding.service_name, "type": finding.resource_type, @@ -348,6 +349,7 @@ class TestPerformScan: resource_instance = MagicMock() resource_instance.uid = finding.resource_uid + resource_instance.name = "old_name" resource_instance.region = "us-west-1" resource_instance.service = "old_service" resource_instance.type = "old_type" @@ -366,6 +368,7 @@ class TestPerformScan: provider=provider_instance, uid=finding.resource_uid, defaults={ + "name": finding.resource_name, "region": finding.region, "service": finding.service_name, "type": finding.resource_type, @@ -373,6 +376,7 @@ class TestPerformScan: ) # Check that resource fields were updated + assert resource_instance.name == finding.resource_name assert resource_instance.region == finding.region assert resource_instance.service == finding.service_name assert resource_instance.type == finding.resource_type @@ -1565,6 +1569,75 @@ class TestProcessFindingMicroBatch: assert resource_cache[finding.resource_uid].service == finding.service_name assert tag_cache.keys() == {("team", "devsec")} + def test_process_finding_micro_batch_refreshes_empty_resource_name( + self, tenants_fixture, scans_fixture + ): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = scan.provider + + # Old resource stored before names were persisted: empty name. + existing_resource = Resource.objects.create( + tenant_id=tenant.id, + provider=provider, + uid="arn:aws:s3:::my-bucket", + name="", + region="us-east-1", + service="s3", + type="bucket", + ) + + finding = FakeFinding( + uid="finding-empty-name", + status=StatusChoices.PASS, + status_extended="passing", + severity=Severity.low, + check_id="s3_bucket_public_access", + resource_uid=existing_resource.uid, + resource_name="my-bucket", + region="us-east-1", + service_name="s3", + resource_type="bucket", + partition="aws", + raw={"status": "PASS"}, + metadata={"source": "prowler"}, + ) + + resource_cache = {existing_resource.uid: existing_resource} + tag_cache = {} + last_status_cache = {} + resource_failed_findings_cache = {existing_resource.uid: 0} + unique_resources: set[tuple[str, str]] = set() + scan_resource_cache: set[tuple[str, str, str, str]] = set() + mute_rules_cache = {} + scan_categories_cache: dict[tuple[str, str], dict[str, int]] = {} + scan_resource_groups_cache: dict[tuple[str, str], dict[str, int]] = {} + group_resources_cache: dict[str, set] = {} + + with ( + patch("tasks.jobs.scan.rls_transaction", new=noop_rls_transaction), + patch("api.db_utils.rls_transaction", new=noop_rls_transaction), + ): + _process_finding_micro_batch( + str(tenant.id), + [finding], + scan, + provider, + resource_cache, + tag_cache, + last_status_cache, + resource_failed_findings_cache, + unique_resources, + scan_resource_cache, + mute_rules_cache, + scan_categories_cache, + scan_resource_groups_cache, + group_resources_cache, + ) + + existing_resource.refresh_from_db() + assert existing_resource.name == finding.resource_name + def test_process_finding_micro_batch_skips_long_uid( self, tenants_fixture, scans_fixture ): @@ -1880,6 +1953,62 @@ class TestCreateComplianceRequirements: assert "requirements_created" in result + @pytest.mark.django_db(transaction=True) + def test_create_compliance_requirements_idempotent_on_rerun( + self, + tenants_fixture, + scans_fixture, + providers_fixture, + findings_fixture, + ): + """Re-running compliance materialization must not raise nor duplicate rows. + + Uses transaction=True because the COPY path commits on its own connection, + so the test must use real commits (mirroring production) rather than the + default rollback wrapper. + """ + 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 = { + "test_compliance": { + "framework": "Test Framework", + "version": "1.0", + "requirements": { + "req_1": { + "description": "Test Requirement 1", + "checks": {"test_check_id": None}, + "checks_status": { + "pass": 2, + "fail": 1, + "manual": 0, + "total": 3, + }, + "status": "FAIL", + }, + }, + } + } + + create_compliance_requirements(tenant_id, scan_id) + count_after_first = ComplianceRequirementOverview.objects.filter( + scan_id=scan_id + ).count() + + # Second run must not raise and must not duplicate rows. + create_compliance_requirements(tenant_id, scan_id) + count_after_second = ComplianceRequirementOverview.objects.filter( + scan_id=scan_id + ).count() + + assert count_after_first > 0 + assert count_after_second == count_after_first + def test_create_compliance_requirements_kubernetes_provider( self, tenants_fixture, @@ -3545,19 +3674,19 @@ class TestAggregateFindingsByRegion: scan_id = str(uuid.uuid4()) modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0" - # Mock findings with resources - mock_finding1 = MagicMock() - mock_finding1.check_id = "check1" - mock_finding1.status = "FAIL" - mock_finding1.compliance = {modeled_threatscore_compliance_id: ["req1", "req2"]} - - mock_resource1 = MagicMock() - mock_resource1.region = "us-east-1" - mock_finding1.small_resources = [mock_resource1] + # (check_id, status, resource_regions, compliance) tuples + finding_rows = [ + ( + "check1", + "FAIL", + ["us-east-1"], + {modeled_threatscore_compliance_id: ["req1", "req2"]}, + ) + ] mock_queryset = MagicMock() - mock_queryset.only.return_value = mock_queryset - mock_queryset.prefetch_related.return_value = [mock_finding1] + mock_queryset.values_list.return_value = mock_queryset + mock_queryset.iterator.return_value = finding_rows ctx = MagicMock() ctx.__enter__.return_value = None @@ -3571,6 +3700,12 @@ class TestAggregateFindingsByRegion: ) ) + # Streaming query contract: column-scoped values_list + iterator + mock_queryset.values_list.assert_called_once_with( + "check_id", "status", "resource_regions", "compliance" + ) + mock_queryset.iterator.assert_called_once() + # Verify structure of check_status_by_region assert isinstance(check_status_by_region, dict) assert "us-east-1" in check_status_by_region @@ -3590,27 +3725,15 @@ class TestAggregateFindingsByRegion: scan_id = str(uuid.uuid4()) modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0" - # First finding with PASS status - mock_finding1 = MagicMock() - mock_finding1.check_id = "check1" - mock_finding1.status = "PASS" - mock_finding1.compliance = {} - mock_resource1 = MagicMock() - mock_resource1.region = "us-east-1" - mock_finding1.small_resources = [mock_resource1] - - # Second finding with FAIL status for same check/region - mock_finding2 = MagicMock() - mock_finding2.check_id = "check1" - mock_finding2.status = "FAIL" - mock_finding2.compliance = {} - mock_resource2 = MagicMock() - mock_resource2.region = "us-east-1" - mock_finding2.small_resources = [mock_resource2] + # Same check/region: PASS first, then FAIL — FAIL must win + finding_rows = [ + ("check1", "PASS", ["us-east-1"], {}), + ("check1", "FAIL", ["us-east-1"], {}), + ] mock_queryset = MagicMock() - mock_queryset.only.return_value = mock_queryset - mock_queryset.prefetch_related.return_value = [mock_finding1, mock_finding2] + mock_queryset.values_list.return_value = mock_queryset + mock_queryset.iterator.return_value = finding_rows ctx = MagicMock() ctx.__enter__.return_value = None @@ -3622,6 +3745,12 @@ class TestAggregateFindingsByRegion: tenant_id, scan_id, modeled_threatscore_compliance_id ) + # Streaming query contract: column-scoped values_list + iterator + mock_queryset.values_list.assert_called_once_with( + "check_id", "status", "resource_regions", "compliance" + ) + mock_queryset.iterator.assert_called_once() + # FAIL should override PASS assert check_status_by_region["us-east-1"]["check1"] == "FAIL" @@ -3636,8 +3765,8 @@ class TestAggregateFindingsByRegion: modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0" mock_queryset = MagicMock() - mock_queryset.only.return_value = mock_queryset - mock_queryset.prefetch_related.return_value = [] + mock_queryset.values_list.return_value = mock_queryset + mock_queryset.iterator.return_value = [] ctx = MagicMock() ctx.__enter__.return_value = None @@ -3649,6 +3778,12 @@ class TestAggregateFindingsByRegion: tenant_id, scan_id, modeled_threatscore_compliance_id ) + # Streaming query contract: column-scoped values_list + iterator + mock_queryset.values_list.assert_called_once_with( + "check_id", "status", "resource_regions", "compliance" + ) + mock_queryset.iterator.assert_called_once() + # Verify filter was called with muted=False mock_findings_filter.assert_called_once_with( tenant_id=tenant_id, @@ -3667,27 +3802,25 @@ class TestAggregateFindingsByRegion: scan_id = str(uuid.uuid4()) modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0" - # Finding with PASS status - mock_finding1 = MagicMock() - mock_finding1.check_id = "check1" - mock_finding1.status = "PASS" - mock_finding1.compliance = {modeled_threatscore_compliance_id: ["req1"]} - mock_resource1 = MagicMock() - mock_resource1.region = "us-east-1" - mock_finding1.small_resources = [mock_resource1] - - # Finding with FAIL status - mock_finding2 = MagicMock() - mock_finding2.check_id = "check2" - mock_finding2.status = "FAIL" - mock_finding2.compliance = {modeled_threatscore_compliance_id: ["req1"]} - mock_resource2 = MagicMock() - mock_resource2.region = "us-east-1" - mock_finding2.small_resources = [mock_resource2] + # 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"]}, + ), + ] mock_queryset = MagicMock() - mock_queryset.only.return_value = mock_queryset - mock_queryset.prefetch_related.return_value = [mock_finding1, mock_finding2] + mock_queryset.values_list.return_value = mock_queryset + mock_queryset.iterator.return_value = finding_rows ctx = MagicMock() ctx.__enter__.return_value = None @@ -3699,6 +3832,12 @@ class TestAggregateFindingsByRegion: tenant_id, scan_id, modeled_threatscore_compliance_id ) + # Streaming query contract: column-scoped values_list + iterator + mock_queryset.values_list.assert_called_once_with( + "check_id", "status", "resource_regions", "compliance" + ) + mock_queryset.iterator.assert_called_once() + # Verify compliance counts normalized_id = re.sub( r"[^a-z0-9]", "", modeled_threatscore_compliance_id.lower() @@ -3721,27 +3860,15 @@ class TestAggregateFindingsByRegion: scan_id = str(uuid.uuid4()) modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0" - # Finding in us-east-1 - mock_finding1 = MagicMock() - mock_finding1.check_id = "check1" - mock_finding1.status = "FAIL" - mock_finding1.compliance = {} - mock_resource1 = MagicMock() - mock_resource1.region = "us-east-1" - mock_finding1.small_resources = [mock_resource1] - - # Finding in us-west-2 - mock_finding2 = MagicMock() - mock_finding2.check_id = "check1" - mock_finding2.status = "PASS" - mock_finding2.compliance = {} - mock_resource2 = MagicMock() - mock_resource2.region = "us-west-2" - mock_finding2.small_resources = [mock_resource2] + # One finding per region + finding_rows = [ + ("check1", "FAIL", ["us-east-1"], {}), + ("check1", "PASS", ["us-west-2"], {}), + ] mock_queryset = MagicMock() - mock_queryset.only.return_value = mock_queryset - mock_queryset.prefetch_related.return_value = [mock_finding1, mock_finding2] + mock_queryset.values_list.return_value = mock_queryset + mock_queryset.iterator.return_value = finding_rows ctx = MagicMock() ctx.__enter__.return_value = None @@ -3753,6 +3880,12 @@ class TestAggregateFindingsByRegion: tenant_id, scan_id, modeled_threatscore_compliance_id ) + # Streaming query contract: column-scoped values_list + iterator + mock_queryset.values_list.assert_called_once_with( + "check_id", "status", "resource_regions", "compliance" + ) + mock_queryset.iterator.assert_called_once() + # Verify both regions are present with correct statuses assert "us-east-1" in check_status_by_region assert "us-west-2" in check_status_by_region @@ -3761,17 +3894,26 @@ class TestAggregateFindingsByRegion: @patch("tasks.jobs.scan.Finding.all_objects.filter") @patch("tasks.jobs.scan.rls_transaction") - def test_aggregate_findings_by_region_empty_findings( + def test_aggregate_findings_by_region_multi_region_finding( self, mock_rls_transaction, mock_findings_filter ): - """Test with no findings - should return empty dicts.""" + """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" + finding_rows = [ + ( + "check1", + "FAIL", + ["us-east-1", "eu-west-1"], + {modeled_threatscore_compliance_id: ["req1"]}, + ) + ] + mock_queryset = MagicMock() - mock_queryset.only.return_value = mock_queryset - mock_queryset.prefetch_related.return_value = [] + mock_queryset.values_list.return_value = mock_queryset + mock_queryset.iterator.return_value = finding_rows ctx = MagicMock() ctx.__enter__.return_value = None @@ -3785,6 +3927,92 @@ class TestAggregateFindingsByRegion: ) ) + # Streaming query contract: column-scoped values_list + iterator + mock_queryset.values_list.assert_called_once_with( + "check_id", "status", "resource_regions", "compliance" + ) + 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"] + assert req_stats == {"total": 1, "pass": 0} + + @patch("tasks.jobs.scan.Finding.all_objects.filter") + @patch("tasks.jobs.scan.rls_transaction") + def test_aggregate_findings_by_region_skips_empty_regions( + self, mock_rls_transaction, mock_findings_filter + ): + """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" + + finding_rows = [ + ("check1", "FAIL", [], {modeled_threatscore_compliance_id: ["req1"]}), + ("check2", "PASS", None, {}), + ] + + mock_queryset = MagicMock() + mock_queryset.values_list.return_value = mock_queryset + mock_queryset.iterator.return_value = finding_rows + + ctx = MagicMock() + ctx.__enter__.return_value = None + ctx.__exit__.return_value = False + mock_rls_transaction.return_value = ctx + mock_findings_filter.return_value = mock_queryset + + check_status_by_region, findings_count_by_compliance = ( + _aggregate_findings_by_region( + tenant_id, scan_id, modeled_threatscore_compliance_id + ) + ) + + # Streaming query contract: column-scoped values_list + iterator + mock_queryset.values_list.assert_called_once_with( + "check_id", "status", "resource_regions", "compliance" + ) + mock_queryset.iterator.assert_called_once() + + assert check_status_by_region == {} + assert findings_count_by_compliance == {} + + @patch("tasks.jobs.scan.Finding.all_objects.filter") + @patch("tasks.jobs.scan.rls_transaction") + def test_aggregate_findings_by_region_empty_findings( + self, mock_rls_transaction, mock_findings_filter + ): + """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" + + mock_queryset = MagicMock() + mock_queryset.values_list.return_value = mock_queryset + mock_queryset.iterator.return_value = [] + + ctx = MagicMock() + ctx.__enter__.return_value = None + ctx.__exit__.return_value = False + mock_rls_transaction.return_value = ctx + mock_findings_filter.return_value = mock_queryset + + check_status_by_region, findings_count_by_compliance = ( + _aggregate_findings_by_region( + tenant_id, scan_id, modeled_threatscore_compliance_id + ) + ) + + # Streaming query contract: column-scoped values_list + iterator + mock_queryset.values_list.assert_called_once_with( + "check_id", "status", "resource_regions", "compliance" + ) + mock_queryset.iterator.assert_called_once() + assert check_status_by_region == {} assert findings_count_by_compliance == {} diff --git a/api/src/backend/tasks/tests/test_tasks.py b/api/src/backend/tasks/tests/test_tasks.py index f15bdf7192..67d2c64555 100644 --- a/api/src/backend/tasks/tests/test_tasks.py +++ b/api/src/backend/tasks/tests/test_tasks.py @@ -15,8 +15,10 @@ from tasks.jobs.lighthouse_providers import ( from tasks.tasks import ( DJANGO_TMP_OUTPUT_DIRECTORY, STALE_TMP_OUTPUT_MAX_AGE_HOURS, + ScanReportRLSTask, _cleanup_orphan_scheduled_scans, _perform_scan_complete_tasks, + _scan_tmp_output_directory, check_integrations_task, check_lighthouse_provider_connection_task, generate_outputs_task, @@ -321,6 +323,7 @@ class TestGenerateOutputs: mock_transformed_stats = {"some": "stats"} with ( + patch("tasks.tasks.get_prowler_provider_compliance", return_value={}), patch( "tasks.tasks.FindingOutput._transform_findings_stats", return_value=mock_transformed_stats, @@ -439,6 +442,7 @@ class TestGenerateOutputs: mock_provider.uid = "test-provider-uid" with ( + patch("tasks.tasks.get_prowler_provider_compliance", return_value={}), patch("tasks.tasks.ScanSummary.objects.filter") as mock_filter, patch("tasks.tasks.Provider.objects.get", return_value=mock_provider), patch("tasks.tasks.initialize_prowler_provider"), @@ -594,6 +598,7 @@ class TestGenerateOutputs: ] with ( + patch("tasks.tasks.get_prowler_provider_compliance", return_value={}), patch("tasks.tasks.ScanSummary.objects.filter") as mock_summary, patch( "tasks.tasks.Provider.objects.get", @@ -668,6 +673,7 @@ class TestGenerateOutputs: mock_provider.uid = "test-provider-uid" with ( + patch("tasks.tasks.get_prowler_provider_compliance", return_value={}), patch("tasks.tasks.ScanSummary.objects.filter") as mock_filter, patch("tasks.tasks.Provider.objects.get", return_value=mock_provider), patch("tasks.tasks.initialize_prowler_provider"), @@ -771,6 +777,38 @@ class TestGenerateOutputs: mock_s3_task.assert_called_once() +class TestScanReportRLSTaskOnFailure: + def test_on_failure_removes_scan_tmp_directory(self): + task = ScanReportRLSTask() + + with patch("tasks.tasks.rmtree") as mock_rmtree: + task.on_failure( + exc=OSError("No space left on device"), + task_id="task-abc", + args=(), + kwargs={"tenant_id": "t-1", "scan_id": "s-1"}, + _einfo=None, + ) + + mock_rmtree.assert_called_once_with( + _scan_tmp_output_directory("t-1", "s-1"), ignore_errors=True + ) + + def test_on_failure_skips_when_missing_kwargs(self): + task = ScanReportRLSTask() + + with patch("tasks.tasks.rmtree") as mock_rmtree: + task.on_failure( + exc=OSError("No space left on device"), + task_id="task-abc", + args=(), + kwargs={}, + _einfo=None, + ) + + mock_rmtree.assert_not_called() + + class TestScanCompleteTasks: @patch("tasks.tasks.aggregate_attack_surface_task.apply_async") @patch("tasks.tasks.chain") @@ -1079,6 +1117,7 @@ class TestCheckIntegrationsTask: enabled=True, ) + @patch("tasks.tasks.get_prowler_provider_compliance", return_value={}) @patch("tasks.tasks.s3_integration_task") @patch("tasks.tasks.Integration.objects.filter") @patch("tasks.tasks.ScanSummary.objects.filter") @@ -1111,6 +1150,7 @@ class TestCheckIntegrationsTask: mock_scan_summary, mock_integration_filter, mock_s3_task, + mock_get_prowler_compliance, ): """Test that ASFF output is generated for AWS providers with SecurityHub integration.""" # Setup @@ -1207,6 +1247,7 @@ class TestCheckIntegrationsTask: assert result == {"upload": True} + @patch("tasks.tasks.get_prowler_provider_compliance", return_value={}) @patch("tasks.tasks.s3_integration_task") @patch("tasks.tasks.Integration.objects.filter") @patch("tasks.tasks.ScanSummary.objects.filter") @@ -1239,6 +1280,7 @@ class TestCheckIntegrationsTask: mock_scan_summary, mock_integration_filter, mock_s3_task, + mock_get_prowler_compliance, ): """Test that ASFF output is NOT generated for AWS providers without SecurityHub integration.""" # Setup @@ -1332,6 +1374,7 @@ class TestCheckIntegrationsTask: assert result == {"upload": True} + @patch("tasks.tasks.get_prowler_provider_compliance", return_value={}) @patch("tasks.tasks.ScanSummary.objects.filter") @patch("tasks.tasks.Provider.objects.get") @patch("tasks.tasks.initialize_prowler_provider") @@ -1360,6 +1403,7 @@ class TestCheckIntegrationsTask: mock_initialize_provider, mock_provider_get, mock_scan_summary, + mock_get_prowler_compliance, ): """Test that ASFF output is NOT generated for non-AWS providers (e.g., Azure, GCP).""" # Setup @@ -2672,3 +2716,36 @@ class TestReaggregateAllFindingGroupSummaries: assert result == {"scans_reaggregated": 0} mock_group.assert_not_called() mock_chain.assert_not_called() + + +class TestTaskTimeLimits: + """The per-task limits in task_annotations must actually take effect. + + Celery applies a "*" annotation after the per-task one, so a "*" entry would + silently overwrite every specific limit and cap long scans at the default. The + default is set as the global limit instead, and these per-task limits must win. + """ + + def test_long_running_tasks_exceed_the_default_limit(self): + from config.celery import celery_app + + default = celery_app.conf.task_time_limit + for name in ( + "scan-perform", + "scan-perform-scheduled", + "provider-deletion", + "tenant-deletion", + ): + assert celery_app.tasks[name].time_limit > default + + def test_connection_checks_stay_below_the_default_limit(self): + from config.celery import celery_app + + default = celery_app.conf.task_time_limit + for name in ( + "provider-connection-check", + "integration-connection-check", + "lighthouse-connection-check", + "lighthouse-provider-connection-check", + ): + assert celery_app.tasks[name].time_limit < default diff --git a/api/uv.lock b/api/uv.lock index d90eff5fdf..f96318c1f4 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -16,7 +16,7 @@ constraints = [ { name = "aiobotocore", specifier = "==2.25.1" }, { name = "aiofiles", specifier = "==24.1.0" }, { name = "aiohappyeyeballs", specifier = "==2.6.1" }, - { name = "aiohttp", specifier = "==3.13.5" }, + { name = "aiohttp", specifier = "==3.14.0" }, { name = "aioitertools", specifier = "==0.13.0" }, { name = "aiosignal", specifier = "==1.4.0" }, { name = "alibabacloud-actiontrail20200706", specifier = "==2.4.1" }, @@ -61,9 +61,8 @@ constraints = [ { name = "astroid", specifier = "==3.2.4" }, { name = "async-timeout", specifier = "==5.0.1" }, { name = "attrs", specifier = "==25.4.0" }, - { name = "authlib", specifier = "==1.6.9" }, + { name = "authlib", specifier = "==1.6.12" }, { name = "autopep8", specifier = "==2.3.2" }, - { name = "awsipranges", specifier = "==0.3.3" }, { name = "azure-cli-core", specifier = "==2.83.0" }, { name = "azure-cli-telemetry", specifier = "==1.1.0" }, { name = "azure-common", specifier = "==1.1.28" }, @@ -146,6 +145,7 @@ constraints = [ { name = "django-celery-results", specifier = "==2.6.0" }, { name = "django-cors-headers", specifier = "==4.4.0" }, { name = "django-environ", specifier = "==0.11.2" }, + { name = "django-eventstream", specifier = "==5.3.3" }, { name = "django-filter", specifier = "==24.3" }, { name = "django-guid", specifier = "==3.5.0" }, { name = "django-postgres-extra", specifier = "==2.0.9" }, @@ -163,7 +163,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 = "==0.23.0" }, + { name = "dulwich", specifier = "==1.2.5" }, { name = "duo-client", specifier = "==5.5.0" }, { name = "durationpy", specifier = "==0.10" }, { name = "email-validator", specifier = "==2.2.0" }, @@ -190,7 +190,7 @@ constraints = [ { name = "grpc-google-iam-v1", specifier = "==0.14.3" }, { name = "grpcio", specifier = "==1.76.0" }, { name = "grpcio-status", specifier = "==1.76.0" }, - { name = "gunicorn", specifier = "==23.0.0" }, + { name = "gunicorn", specifier = "==26.0.0" }, { name = "h11", specifier = "==0.16.0" }, { name = "h2", specifier = "==4.3.0" }, { name = "hpack", specifier = "==4.1.0" }, @@ -199,8 +199,8 @@ constraints = [ { name = "httpx", specifier = "==0.28.1" }, { name = "humanfriendly", specifier = "==10.0" }, { name = "hyperframe", specifier = "==6.1.0" }, - { name = "iamdata", specifier = "==0.1.202602021" }, - { name = "idna", specifier = "==3.11" }, + { name = "iamdata", specifier = "==0.1.202605131" }, + { name = "idna", specifier = "==3.15" }, { name = "importlib-metadata", specifier = "==8.7.1" }, { name = "inflection", specifier = "==0.5.1" }, { name = "iniconfig", specifier = "==2.3.0" }, @@ -252,7 +252,7 @@ constraints = [ { name = "neo4j", specifier = "==6.1.0" }, { name = "nest-asyncio", specifier = "==1.6.0" }, { name = "nltk", specifier = "==3.9.4" }, - { name = "numpy", specifier = "==2.0.2" }, + { name = "numpy", specifier = "==2.2.6" }, { name = "oauthlib", specifier = "==3.3.1" }, { name = "oci", specifier = "==2.169.0" }, { name = "openai", specifier = "==1.109.1" }, @@ -281,7 +281,7 @@ constraints = [ { name = "psutil", specifier = "==7.2.2" }, { name = "psycopg2-binary", specifier = "==2.9.9" }, { name = "py-deviceid", specifier = "==0.1.1" }, - { name = "py-iam-expand", specifier = "==0.1.0" }, + { name = "py-iam-expand", specifier = "==0.3.0" }, { name = "py-ocsf-models", specifier = "==0.8.1" }, { name = "pyasn1", specifier = "==0.6.3" }, { name = "pyasn1-modules", specifier = "==0.4.2" }, @@ -291,7 +291,7 @@ constraints = [ { name = "pydantic-core", specifier = "==2.41.5" }, { name = "pygithub", specifier = "==2.8.0" }, { name = "pygments", specifier = "==2.20.0" }, - { name = "pyjwt", specifier = "==2.12.1" }, + { name = "pyjwt", specifier = "==2.13.0" }, { name = "pylint", specifier = "==3.2.5" }, { name = "pymsalruntime", specifier = "==0.18.1" }, { name = "pynacl", specifier = "==1.6.2" }, @@ -357,6 +357,7 @@ constraints = [ { name = "uritemplate", specifier = "==4.2.0" }, { name = "urllib3", specifier = "==2.7.0" }, { name = "uuid6", specifier = "==2024.7.10" }, + { name = "uvloop", specifier = "==0.22.1" }, { name = "vine", specifier = "==5.1.0" }, { name = "vulture", specifier = "==2.14" }, { name = "wcwidth", specifier = "==0.5.3" }, @@ -374,8 +375,10 @@ constraints = [ { name = "zstd", specifier = "==1.5.7.3" }, ] overrides = [ + { name = "dulwich", specifier = "==1.2.5" }, { name = "microsoft-kiota-abstractions", specifier = "==1.9.9" }, { name = "okta", specifier = "==3.4.2" }, + { name = "pyjwt", extras = ["crypto"], specifier = "==2.13.0" }, ] [[package]] @@ -393,7 +396,7 @@ version = "1.2.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, - { name = "pyjwt" }, + { name = "pyjwt", extra = ["crypto"] }, { name = "python-dateutil" }, { name = "requests" }, ] @@ -467,7 +470,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.5" +version = "3.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -476,44 +479,47 @@ dependencies = [ { name = "frozenlist" }, { name = "multidict" }, { name = "propcache" }, + { name = "typing-extensions" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/ab/93ce242f899b68c51b0578c027aafa791ab3614cb9345fa5d37b5f5c8e3e/aiohttp-3.14.0.tar.gz", hash = "sha256:2882de819734c715fd1b9c11c97e09fa020d14438203d1d354d8ed1702791c9b", size = 7940674, upload-time = "2026-06-01T19:41:02.763Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/f5/a20c4ac64aeaef1679e25c9983573618ff765d7aa829fa2b84ae7573169e/aiohttp-3.13.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ab7229b6f9b5c1ba4910d6c41a9eb11f543eadb3f384df1b4c293f4e73d44d6", size = 757513, upload-time = "2026-03-31T21:57:02.146Z" }, - { url = "https://files.pythonhosted.org/packages/75/0a/39fa6c6b179b53fcb3e4b3d2b6d6cad0180854eda17060c7218540102bef/aiohttp-3.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8f14c50708bb156b3a3ca7230b3d820199d56a48e3af76fa21c2d6087190fe3d", size = 506748, upload-time = "2026-03-31T21:57:04.275Z" }, - { url = "https://files.pythonhosted.org/packages/87/ec/e38ce072e724fd7add6243613f8d1810da084f54175353d25ccf9f9c7e5a/aiohttp-3.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7d2f8616f0ff60bd332022279011776c3ac0faa0f1b463f7bb12326fbc97a1c", size = 501673, upload-time = "2026-03-31T21:57:06.208Z" }, - { url = "https://files.pythonhosted.org/packages/ba/ba/3bc7525d7e2beaa11b309a70d48b0d3cfc3c2089ec6a7d0820d59c657053/aiohttp-3.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2567b72e1ffc3ab25510db43f355b29eeada56c0a622e58dcdb19530eb0a3cb", size = 1763757, upload-time = "2026-03-31T21:57:07.882Z" }, - { url = "https://files.pythonhosted.org/packages/5e/ab/e87744cf18f1bd78263aba24924d4953b41086bd3a31d22452378e9028a0/aiohttp-3.13.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fb0540c854ac9c0c5ad495908fdfd3e332d553ec731698c0e29b1877ba0d2ec6", size = 1720152, upload-time = "2026-03-31T21:57:09.946Z" }, - { url = "https://files.pythonhosted.org/packages/6b/f3/ed17a6f2d742af17b50bae2d152315ed1b164b07a5fd5cc1754d99e4dfa5/aiohttp-3.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9883051c6972f58bfc4ebb2116345ee2aa151178e99c3f2b2bbe2af712abd13", size = 1818010, upload-time = "2026-03-31T21:57:12.157Z" }, - { url = "https://files.pythonhosted.org/packages/53/06/ecbc63dc937192e2a5cb46df4d3edb21deb8225535818802f210a6ea5816/aiohttp-3.13.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2294172ce08a82fb7c7273485895de1fa1186cc8294cfeb6aef4af42ad261174", size = 1907251, upload-time = "2026-03-31T21:57:14.023Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a5/0521aa32c1ddf3aa1e71dcc466be0b7db2771907a13f18cddaa45967d97b/aiohttp-3.13.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a807cabd5115fb55af198b98178997a5e0e57dead43eb74a93d9c07d6d4a7dc", size = 1759969, upload-time = "2026-03-31T21:57:16.146Z" }, - { url = "https://files.pythonhosted.org/packages/f6/78/a38f8c9105199dd3b9706745865a8a59d0041b6be0ca0cc4b2ccf1bab374/aiohttp-3.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6d0d932e0f39c02b80744273cd5c388a2d9bc07760a03164f229c8e02662f6", size = 1616871, upload-time = "2026-03-31T21:57:17.856Z" }, - { url = "https://files.pythonhosted.org/packages/6f/41/27392a61ead8ab38072105c71aa44ff891e71653fe53d576a7067da2b4e8/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60869c7ac4aaabe7110f26499f3e6e5696eae98144735b12a9c3d9eae2b51a49", size = 1739844, upload-time = "2026-03-31T21:57:19.679Z" }, - { url = "https://files.pythonhosted.org/packages/6e/55/5564e7ae26d94f3214250009a0b1c65a0c6af4bf88924ccb6fdab901de28/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:26d2f8546f1dfa75efa50c3488215a903c0168d253b75fba4210f57ab77a0fb8", size = 1731969, upload-time = "2026-03-31T21:57:22.006Z" }, - { url = "https://files.pythonhosted.org/packages/6d/c5/705a3929149865fc941bcbdd1047b238e4a72bcb215a9b16b9d7a2e8d992/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1162a1492032c82f14271e831c8f4b49f2b6078f4f5fc74de2c912fa225d51d", size = 1795193, upload-time = "2026-03-31T21:57:24.256Z" }, - { url = "https://files.pythonhosted.org/packages/a6/19/edabed62f718d02cff7231ca0db4ef1c72504235bc467f7b67adb1679f48/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8b14eb3262fad0dc2f89c1a43b13727e709504972186ff6a99a3ecaa77102b6c", size = 1606477, upload-time = "2026-03-31T21:57:26.364Z" }, - { url = "https://files.pythonhosted.org/packages/de/fc/76f80ef008675637d88d0b21584596dc27410a990b0918cb1e5776545b5b/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ca9ac61ac6db4eb6c2a0cd1d0f7e1357647b638ccc92f7e9d8d133e71ed3c6ac", size = 1813198, upload-time = "2026-03-31T21:57:28.316Z" }, - { url = "https://files.pythonhosted.org/packages/e5/67/5b3ac26b80adb20ea541c487f73730dc8fa107d632c998f25bbbab98fcda/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7996023b2ed59489ae4762256c8516df9820f751cf2c5da8ed2fb20ee50abab3", size = 1752321, upload-time = "2026-03-31T21:57:30.549Z" }, - { url = "https://files.pythonhosted.org/packages/88/06/e4a2e49255ea23fa4feeb5ab092d90240d927c15e47b5b5c48dff5a9ce29/aiohttp-3.13.5-cp311-cp311-win32.whl", hash = "sha256:77dfa48c9f8013271011e51c00f8ada19851f013cde2c48fca1ba5e0caf5bb06", size = 439069, upload-time = "2026-03-31T21:57:32.388Z" }, - { url = "https://files.pythonhosted.org/packages/c0/43/8c7163a596dab4f8be12c190cf467a1e07e4734cf90eebb39f7f5d53fc6a/aiohttp-3.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:d3a4834f221061624b8887090637db9ad4f61752001eae37d56c52fddade2dc8", size = 462859, upload-time = "2026-03-31T21:57:34.455Z" }, - { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, - { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, - { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, - { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, - { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, - { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, - { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, - { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, - { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, - { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, - { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, - { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, - { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, - { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, - { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, + { url = "https://files.pythonhosted.org/packages/67/47/7727bfe8db93f8835a001bd4359d8480cc68d1259b8bce334668f8be97bd/aiohttp-3.14.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:54bf3522d6f7351e55f89a62d5c2bf138ad557b031670266c5df604ae88e0b5a", size = 759147, upload-time = "2026-06-01T19:37:12.918Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f2/cd3fedff6fade73d71df9ec908c210cec518ef90fd00289250684b90aecf/aiohttp-3.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0746d9fb0ac4fdef643a84494efe3f06d50335dd8c7a530228b86448aae0a803", size = 513705, upload-time = "2026-06-01T19:37:14.633Z" }, + { url = "https://files.pythonhosted.org/packages/5a/fe/49746b6b610144a06323bebd8e1211a390310d8c69b98dd6d52df341bc3e/aiohttp-3.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f3a96b6d39a4872222beee72e1df41d2ff886ae96152cf3e757ef8c5673ef0e", size = 509627, upload-time = "2026-06-01T19:37:16.385Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3f/28f2f6cf3d5c0e7b01b27140d0e7873fd11fb341169ad3ce78ad04aba628/aiohttp-3.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d336820adbb914debbc90a1d8c1bfc4bea55996aecf64866a989d35d1f9fd903", size = 1769293, upload-time = "2026-06-01T19:37:18.067Z" }, + { url = "https://files.pythonhosted.org/packages/97/6f/2e5f1b525d5474b12b3c60abf733a755845f3bceff21542081ada515f837/aiohttp-3.14.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:71b2604c9bfc1b115547d63a094d5244b3f02799833513a99a68aaa7b167c4cb", size = 1732363, upload-time = "2026-06-01T19:37:20.138Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ce/596120faa85ca7b19cd061e3f2f3be23aa8f11a0aedf9191db9e0da1bd76/aiohttp-3.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:610d68800435903e303ca0542b9d3e4eb72a12ff33a6d471a070c1d81eebd3c2", size = 1840375, upload-time = "2026-06-01T19:37:22.104Z" }, + { url = "https://files.pythonhosted.org/packages/72/3c/a7ffe05a757a4a7867643da69357ec41f506879fbd1b231d2ed90af246b2/aiohttp-3.14.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:514db9a79337068981ee2137310283a07b4b885c584991097a91a4da419bcb81", size = 1921484, upload-time = "2026-06-01T19:37:24.068Z" }, + { url = "https://files.pythonhosted.org/packages/93/fa/2c861170bbd4a491de93a69e081db1d971092569e0d593a98ef62c384dc1/aiohttp-3.14.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c452d17eeb95d563fc8b936f3050301dbd1d268126c4632d8b70ede9696202ee", size = 1774153, upload-time = "2026-06-01T19:37:26.256Z" }, + { url = "https://files.pythonhosted.org/packages/9d/da/1d2f5a165f47ec9b1f69d37b8b977fdc4d501aa72ffb7930db27bb9e49ea/aiohttp-3.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ed94a81506e3d1bdbad5108f497a58f2a2354aedb4ca314d5326f07d1fd1ac2d", size = 1632569, upload-time = "2026-06-01T19:37:28.192Z" }, + { url = "https://files.pythonhosted.org/packages/46/1d/7a6e295c4257252f70f69e90864fdad74b6a1293054fb3f9e65a15de6d63/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1394dce36e0f0d260ac0b555a654de19cb989f3c1b8bdd24f505314dfea18a00", size = 1740325, upload-time = "2026-06-01T19:37:30.08Z" }, + { url = "https://files.pythonhosted.org/packages/f1/7e/e1899b1ca3ec62f1eab2a5cbde14039b97493f7f53eb88d9b668562ffa8d/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d1467d1e7b48a73ca7237e0ee4335f3d02b923dbc27b82fd254bc301c97d4026", size = 1748691, upload-time = "2026-06-01T19:37:32.211Z" }, + { url = "https://files.pythonhosted.org/packages/ec/54/4e6b61c1fe7d3433f82bcc6bd7e4d7c683a742a10c9b12a025fd3695c047/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6a5f3532125233c261cf61f32df4059cfcf482eb793c7d3db8452e3142028b86", size = 1814477, upload-time = "2026-06-01T19:37:34.173Z" }, + { url = "https://files.pythonhosted.org/packages/9c/38/86fd51be2e08d8e45c83d879d255f10391903cd9fe2a16512f7591a15873/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3ea81eb518a2ecb319d8ec6d1424a37c773f6634bd87d6985eb606b2faac419f", size = 1623393, upload-time = "2026-06-01T19:37:36.281Z" }, + { url = "https://files.pythonhosted.org/packages/78/49/466e947a42a88ee23c486d036e7e5d1b097f1bafd8084ad9c9a0a92f0f43/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:32e735c3182de7b64f6941a4ede48b38c7f47d9437bd615dd30b5bda8fa1bc93", size = 1824097, upload-time = "2026-06-01T19:37:38.421Z" }, + { url = "https://files.pythonhosted.org/packages/f3/89/35f3410bc284682338a1be6b6ea0c5abfa05f063942cfaa9256608440434/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c21ca9a1c63d4509158f478aeb9d02914dcc52adc68d1bc9dee2452284ee5996", size = 1764790, upload-time = "2026-06-01T19:37:40.755Z" }, + { url = "https://files.pythonhosted.org/packages/42/80/2d4291bd5724d3d17e5951aff5a3e02281483fb47295f0788276ee66cd73/aiohttp-3.14.0-cp311-cp311-win32.whl", hash = "sha256:19ca5fc84130675ba11c6ca5c7da5cb65f7bf8a32cdd2b616bf49cd334688aae", size = 454176, upload-time = "2026-06-01T19:37:42.837Z" }, + { url = "https://files.pythonhosted.org/packages/59/ed/41d0ad4f6ececffc32bdf1f7b494e5498f7ca5c849ea2e3cc9bbd1668251/aiohttp-3.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:d488e6e9d3bb8ba5ae7066d5be885ae9670eba021b8c6ccb9a3a568e6b19d6e5", size = 479334, upload-time = "2026-06-01T19:37:44.776Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/c0b5e305c770053f8c3d069bb52b8196917ba91949d1962d52eb307fb0d2/aiohttp-3.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:8b93618102caf12801638a01a2b478a55410ddd71bd41cfaf6f707953a49ac43", size = 450262, upload-time = "2026-06-01T19:37:46.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/97/2b6889bfb6b6847520d50d95eb8c4307a45e28aaca39faf4a9454b3d1b2f/aiohttp-3.14.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b29518c9c2ec7e373e68259206a137c7f4f5439c58baaec4b5ab3ab799850a4e", size = 750194, upload-time = "2026-06-01T19:37:48.164Z" }, + { url = "https://files.pythonhosted.org/packages/21/e2/62634b7fff918ed98c3c6b2f0e70d520f7f28846cb412d451b04354c6459/aiohttp-3.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dbec68ce61b64cb73cab4d33df9433427b1713c8bcccb181dce695c1b6f8e87c", size = 506966, upload-time = "2026-06-01T19:37:50.014Z" }, + { url = "https://files.pythonhosted.org/packages/dd/fb/5ce075150828c797a5106f1c2fb26034e709d4289b9d2bf8b07f1e59fac6/aiohttp-3.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3cdf534aa455593e589302990c5097aa5c92c06c4262a20da22934f9186a5fff", size = 507527, upload-time = "2026-06-01T19:37:51.96Z" }, + { url = "https://files.pythonhosted.org/packages/01/d5/405a0ae4e6b081754a3609c1c97c63a950e000a2def16046f1e736933a0e/aiohttp-3.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb6c657104393b5fbff01a5f59b2023db74058a8077d94475d6c25d03882a108", size = 1762420, upload-time = "2026-06-01T19:37:53.839Z" }, + { url = "https://files.pythonhosted.org/packages/ae/1d/e05a7c896b15a6bc6fb8fc5319eb437861c2c49c34559ef928add6590315/aiohttp-3.14.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:46fbbec4e4fab7428d4396a3823f9320e4560aa3113b89eeebce712c27c9ed5a", size = 1733672, upload-time = "2026-06-01T19:37:55.791Z" }, + { url = "https://files.pythonhosted.org/packages/cc/22/a72f7c459e195fa41bf4f7abd1f925b91fe91f8097e51c654229ba144a33/aiohttp-3.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c2c7e05dd5335b298085abf45ddf98673934c3ee1c083d0b9ea13d4186ad500", size = 1805064, upload-time = "2026-06-01T19:37:57.931Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/e85bdaba0be59ca4838005ebfef4048fcdd5f35a02b07057a9a123394440/aiohttp-3.14.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3c7139100fbaae76515b73051d8f0aa3a3ff02e415eec8a8eee8e2223d9ba955", size = 1902125, upload-time = "2026-06-01T19:38:00.225Z" }, + { url = "https://files.pythonhosted.org/packages/19/d8/51de5c6b971c27bb1ef620293b8d1ca611ec78736b34b3f6ccf68e4c8785/aiohttp-3.14.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78d6f9286a629ce52728430afe18f8ed2b6c39a1fddb3802d7244b9983910ad2", size = 1783112, upload-time = "2026-06-01T19:38:02.641Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/b4402bfde77e43dfb1b6ccff83c7b7ab63ed06b50c4754f0c5423fb374fe/aiohttp-3.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3c3e12cdaeb92d7dcf13db00e9f6b1956b910e47256e696df1cfa946d02159", size = 1586356, upload-time = "2026-06-01T19:38:04.637Z" }, + { url = "https://files.pythonhosted.org/packages/bc/05/750a3265ca4dc54a460bd0cb1121a8f2ce9171fce4a135fb47ea7fd594d2/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4d6a998191f5ebe3b8c28463ff72bc030250008b3193c402464efadd08b5ca02", size = 1723119, upload-time = "2026-06-01T19:38:06.713Z" }, + { url = "https://files.pythonhosted.org/packages/37/01/8c0812c50b3b1b1c37b323bf170d6be8847a8f234060485b7d1e71953f60/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0fc2b75ae8d169d853be2862d960be8550da6c5c65711d5476407eb3fdb006bd", size = 1757216, upload-time = "2026-06-01T19:38:08.736Z" }, + { url = "https://files.pythonhosted.org/packages/47/2a/50fb98028a26887cbe48dcc1df92a90825615bc73b5584301304090cded8/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:16eee56bcc72d04600bc56c1759982c2385ec0b41d3fd3521f836bf64a0957ef", size = 1770500, upload-time = "2026-06-01T19:38:11.111Z" }, + { url = "https://files.pythonhosted.org/packages/bd/32/0ffd598a2fa2b9a423daf242e700cfdabda35d6e602394ad9ae58972c1c7/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5a2e7ca615c3ddc15b82687e05a624e5f5cba3f1d6c20cb81172d70ea498451e", size = 1576224, upload-time = "2026-06-01T19:38:13.391Z" }, + { url = "https://files.pythonhosted.org/packages/0b/f9/b9fc381dd9b66afb33f2634c40e229d106467be0afcabe79648631ab6712/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:f0b7b8bbbec3ce9467ee0ebe334622fd90624f593edd3136c567811453fc4fae", size = 1794252, upload-time = "2026-06-01T19:38:15.498Z" }, + { url = "https://files.pythonhosted.org/packages/a8/fb/05d9214c975f23225a8cd5c439325e338c7c377b315480ef3871db51f54e/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ba10966d4f03dd96a14365be4b8e37c327c76f11c3ca867116966cdd9f98066", size = 1760193, upload-time = "2026-06-01T19:38:17.624Z" }, + { url = "https://files.pythonhosted.org/packages/d9/4b/02992fc4fb9e1b6673ee3f888a8e587a6447afda1f6f4aca776c148c2876/aiohttp-3.14.0-cp312-cp312-win32.whl", hash = "sha256:101df7779c80c0636014a6b2c6642acd3efb5b355d48347c9d7dfb720aee9430", size = 448650, upload-time = "2026-06-01T19:38:19.545Z" }, + { url = "https://files.pythonhosted.org/packages/39/e9/246532214c3abda518477cbaaf16d420295ad8effa5233844cbb38f299ab/aiohttp-3.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:b0a5747586d4467efd1f932710b269131c9717a872dce082cd92a00c1c13123a", size = 476145, upload-time = "2026-06-01T19:38:21.505Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c3/63f8c20090048915711598b0adf475b149216d736157961de06480a45b15/aiohttp-3.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:5f1c5be60add78fabb4aacd13c5a348ae79d2fcbfc7fa78da8f1eb192273b370", size = 444250, upload-time = "2026-06-01T19:38:24.027Z" }, ] [[package]] @@ -1043,15 +1049,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/43/53afb8ba17218f19b77c7834128566c5bbb100a0ad9ba2e8e89d089d7079/autopep8-2.3.2-py2.py3-none-any.whl", hash = "sha256:ce8ad498672c845a0c3de2629c15b635ec2b05ef8177a6e7c91c74f3e9b51128", size = 45807, upload-time = "2025-01-14T14:46:15.466Z" }, ] -[[package]] -name = "awsipranges" -version = "0.3.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/2e/6efa95f995369da828715f41705686cd214b9259ed758266942553d40441/awsipranges-0.3.3.tar.gz", hash = "sha256:4f0b3f22a9dc1163c85b513bed812b6c92bdacd674e6a7b68252a3c25b99e2c0", size = 16739, upload-time = "2022-02-10T21:08:32.825Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/ce/5c9a8bf91bdc9592a409c99e58fd99f2727ab8d634719c0ad796021b76d7/awsipranges-0.3.3-py3-none-any.whl", hash = "sha256:f3d7a54aeaf7fe310beb5d377a4034a63a51b72677ae6af3e0967bc4de7eedaf", size = 18106, upload-time = "2022-02-10T21:08:31.497Z" }, -] - [[package]] name = "azure-cli-core" version = "2.83.0" @@ -1074,7 +1071,7 @@ dependencies = [ { name = "pkginfo" }, { name = "psutil", marker = "sys_platform != 'cygwin'" }, { name = "py-deviceid" }, - { name = "pyjwt" }, + { name = "pyjwt", extra = ["crypto"] }, { name = "pyopenssl" }, { name = "requests", extra = ["socks"] }, ] @@ -2360,6 +2357,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/f1/468b49cccba3b42dda571063a14c668bb0b53a1d5712426d18e36663bd53/django_environ-0.11.2-py2.py3-none-any.whl", hash = "sha256:0ff95ab4344bfeff693836aa978e6840abef2e2f1145adff7735892711590c05", size = 19141, upload-time = "2023-09-01T21:02:59.88Z" }, ] +[[package]] +name = "django-eventstream" +version = "5.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "django-grip" }, + { name = "gripcontrol" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/49/ec6cbc24f3f30465370df7096cfea9722bad2b0c1f35a7ff5d45fb96cff6/django_eventstream-5.3.3.tar.gz", hash = "sha256:6880b03298eebf18c1b736b972fb862eaf631dfbb79f8b27496418a3495d08dc", size = 47622, upload-time = "2025-10-23T00:22:40.291Z" } + [[package]] name = "django-filter" version = "24.3" @@ -2372,6 +2382,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/09/b1/92f1c30b47c1ebf510c35a2ccad9448f73437e5891bbd2b4febe357cc3de/django_filter-24.3-py3-none-any.whl", hash = "sha256:c4852822928ce17fb699bcfccd644b3574f1a2d80aeb2b4ff4f16b02dd49dc64", size = 95011, upload-time = "2024-08-02T13:27:55.616Z" }, ] +[[package]] +name = "django-grip" +version = "3.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "gripcontrol" }, + { name = "pubcontrol" }, + { name = "six" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/d0/2c7b04fa864073cd8cb324f8674672162282d97540d56732cbd3a9ae5bca/django-grip-3.5.2.tar.gz", hash = "sha256:1ee1601492cd110256bd03e4a68797a9fbefa27c15f5a838bf245df97db0450c", size = 7626, upload-time = "2025-03-24T18:53:58.677Z" } + [[package]] name = "django-guid" version = "3.5.0" @@ -2457,7 +2480,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, { name = "djangorestframework" }, - { name = "pyjwt" }, + { name = "pyjwt", extra = ["crypto"] }, ] sdist = { url = "https://files.pythonhosted.org/packages/a8/27/2874a325c11112066139769f7794afae238a07ce6adf96259f08fd37a9d7/djangorestframework_simplejwt-5.5.1.tar.gz", hash = "sha256:e72c5572f51d7803021288e2057afcbd03f17fe11d484096f40a460abc76e87f", size = 101265, upload-time = "2025-07-21T16:52:25.026Z" } wheels = [ @@ -2576,24 +2599,27 @@ wheels = [ [[package]] name = "dulwich" -version = "0.23.0" +version = "1.2.5" 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/4b/ac/ba58cf420640c7bc77ae8e1b31e174d83c9117750c63cf9ea3b5e202e5c4/dulwich-0.23.0.tar.gz", hash = "sha256:0aa6c2489dd5e978b27e9b75983b7331a66c999f0efc54ebe37cab808ed322ae", size = 575116, upload-time = "2025-06-21T17:56:47.494Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/11/f6bbba8583f69cf19ef4bd7f5fde1a6b5ccaf8b6951781cec8db247116f4/dulwich-0.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d68498fdda13ab00791b483daab3bcfe9f9721c037aa458695e6ad81640c57cc", size = 972658, upload-time = "2025-06-21T17:56:13.505Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9d/2720e0ab58666378a33c752a61543f936cd6b06dfe5d84a2215ddc0914b0/dulwich-0.23.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:cb7bb930b12471a1cfcea4b3d25a671dc0ad32573f0ad25684684298959a1527", size = 1049813, upload-time = "2025-06-21T17:56:14.884Z" }, - { url = "https://files.pythonhosted.org/packages/e5/f3/81d8075141dfcc0a0449c2093596e58d3e11444e3af54e819eca63b84dd0/dulwich-0.23.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a2abbce32fd2bc7902bcc5f69b10bf22576810de21651baaa864b78fd7aec261", size = 1051639, upload-time = "2025-06-21T17:56:16.437Z" }, - { url = "https://files.pythonhosted.org/packages/4f/0d/c06ccb227b096aef5906142fe78b5c79f9070a0ea6152fc219941186d540/dulwich-0.23.0-cp311-cp311-win32.whl", hash = "sha256:9e3151f10ce2a9ff91bca64c74345217f53bdd947dc958032343822009832f7a", size = 642918, upload-time = "2025-06-21T17:56:18.373Z" }, - { url = "https://files.pythonhosted.org/packages/d7/1c/1e99aa34c9aead9e641b2d9934f0a3d00257f75027cf5cdecc8a1a6c18ae/dulwich-0.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:3ae9f1d9dc92d4e9a3f89ba2c55221f7b6442c5dd93b3f6f539a3c9eb3f37bdd", size = 659010, upload-time = "2025-06-21T17:56:19.947Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d7/1e6fba0235babe912e8467b036062e37d11672cbbeb0d8074f9d4559057b/dulwich-0.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:52cdef66a7994d29528ca79ca59452518bbba3fd56a9c61c61f6c467c1c7956e", size = 960292, upload-time = "2025-06-21T17:56:21.308Z" }, - { url = "https://files.pythonhosted.org/packages/4b/6a/23f0c487ec03f2752600cab4a8e0dedb38186246c475bf3fa90a8db830d5/dulwich-0.23.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d473888a6ab9ed5d4a4c3f053cbe5b77f72d54b6efdf5688fed76094316e571e", size = 1047892, upload-time = "2025-06-21T17:56:22.989Z" }, - { url = "https://files.pythonhosted.org/packages/c7/e2/8f3d216be5fd0ee1180d917b59b34b54b9896384cf139f319b5d3a8f16b4/dulwich-0.23.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:19fcf20224c641a61c774da92f098fbaae9938c7e17a52841e64092adf7e78f9", size = 1048699, upload-time = "2025-06-21T17:56:24.602Z" }, - { url = "https://files.pythonhosted.org/packages/8f/c4/18e6223cd4ad1ae9334eb4e6aa5952fd8f5c3d75762918eb90c209fec4ba/dulwich-0.23.0-cp312-cp312-win32.whl", hash = "sha256:7fc8b76b704ef35cd001e993e3aa4e1d666a2064bf467c07c560f12b2959dcaf", size = 641268, upload-time = "2025-06-21T17:56:26.18Z" }, - { url = "https://files.pythonhosted.org/packages/b8/9c/65bfbbac62d8a2967e13f6a1512371c5eb6b906a61fb6dead992669cad0e/dulwich-0.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:cb0566b888b578325350b4d67c61a0de35d417e9877560e3a6df88cae4576a59", size = 657837, upload-time = "2025-06-21T17:56:27.821Z" }, - { url = "https://files.pythonhosted.org/packages/35/31/49318ee9db4b402e6d8b9b01bd4cae9298f59e1bb9bd56cf4a94e48fa069/dulwich-0.23.0-py3-none-any.whl", hash = "sha256:d8da6694ca332bb48775e35ee2215aa4673821164a91b83062f699c69f7cd135", size = 313776, upload-time = "2025-06-21T17:56:46.221Z" }, + { 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" }, ] [[package]] @@ -2980,6 +3006,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/ab/717c58343cf02c5265b531384b248787e04d8160b8afe53d9eec053d7b44/greenlet-3.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bfb2d1763d777de5ee495c85309460f6fd8146e50ec9d0ae0183dbf6f0a829d1", size = 226403, upload-time = "2026-01-23T15:31:39.372Z" }, ] +[[package]] +name = "gripcontrol" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pubcontrol" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4f/51/1cbf88384fbe97a1454fb0adddcdca8cb90ceb99c3250274c334db844f4f/gripcontrol-4.4.0.tar.gz", hash = "sha256:44ee6fe244a02870aa4e5bc810138ccaf5070dce5eb149b8ee9e27b960a95c2d", size = 12526, upload-time = "2026-05-14T21:19:28.49Z" } + [[package]] name = "grpc-google-iam-v1" version = "0.14.3" @@ -3041,14 +3078,14 @@ wheels = [ [[package]] name = "gunicorn" -version = "23.0.0" +version = "26.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" }, + { url = "https://files.pythonhosted.org/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009, upload-time = "2026-05-05T06:38:23.007Z" }, ] [[package]] @@ -3150,20 +3187,20 @@ wheels = [ [[package]] name = "iamdata" -version = "0.1.202602021" +version = "0.1.202605131" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/93/5e/8179963b7a528c548824a8e4088150509d9fa8571dd622b7399f6d2d5680/iamdata-0.1.202602021.tar.gz", hash = "sha256:c24265fc3694076f65da91a8aa9361b60da25f7b8cfd8ba4ddd6aa1b9bb5153e", size = 771233, upload-time = "2026-02-02T05:49:56.76Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/ea/d68e25aa4392e8a9f8e6523adc95a5fb86baf98d052efa2cec4d4a00e7ce/iamdata-0.1.202605131.tar.gz", hash = "sha256:ab4e8f1ea080394157848fecd0ca643633e35b2e0cb1965c9ed9bdd673afe00c", size = 793465, upload-time = "2026-05-13T05:57:10.607Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/74/9e/ae7a3019aa5a27d70412b74da4f0304695efa5d9a88f0689f37ea2602ea2/iamdata-0.1.202602021-py3-none-any.whl", hash = "sha256:48419662d75dd0e1ea22b9cc98fd70201d4c72760c6897acc46ad9ab90633d18", size = 1226614, upload-time = "2026-02-02T05:49:54.735Z" }, + { url = "https://files.pythonhosted.org/packages/f6/93/03396b477b0faa9f1a12142209b59aa13d0fe4f64e2be47883f607789c14/iamdata-0.1.202605131-py3-none-any.whl", hash = "sha256:350e317d96fb8c8ddf30aa6da222788d302af5f13c9e357b59f9eefe50b8af5a", size = 1259166, upload-time = "2026-05-13T05:57:09.093Z" }, ] [[package]] name = "idna" -version = "3.11" +version = "3.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, ] [[package]] @@ -3966,30 +4003,30 @@ wheels = [ [[package]] name = "numpy" -version = "2.0.2" +version = "2.2.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a9/75/10dd1f8116a8b796cb2c737b674e02d02e80454bda953fa7e65d8c12b016/numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78", size = 18902015, upload-time = "2024-08-26T20:19:40.945Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/cf/034500fb83041aa0286e0fb16e7c76e5c8b67c0711bb6e9e9737a717d5fe/numpy-2.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448", size = 21169137, upload-time = "2024-08-26T20:07:45.345Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d9/32de45561811a4b87fbdee23b5797394e3d1504b4a7cf40c10199848893e/numpy-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195", size = 13703552, upload-time = "2024-08-26T20:08:06.666Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ca/2f384720020c7b244d22508cb7ab23d95f179fcfff33c31a6eeba8d6c512/numpy-2.0.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57", size = 5298957, upload-time = "2024-08-26T20:08:15.83Z" }, - { url = "https://files.pythonhosted.org/packages/0e/78/a3e4f9fb6aa4e6fdca0c5428e8ba039408514388cf62d89651aade838269/numpy-2.0.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a", size = 6905573, upload-time = "2024-08-26T20:08:27.185Z" }, - { url = "https://files.pythonhosted.org/packages/a0/72/cfc3a1beb2caf4efc9d0b38a15fe34025230da27e1c08cc2eb9bfb1c7231/numpy-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669", size = 13914330, upload-time = "2024-08-26T20:08:48.058Z" }, - { url = "https://files.pythonhosted.org/packages/ba/a8/c17acf65a931ce551fee11b72e8de63bf7e8a6f0e21add4c937c83563538/numpy-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951", size = 19534895, upload-time = "2024-08-26T20:09:16.536Z" }, - { url = "https://files.pythonhosted.org/packages/ba/86/8767f3d54f6ae0165749f84648da9dcc8cd78ab65d415494962c86fac80f/numpy-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9", size = 19937253, upload-time = "2024-08-26T20:09:46.263Z" }, - { url = "https://files.pythonhosted.org/packages/df/87/f76450e6e1c14e5bb1eae6836478b1028e096fd02e85c1c37674606ab752/numpy-2.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15", size = 14414074, upload-time = "2024-08-26T20:10:08.483Z" }, - { url = "https://files.pythonhosted.org/packages/5c/ca/0f0f328e1e59f73754f06e1adfb909de43726d4f24c6a3f8805f34f2b0fa/numpy-2.0.2-cp311-cp311-win32.whl", hash = "sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4", size = 6470640, upload-time = "2024-08-26T20:10:19.732Z" }, - { url = "https://files.pythonhosted.org/packages/eb/57/3a3f14d3a759dcf9bf6e9eda905794726b758819df4663f217d658a58695/numpy-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc", size = 15910230, upload-time = "2024-08-26T20:10:43.413Z" }, - { url = "https://files.pythonhosted.org/packages/45/40/2e117be60ec50d98fa08c2f8c48e09b3edea93cfcabd5a9ff6925d54b1c2/numpy-2.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b", size = 20895803, upload-time = "2024-08-26T20:11:13.916Z" }, - { url = "https://files.pythonhosted.org/packages/46/92/1b8b8dee833f53cef3e0a3f69b2374467789e0bb7399689582314df02651/numpy-2.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e", size = 13471835, upload-time = "2024-08-26T20:11:34.779Z" }, - { url = "https://files.pythonhosted.org/packages/7f/19/e2793bde475f1edaea6945be141aef6c8b4c669b90c90a300a8954d08f0a/numpy-2.0.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c", size = 5038499, upload-time = "2024-08-26T20:11:43.902Z" }, - { url = "https://files.pythonhosted.org/packages/e3/ff/ddf6dac2ff0dd50a7327bcdba45cb0264d0e96bb44d33324853f781a8f3c/numpy-2.0.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c", size = 6633497, upload-time = "2024-08-26T20:11:55.09Z" }, - { url = "https://files.pythonhosted.org/packages/72/21/67f36eac8e2d2cd652a2e69595a54128297cdcb1ff3931cfc87838874bd4/numpy-2.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692", size = 13621158, upload-time = "2024-08-26T20:12:14.95Z" }, - { url = "https://files.pythonhosted.org/packages/39/68/e9f1126d757653496dbc096cb429014347a36b228f5a991dae2c6b6cfd40/numpy-2.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a", size = 19236173, upload-time = "2024-08-26T20:12:44.049Z" }, - { url = "https://files.pythonhosted.org/packages/d1/e9/1f5333281e4ebf483ba1c888b1d61ba7e78d7e910fdd8e6499667041cc35/numpy-2.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c", size = 19634174, upload-time = "2024-08-26T20:13:13.634Z" }, - { url = "https://files.pythonhosted.org/packages/71/af/a469674070c8d8408384e3012e064299f7a2de540738a8e414dcfd639996/numpy-2.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded", size = 14099701, upload-time = "2024-08-26T20:13:34.851Z" }, - { url = "https://files.pythonhosted.org/packages/d0/3d/08ea9f239d0e0e939b6ca52ad403c84a2bce1bde301a8eb4888c1c1543f1/numpy-2.0.2-cp312-cp312-win32.whl", hash = "sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5", size = 6174313, upload-time = "2024-08-26T20:13:45.653Z" }, - { url = "https://files.pythonhosted.org/packages/b2/b5/4ac39baebf1fdb2e72585c8352c56d063b6126be9fc95bd2bb5ef5770c20/numpy-2.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a", size = 15606179, upload-time = "2024-08-26T20:14:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, ] [[package]] @@ -4031,7 +4068,7 @@ dependencies = [ { name = "pycryptodomex" }, { name = "pydantic" }, { name = "pydash" }, - { name = "pyjwt" }, + { name = "pyjwt", extra = ["crypto"] }, { name = "python-dateutil" }, { name = "pyyaml" }, { name = "requests" }, @@ -4410,8 +4447,8 @@ wheels = [ [[package]] name = "prowler" -version = "5.27.0" -source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#0abbb7fc590eaf7de6ed354dd5a217bca261d2b0" } +version = "5.31.0" +source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#b5bb85c9564f6ca6a7f66c851bb56bde719205ee" } dependencies = [ { name = "alibabacloud-actiontrail20200706" }, { name = "alibabacloud-credentials" }, @@ -4427,7 +4464,6 @@ dependencies = [ { name = "alibabacloud-tea-openapi" }, { name = "alibabacloud-vpc20160428" }, { name = "alive-progress" }, - { name = "awsipranges" }, { name = "azure-identity" }, { name = "azure-keyvault-keys" }, { name = "azure-mgmt-apimanagement" }, @@ -4484,9 +4520,14 @@ dependencies = [ { name = "pygithub" }, { name = "python-dateutil" }, { name = "pytz" }, + { name = "scaleway" }, { name = "schema" }, { name = "shodan" }, { name = "slack-sdk" }, + { name = "stackit-core" }, + { name = "stackit-iaas" }, + { name = "stackit-objectstorage" }, + { name = "stackit-resourcemanager" }, { name = "tabulate" }, { name = "tzlocal" }, { name = "uuid6" }, @@ -4494,7 +4535,7 @@ dependencies = [ [[package]] name = "prowler-api" -version = "1.31.0" +version = "1.32.0" source = { virtual = "." } dependencies = [ { name = "cartography" }, @@ -4507,6 +4548,7 @@ dependencies = [ { name = "django-celery-results" }, { name = "django-cors-headers" }, { name = "django-environ" }, + { name = "django-eventstream" }, { name = "django-filter" }, { name = "django-guid" }, { name = "django-postgres-extra" }, @@ -4533,6 +4575,7 @@ dependencies = [ { name = "sentry-sdk", extra = ["django"] }, { name = "sqlparse" }, { name = "uuid6" }, + { name = "uvloop" }, { name = "werkzeug" }, { name = "xmlsec" }, ] @@ -4571,6 +4614,7 @@ requires-dist = [ { name = "django-celery-results", specifier = "==2.6.0" }, { name = "django-cors-headers", specifier = "==4.4.0" }, { name = "django-environ", specifier = "==0.11.2" }, + { name = "django-eventstream", specifier = "==5.3.3" }, { name = "django-filter", specifier = "==24.3" }, { name = "django-guid", specifier = "==3.5.0" }, { name = "django-postgres-extra", specifier = "==2.0.9" }, @@ -4583,7 +4627,7 @@ requires-dist = [ { name = "drf-spectacular-jsonapi", specifier = "==0.5.1" }, { name = "fonttools", specifier = "==4.62.1" }, { name = "gevent", specifier = "==25.9.1" }, - { name = "gunicorn", specifier = "==23.0.0" }, + { name = "gunicorn", specifier = "==26.0.0" }, { name = "h2", specifier = "==4.3.0" }, { name = "lxml", specifier = "==6.1.0" }, { name = "markdown", specifier = "==3.10.2" }, @@ -4597,6 +4641,7 @@ requires-dist = [ { name = "sentry-sdk", extras = ["django"], specifier = "==2.56.0" }, { name = "sqlparse", specifier = "==0.5.5" }, { name = "uuid6", specifier = "==2024.7.10" }, + { name = "uvloop", specifier = "==0.22.1" }, { name = "werkzeug", specifier = "==3.1.7" }, { name = "xmlsec", specifier = "==1.3.17" }, ] @@ -4671,6 +4716,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/08/9c66c269b0d417a0af9fb969535f0371b8c538633535a7a6a5ca3f9231e2/psycopg2_binary-2.9.9-cp312-cp312-win_amd64.whl", hash = "sha256:81ff62668af011f9a48787564ab7eded4e9fb17a4a6a74af5ffa6a457400d2ab", size = 1163864, upload-time = "2023-10-28T09:37:28.155Z" }, ] +[[package]] +name = "pubcontrol" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/6a/02202a247214a6ffd5148ab1b16aca1c334b40dca211bca0442c8b7c7447/pubcontrol-3.5.0.tar.gz", hash = "sha256:a5ec6b3f53edfd005675518e5e4cc23b34122776835ae7c6dbd1db173d1ff0cb", size = 18199, upload-time = "2023-07-05T19:11:40.477Z" } + [[package]] name = "py-deviceid" version = "0.1.1" @@ -4682,14 +4737,14 @@ wheels = [ [[package]] name = "py-iam-expand" -version = "0.1.0" +version = "0.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "iamdata" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/22/99/8d31a30b37825577275bb3663885b55075fba80257fcd6813b85d3aaffa8/py_iam_expand-0.1.0.tar.gz", hash = "sha256:5a2884dc267ac59a02c3a80fefc0b34c309dac681baa0f87c436067c6cf53a96", size = 10228, upload-time = "2025-04-30T07:15:35.304Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/08/f6e11a029b81f0bec4b7b1f18704aadf509a882cc386c90ef1ac043c18cc/py_iam_expand-0.3.0.tar.gz", hash = "sha256:4ccfe25f40ba0633a152c4f86b49cde8972ee3d4b6009b017a4310cc4b9e64c7", size = 10234, upload-time = "2026-02-24T09:47:47.772Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/19/482c2e0768cda7afaed07918e4fbd951e2418255fb5d1d9b35b284871716/py_iam_expand-0.1.0-py3-none-any.whl", hash = "sha256:b845ce7b50ac895b02b4f338e09c62a68ea51849794f76e189b02009bd388510", size = 12522, upload-time = "2025-04-30T07:15:33.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/dd/4056d0bc3d6317039d2dd2ee7cd6a5389575603e270399a8f9f20e11e721/py_iam_expand-0.3.0-py3-none-any.whl", hash = "sha256:94c0a1e9dd60316ce60ddc0cdc9a046119bde335b5bb9593ee29224857860d5a", size = 12527, upload-time = "2026-02-24T09:47:45.602Z" }, ] [[package]] @@ -4873,11 +4928,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.12.1" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, + { 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" }, ] [package.optional-dependencies] @@ -5526,6 +5581,67 @@ 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" }, ] +[[package]] +name = "stackit-core" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/90/20f9ec7387eec4067cfd3d29055d0e2b5e1e0322c601a7f48125fd8ea35f/stackit_core-0.2.0.tar.gz", hash = "sha256:b8af91877cdb060d6969a303d8cf20bc0b33b345afd91f679c44a987381e2d47", size = 8987, upload-time = "2025-06-12T08:24:45.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/b4/7b53187ce68956870d864ccb9ccfb68066c9df9de1c9568fd2feb03c4504/stackit_core-0.2.0-py3-none-any.whl", hash = "sha256:04632fc6742790d08ddfcb7f2313e04d1254827397a80250f838a2f81b92645b", size = 10240, upload-time = "2025-06-12T08:24:44.214Z" }, +] + +[[package]] +name = "stackit-iaas" +version = "1.4.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/52/07/24e65278300d5c3cb19cb1660bff924c80812cf8aad3e715f826bae5aa80/stackit_iaas-1.4.0.tar.gz", hash = "sha256:93523b23442350c7ebefd9129485c4c2a539f694a9c36a0f8edfaba9862057ea", size = 116236, upload-time = "2026-05-13T09:43:15.996Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/51/2201164d7bfacf47539888c735f10f6320c188252384957aa1b23121a210/stackit_iaas-1.4.0-py3-none-any.whl", hash = "sha256:3f4a32321b57ac238f73e5d660c6428186b92cc0425c1f0783ba801e377149d9", size = 316588, upload-time = "2026-05-13T09:43:14.943Z" }, +] + +[[package]] +name = "stackit-objectstorage" +version = "1.4.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/90/80/b790756af40a5c6d979dd688b2557394ac54b594eb4c08edc33157ba890f/stackit_objectstorage-1.4.0.tar.gz", hash = "sha256:4a3812b4de102b199f061706a802909f9e53ae9b0858769d5bd720f814c8bdbe", size = 31814, upload-time = "2026-05-13T09:43:05.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/f1/ffa8d5e2ec9f818c72a6f045691364eb4e927ee86641993a70882d00205a/stackit_objectstorage-1.4.0-py3-none-any.whl", hash = "sha256:1a3285c6840d95cff591d84fd21803575cb0d010c398e6575ed92987b9c39866", size = 65061, upload-time = "2026-05-13T09:43:04.13Z" }, +] + +[[package]] +name = "stackit-resourcemanager" +version = "0.8.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/23/2d/f458f18e48ed2b1c83df52cff7dbdfd5dd904fb2980ffd9385876e47bbd9/stackit_resourcemanager-0.8.0.tar.gz", hash = "sha256:f44542beab4130857f5a7f465cf02defeef657bdf63c1beeb3102f0ba3c003fe", size = 33943, upload-time = "2026-05-13T09:43:08.667Z" } +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 = "statsd" version = "4.0.1" @@ -5730,6 +5846,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d3/3e/4ae6af487ce5781ed71d5fe10aca72e7cbc4d4f45afc31b120287082a8dd/uuid6-2024.7.10-py3-none-any.whl", hash = "sha256:93432c00ba403751f722829ad21759ff9db051dea140bf81493271e8e4dd18b7", size = 6376, upload-time = "2024-07-10T16:39:36.148Z" }, ] +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, +] + [[package]] name = "vine" version = "5.1.0" @@ -5785,7 +5921,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "httpx" }, - { name = "pyjwt" }, + { name = "pyjwt", extra = ["crypto"] }, ] sdist = { url = "https://files.pythonhosted.org/packages/3c/2f/99fb8718274116c5c146c745755620fd5c5943f78ca52ca9b17e94348286/workos-6.0.4.tar.gz", hash = "sha256:b0bfe8fd212b8567422c4ea3732eb33608794033eb3a69900c6b04db183c32d6", size = 172217, upload-time = "2026-04-16T03:09:28.583Z" } wheels = [ diff --git a/contrib/aws/multi-account-securityhub/Dockerfile b/contrib/aws/multi-account-securityhub/Dockerfile index 1cc6c326b7..de5c57ba7b 100644 --- a/contrib/aws/multi-account-securityhub/Dockerfile +++ b/contrib/aws/multi-account-securityhub/Dockerfile @@ -1,7 +1,7 @@ # Build command # docker build --platform=linux/amd64 --no-cache -t prowler:latest . -ARG PROWLER_VERSION=latest +ARG PROWLER_VERSION=latest@sha256:4b796c6df40a3350c7947747b59bdda230d0da6222287500e13b0a8e1574aad4 FROM toniblyx/prowler:${PROWLER_VERSION} diff --git a/contrib/inventory-graph/lib/inventory_output.py b/contrib/inventory-graph/lib/inventory_output.py index 359e7eb91f..459dc37cdb 100644 --- a/contrib/inventory-graph/lib/inventory_output.py +++ b/contrib/inventory-graph/lib/inventory_output.py @@ -16,7 +16,6 @@ from typing import Optional from prowler.lib.logger import logger from lib.models import ConnectivityGraph - # --------------------------------------------------------------------------- # JSON output # --------------------------------------------------------------------------- diff --git a/contrib/k8s/helm/prowler-api/values.yaml b/contrib/k8s/helm/prowler-api/values.yaml index 81eda5f690..0395c6f704 100644 --- a/contrib/k8s/helm/prowler-api/values.yaml +++ b/contrib/k8s/helm/prowler-api/values.yaml @@ -28,7 +28,7 @@ containers: image: repository: prowlercloud/prowler-api pullPolicy: IfNotPresent - command: ["../docker-entrypoint.sh", "beat"] + command: ["/home/prowler/docker-entrypoint.sh", "beat"] secrets: POSTGRES_HOST: diff --git a/contrib/k8s/helm/prowler-app/templates/ui/configmap.yaml b/contrib/k8s/helm/prowler-app/templates/ui/configmap.yaml index 38d6e65ee3..856770722a 100644 --- a/contrib/k8s/helm/prowler-app/templates/ui/configmap.yaml +++ b/contrib/k8s/helm/prowler-app/templates/ui/configmap.yaml @@ -11,8 +11,7 @@ data: {{- else }} AUTH_URL: {{ .Values.ui.authUrl | quote }} {{- end }} - API_BASE_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1" - NEXT_PUBLIC_API_BASE_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1" - NEXT_PUBLIC_API_DOCS_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1/docs" + UI_API_BASE_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1" + UI_API_DOCS_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1/docs" AUTH_TRUST_HOST: "true" UI_PORT: {{ .Values.ui.service.port | quote }} diff --git a/contrib/k8s/helm/prowler-app/values.yaml b/contrib/k8s/helm/prowler-app/values.yaml index 9acca09f2a..ed390af29e 100644 --- a/contrib/k8s/helm/prowler-app/values.yaml +++ b/contrib/k8s/helm/prowler-app/values.yaml @@ -440,7 +440,7 @@ worker_beat: tag: "" command: - - ../docker-entrypoint.sh + - /home/prowler/docker-entrypoint.sh args: - beat diff --git a/contrib/k8s/helm/prowler-ui/values.yaml b/contrib/k8s/helm/prowler-ui/values.yaml index d4f2dbe137..aca0178b3a 100644 --- a/contrib/k8s/helm/prowler-ui/values.yaml +++ b/contrib/k8s/helm/prowler-ui/values.yaml @@ -21,8 +21,8 @@ fullnameOverride: "" secrets: SITE_URL: http://localhost:3000 - API_BASE_URL: http://prowler-api:8080/api/v1 - NEXT_PUBLIC_API_DOCS_URL: http://prowler-api:8080/api/v1/docs + UI_API_BASE_URL: http://prowler-api:8080/api/v1 + UI_API_DOCS_URL: http://prowler-api:8080/api/v1/docs AUTH_TRUST_HOST: True UI_PORT: 3000 # openssl rand -base64 32 diff --git a/contrib/reverse-proxy/docker-compose.reverse-proxy.yml b/contrib/reverse-proxy/docker-compose.reverse-proxy.yml index 08c52f3558..b8f8edec30 100644 --- a/contrib/reverse-proxy/docker-compose.reverse-proxy.yml +++ b/contrib/reverse-proxy/docker-compose.reverse-proxy.yml @@ -16,7 +16,7 @@ services: nginx: - image: nginx:alpine + image: nginx:alpine@sha256:8b1e78743a03dbb2c95171cc58639fef29abc8816598e27fb910ed2e621e589a container_name: prowler-nginx restart: unless-stopped ports: diff --git a/dashboard/common_methods.py b/dashboard/common_methods.py index a2f9ffe89b..b9f59513a5 100644 --- a/dashboard/common_methods.py +++ b/dashboard/common_methods.py @@ -1538,6 +1538,186 @@ def get_section_container_iso(data, section_1, section_2): return html.Div(section_containers, className="compliance-data-layout") +def _status_bar(success, failed, classname): + """Build the stacked PASS/FAIL bar shown next to an accordion title.""" + fig = go.Figure( + data=[ + go.Bar( + name="Failed", + x=[failed], + y=[""], + orientation="h", + marker=dict(color="#e77676"), + width=[0.8], + ), + go.Bar( + name="Success", + x=[success], + y=[""], + orientation="h", + marker=dict(color="#45cc6e"), + width=[0.8], + ), + ] + ) + fig.update_layout( + barmode="stack", + margin=dict(l=10, r=10, t=10, b=10), + paper_bgcolor="rgba(0,0,0,0)", + plot_bgcolor="rgba(0,0,0,0)", + showlegend=False, + width=350, + height=30, + xaxis=dict(showticklabels=False, showgrid=False, zeroline=False), + yaxis=dict(showticklabels=False, showgrid=False, zeroline=False), + annotations=[ + dict( + x=success + failed, + y=0, + xref="x", + yref="y", + text=str(success), + showarrow=False, + font=dict(color="#45cc6e", size=14), + xanchor="left", + yanchor="middle", + ), + dict( + x=0, + y=0, + xref="x", + yref="y", + text=str(failed), + showarrow=False, + font=dict(color="#e77676", size=14), + xanchor="right", + yanchor="middle", + ), + ], + ) + fig.add_annotation( + x=failed, + y=0.3, + text="|", + showarrow=False, + xanchor="center", + yanchor="middle", + font=dict(size=20), + ) + return dcc.Graph(figure=fig, config={"staticPlot": True}, className=classname) + + +def get_section_containers_generic(data, section_col, id_col): + """Two-level view: section -> requirement id (+ description) -> checks. + + Sorts lexicographically so arbitrary requirement IDs never crash the + version-aware sort used by the CIS renderer. + """ + data["STATUS"] = data["STATUS"].apply(map_status_to_icon) + data[section_col] = data[section_col].astype(str) + data[id_col] = data[id_col].astype(str) + data.sort_values(by=[section_col, id_col], inplace=True) + + counts_section = data.groupby([section_col, "STATUS"]).size().unstack(fill_value=0) + counts_id = ( + data.groupby([section_col, id_col, "STATUS"]).size().unstack(fill_value=0) + ) + + def count(counts, key, emoji): + return counts.loc[key, emoji] if emoji in counts.columns else 0 + + has_description = "REQUIREMENTS_DESCRIPTION" in data.columns + table_cols = ["CHECKID", "STATUS", "REGION", "ACCOUNTID", "RESOURCEID"] + + section_containers = [] + for section in data[section_col].unique(): + graph_div = html.Div( + _status_bar( + count(counts_section, section, pass_emoji), + count(counts_section, section, fail_emoji), + "info-bar", + ), + className="graph-section", + ) + + internal_items = [] + for req_id in data[data[section_col] == section][id_col].unique(): + specific_data = data[ + (data[section_col] == section) & (data[id_col] == req_id) + ] + data_table = dash_table.DataTable( + data=specific_data.to_dict("records"), + columns=[ + {"name": i, "id": i} + for i in table_cols + if i in specific_data.columns + ], + style_table={"overflowX": "auto"}, + style_as_list_view=True, + style_cell={"textAlign": "left", "padding": "5px"}, + ) + graph_div_req = html.Div( + _status_bar( + count(counts_id, (section, req_id), pass_emoji), + count(counts_id, (section, req_id), fail_emoji), + "info-bar-child", + ), + className="graph-section-req", + ) + + title = req_id + if has_description: + title = ( + f"{req_id} - {specific_data['REQUIREMENTS_DESCRIPTION'].iloc[0]}" + ) + if len(title) > 130: + title = title[:130] + " ..." + + internal_items.append( + html.Div( + [ + graph_div_req, + dbc.Accordion( + [ + dbc.AccordionItem( + title=title, + children=[ + html.Div( + [data_table], + className="inner-accordion-content", + ) + ], + ) + ], + start_collapsed=True, + flush=True, + ), + ], + className="accordion-inner--child", + ) + ) + + section_containers.append( + html.Div( + [ + graph_div, + dbc.Accordion( + [ + dbc.AccordionItem( + title=f"{section}", children=internal_items + ) + ], + start_collapsed=True, + flush=True, + ), + ], + className="accordion-inner", + ) + ) + + return html.Div(section_containers, className="compliance-data-layout") + + def get_section_containers_format4(data, section_1): data["STATUS"] = data["STATUS"].apply(map_status_to_icon) diff --git a/dashboard/compliance/aws_ai_security_framework_aws.py b/dashboard/compliance/aws_ai_security_framework_aws.py new file mode 100644 index 0000000000..ece9bdf9cb --- /dev/null +++ b/dashboard/compliance/aws_ai_security_framework_aws.py @@ -0,0 +1,27 @@ +import warnings + +from dashboard.common_methods import get_section_containers_3_levels + +warnings.filterwarnings("ignore") + + +def get_table(data): + aux = data[ + [ + "REQUIREMENTS_ATTRIBUTES_SECTION", + "REQUIREMENTS_ATTRIBUTES_SUBSECTION", + "NAME", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ] + + return get_section_containers_3_levels( + aux, + "REQUIREMENTS_ATTRIBUTES_SECTION", + "REQUIREMENTS_ATTRIBUTES_SUBSECTION", + "NAME", + ) diff --git a/dashboard/compliance/generic.py b/dashboard/compliance/generic.py new file mode 100644 index 0000000000..f7d68bb52a --- /dev/null +++ b/dashboard/compliance/generic.py @@ -0,0 +1,44 @@ +import warnings + +from dashboard.common_methods import ( + get_section_containers_format4, + get_section_containers_generic, +) + +warnings.filterwarnings("ignore") + + +def get_table(data): + # Discover REQUIREMENTS_ATTRIBUTES_* columns at runtime. + attr_cols = [c for c in data.columns if c.startswith("REQUIREMENTS_ATTRIBUTES_")] + + # Section column (in priority order): + # 1. REQUIREMENTS_ATTRIBUTES_SECTION — most common convention + # 2. First discovered attribute column — covers novel schemas + # 3. None — no section, group flat by requirement id + if "REQUIREMENTS_ATTRIBUTES_SECTION" in attr_cols: + section_col = "REQUIREMENTS_ATTRIBUTES_SECTION" + elif attr_cols: + section_col = attr_cols[0] + else: + section_col = None + + base_cols = [ + "REQUIREMENTS_ID", + "REQUIREMENTS_DESCRIPTION", + "STATUS", + "CHECKID", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + + # Two levels (section -> requirement id) when a section distinct from the + # id exists; otherwise group flat by requirement id. + if section_col and section_col != "REQUIREMENTS_ID": + needed = [section_col] + base_cols + aux = data[[c for c in needed if c in data.columns]].copy() + return get_section_containers_generic(aux, section_col, "REQUIREMENTS_ID") + + aux = data[[c for c in base_cols if c in data.columns]].copy() + return get_section_containers_format4(aux, "REQUIREMENTS_ID") diff --git a/dashboard/lib/layouts.py b/dashboard/lib/layouts.py index 930432b6c4..3fb230f314 100644 --- a/dashboard/lib/layouts.py +++ b/dashboard/lib/layouts.py @@ -156,7 +156,7 @@ def create_layout_compliance( html.Img(src="assets/favicon.ico", className="w-5 mr-3"), html.Span("Subscribe to Prowler Cloud"), ], - href="https://prowler.pro/", + href="https://cloud.prowler.com/", target="_blank", className="text-prowler-stone-900 inline-flex px-4 py-2 text-xs font-bold uppercase transition-all rounded-lg text-gray-900 hover:bg-prowler-stone-900/10 border-solid border-1 hover:border-prowler-stone-900/10 hover:border-solid hover:border-1 border-prowler-stone-900/10", ), diff --git a/dashboard/pages/compliance.py b/dashboard/pages/compliance.py index c1da9f611e..773dd095da 100644 --- a/dashboard/pages/compliance.py +++ b/dashboard/pages/compliance.py @@ -215,6 +215,58 @@ else: ) +def _ensure_scope_columns(data): + """Guarantee ACCOUNTID and REGION exist. + + Scope columns always sit between DESCRIPTION and ASSESSMENTDATE, so derive + them positionally for any provider (e.g. Okta's ORGANIZATIONDOMAIN) and + fall back to "-" to avoid a KeyError. + """ + cols = list(data.columns) + scope = [] + if "DESCRIPTION" in cols and "ASSESSMENTDATE" in cols: + start, end = cols.index("DESCRIPTION") + 1, cols.index("ASSESSMENTDATE") + scope = [c for c in cols[start:end] if c not in ("ACCOUNTID", "REGION")] + + if "ACCOUNTID" not in data.columns: + if scope: + data.rename(columns={scope.pop(0): "ACCOUNTID"}, inplace=True) + else: + data["ACCOUNTID"] = "-" + if "REGION" not in data.columns: + if scope: + data.rename(columns={scope.pop(0): "REGION"}, inplace=True) + else: + data["REGION"] = "-" + return data + + +def _dispatch_compliance_renderer(data, analytics_input): + """Resolve the compliance renderer module and return (table, deduped_data). + + Tries to import the framework-specific builtin module. On + ModuleNotFoundError (dynamic/external provider with no dedicated module), + falls back to the generic renderer. Any other ImportError is re-raised. + get_table() is called OUTSIDE the try block so errors inside the renderer + surface as real exceptions rather than being swallowed. + """ + current = analytics_input.replace(".", "_") + target = f"dashboard.compliance.{current}" + try: + module = importlib.import_module(target) + except ModuleNotFoundError as exc: + if exc.name != target: + raise + from dashboard.compliance import generic as module + dedup_columns = ["CHECKID", "STATUS", "RESOURCEID", "STATUSEXTENDED"] + if "MUTED" in data.columns: + dedup_columns.insert(2, "MUTED") + data = data.drop_duplicates(subset=dedup_columns) + if "threatscore" in analytics_input: + data = get_threatscore_mean_by_pillar(data) + return module.get_table(data), data + + @callback( [ Output("output", "children"), @@ -292,7 +344,7 @@ def display_data( data.rename(columns={"TENANCYID": "ACCOUNTID"}, inplace=True) # Filter the chosen level of the CIS - if is_level_1: + if is_level_1 and "REQUIREMENTS_ATTRIBUTES_PROFILE" in data.columns: data = data[data["REQUIREMENTS_ATTRIBUTES_PROFILE"].str.contains("Level 1")] # Rename the column PROJECTID to ACCOUNTID for GCP @@ -314,6 +366,9 @@ def display_data( data.rename(columns={"SUBSCRIPTION": "ACCOUNTID"}, inplace=True) data["REGION"] = "-" + # Normalize scope columns for any remaining (e.g. dynamic) provider. + data = _ensure_scope_columns(data) + # Filter ACCOUNT if account_filter == ["All"]: updated_cloud_account_values = data["ACCOUNTID"].unique() @@ -409,36 +464,7 @@ def display_data( # Check cases where the compliance start with AWS_ if "aws_" in analytics_input: analytics_input = analytics_input + "_aws" - try: - current = analytics_input.replace(".", "_") - compliance_module = importlib.import_module( - f"dashboard.compliance.{current}" - ) - # Build subset list based on available columns - dedup_columns = ["CHECKID", "STATUS", "RESOURCEID", "STATUSEXTENDED"] - if "MUTED" in data.columns: - dedup_columns.insert(2, "MUTED") - data = data.drop_duplicates(subset=dedup_columns) - - if "threatscore" in analytics_input: - data = get_threatscore_mean_by_pillar(data) - - table = compliance_module.get_table(data) - except ModuleNotFoundError: - table = html.Div( - [ - html.H5( - "No data found for this compliance", - className="card-title", - style={"text-align": "left", "color": "black"}, - ) - ], - style={ - "width": "99%", - "margin-right": "0.8%", - "margin-bottom": "10px", - }, - ) + table, data = _dispatch_compliance_renderer(data, analytics_input) df = data.copy() # Remove Muted rows diff --git a/dashboard/pages/overview.py b/dashboard/pages/overview.py index 665aa8e195..e705f15e9f 100644 --- a/dashboard/pages/overview.py +++ b/dashboard/pages/overview.py @@ -1538,7 +1538,7 @@ def filter_data( html.Img(src="assets/favicon.ico", className="w-5 mr-3"), html.Span("Subscribe to Prowler Cloud"), ], - href="https://prowler.pro/", + href="https://cloud.prowler.com/", target="_blank", className="text-prowler-stone-900 inline-flex px-4 py-2 text-xs font-bold uppercase transition-all rounded-lg text-gray-900 hover:bg-prowler-stone-900/10 border-solid border-1 hover:border-prowler-stone-900/10 hover:border-solid hover:border-1 border-prowler-stone-900/10", ), diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 2020c7d21a..d737298183 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -1,6 +1,6 @@ services: api-dev-init: - image: busybox:1.37.0 + image: busybox:1.37.0@sha256:9532d8c39891ca2ecde4d30d7710e01fb739c87a8b9299685c63704296b16028 volumes: - ./_data/api:/data command: ["sh", "-c", "chown -R 1000:1000 /data"] @@ -64,7 +64,7 @@ services: condition: service_healthy postgres: - image: postgres:16.3-alpine3.20 + image: postgres:16.3-alpine3.20@sha256:36ed71227ae36305d26382657c0b96cbaf298427b3f1eaeb10d77a6dea3eec41 hostname: "postgres-db" volumes: - ./_data/postgres:/var/lib/postgresql/data @@ -88,7 +88,7 @@ services: retries: 5 valkey: - image: valkey/valkey:7-alpine3.19 + image: valkey/valkey:7-alpine3.19@sha256:4054fe7fc607b9326ac7c4691ed26e9670d2ff17a9fb28c2577adecf928acbcc hostname: "valkey" volumes: - ./_data/valkey:/data @@ -104,7 +104,7 @@ services: retries: 3 neo4j: - image: graphstack/dozerdb:5.26.3.0 + image: graphstack/dozerdb:5.26.3.0@sha256:a77526ea3918fdc46d1fff70c4aea7d71d3874a26ecec059179d6775845b1247 hostname: "neo4j" volumes: - ./_data/neo4j:/data @@ -139,6 +139,8 @@ services: worker-dev: image: prowler-api-dev + # Give Celery soft shutdown time to drain/re-queue in-flight tasks on stop. + stop_grace_period: 120s build: context: ./api dockerfile: Dockerfile @@ -183,7 +185,7 @@ services: soft: 65536 hard: 65536 entrypoint: - - "../docker-entrypoint.sh" + - "/home/prowler/docker-entrypoint.sh" - "beat" mcp-server: diff --git a/docker-compose.yml b/docker-compose.yml index a9d2e03c3d..6cb5ac237d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ # services: api-init: - image: busybox:1.37.0 + image: busybox:1.37.0@sha256:9532d8c39891ca2ecde4d30d7710e01fb739c87a8b9299685c63704296b16028 volumes: - ./_data/api:/data command: ["sh", "-c", "chown -R 1000:1000 /data"] @@ -60,7 +60,7 @@ services: start_period: 60s postgres: - image: postgres:16.3-alpine3.20 + image: postgres:16.3-alpine3.20@sha256:36ed71227ae36305d26382657c0b96cbaf298427b3f1eaeb10d77a6dea3eec41 hostname: "postgres-db" volumes: - ./_data/postgres:/var/lib/postgresql/data @@ -80,7 +80,7 @@ services: retries: 5 valkey: - image: valkey/valkey:7-alpine3.19 + image: valkey/valkey:7-alpine3.19@sha256:4054fe7fc607b9326ac7c4691ed26e9670d2ff17a9fb28c2577adecf928acbcc hostname: "valkey" volumes: - ./_data/valkey:/data @@ -96,7 +96,7 @@ services: retries: 3 neo4j: - image: graphstack/dozerdb:5.26.3.0 + image: graphstack/dozerdb:5.26.3.0@sha256:a77526ea3918fdc46d1fff70c4aea7d71d3874a26ecec059179d6775845b1247 hostname: "neo4j" volumes: - ./_data/neo4j:/data @@ -129,6 +129,8 @@ services: worker: image: prowlercloud/prowler-api:${PROWLER_API_VERSION:-stable} + # Give Celery soft shutdown time to drain/re-queue in-flight tasks on stop. + stop_grace_period: 120s env_file: - path: .env required: false @@ -158,7 +160,7 @@ services: soft: 65536 hard: 65536 entrypoint: - - "../docker-entrypoint.sh" + - "/home/prowler/docker-entrypoint.sh" - "beat" mcp-server: diff --git a/docs/developer-guide/ai-skills.mdx b/docs/developer-guide/ai-skills.mdx index 6a0787dac8..ceb154d027 100644 --- a/docs/developer-guide/ai-skills.mdx +++ b/docs/developer-guide/ai-skills.mdx @@ -8,7 +8,77 @@ This guide explains the AI Skills system that provides on-demand context and pat **What are AI Skills?** Skills are structured instructions that help AI agents (Claude Code, Cursor, Copilot, etc.) understand Prowler's conventions, patterns, and best practices. -## Architecture Overview +Skills live in the [`skills/`](https://github.com/prowler-cloud/prowler/tree/master/skills) directory of the Prowler OSS repository. Each skill is a folder containing a `SKILL.md` file with its patterns and metadata. + +## Installation + +To enable skills for the supported AI coding assistants, run the setup script from the repository root: + +```bash +./skills/setup.sh +``` + +The script creates symlinks so each tool finds the skills in its expected location: + +| Tool | Created by setup | +|------|------------------| +| Claude Code | `.claude/skills/` symlink and `CLAUDE.md` | +| Gemini CLI | `.gemini/skills/` symlink and `GEMINI.md` | +| Codex (OpenAI) | `.codex/skills/` symlink (uses `AGENTS.md` natively) | +| GitHub Copilot | `.github/copilot-instructions.md` symlink to `AGENTS.md` | + +After running the setup, restart the AI coding assistant to load the skills. + +## Using Skills + +AI agents discover skills automatically and load them when a request matches a skill trigger. To load a skill manually during a session, point the agent to the skill's `SKILL.md` file: + +```text +Read skills/{skill-name}/SKILL.md +``` + +For the full list of available skills, their triggers, and the Auto-invoke mappings, see the [`skills/README.md`](https://github.com/prowler-cloud/prowler/blob/master/skills/README.md) and [`AGENTS.md`](https://github.com/prowler-cloud/prowler/blob/master/AGENTS.md) in the repository. + +## Available Skills + +| Type | Skills | +|------|--------| +| **Generic** | typescript, react-19, nextjs-16, tailwind-4, pytest, playwright, django-drf, zod-4, zustand-5, ai-sdk-5, vitest, tdd | +| **Prowler** | prowler, prowler-sdk-check, prowler-api, prowler-ui, prowler-mcp, prowler-provider, prowler-compliance, prowler-compliance-review, prowler-docs, prowler-pr, prowler-ci, prowler-attack-paths-query | +| **Testing** | prowler-test-sdk, prowler-test-api, prowler-test-ui | +| **Meta** | skill-creator, skill-sync | + + +This table is a snapshot. The repository is the source of truth: see [`skills/README.md`](https://github.com/prowler-cloud/prowler/blob/master/skills/README.md) for the current, complete list. + + +## Skill Structure + +Each skill follows the [Agent Skills spec](https://agentskills.io): + +```text +skills/{skill-name}/ +├── SKILL.md # Patterns, rules, decision trees +├── assets/ # Code templates, schemas +└── references/ # Links to local docs (single source of truth) +``` + +## Key Design Decisions + +1. **Self-contained skills** - Critical patterns inline for fast loading +2. **Local doc references** - No web URLs, points to `docs/developer-guide/*.mdx` +3. **Single source of truth** - Skills reference docs, no duplication +4. **On-demand loading** - AI loads only what's needed for the task + +## Creating New Skills + +Use the `skill-creator` meta-skill to create new skills that follow the Agent Skills spec. See [`AGENTS.md`](https://github.com/prowler-cloud/prowler/blob/master/AGENTS.md) for the full list of available skills and their triggers. + +## How Skills Work + +The diagrams below explain the internals of the skill system. They are useful for understanding the design, but are not required to install or use skills. + +### Architecture Overview ```mermaid graph LR @@ -28,7 +98,7 @@ graph LR style F fill:#1a4d2e,stroke:#66bb6a,color:#fff ``` -## How It Works +### Request Lifecycle ```mermaid sequenceDiagram @@ -68,7 +138,7 @@ sequenceDiagram A->>U: Creates check with correct patterns ``` -## Before vs After +### With and Without Skills ```mermaid graph TD @@ -96,7 +166,7 @@ graph TD style AFTER fill:#1a4d1a,stroke:#66bb6a,color:#fff ``` -## Complete Architecture +### Full Component Map ```mermaid flowchart TB @@ -110,7 +180,7 @@ flowchart TB subgraph GENERIC["Generic Skills"] G1["typescript"] G2["react-19"] - G3["nextjs-15"] + G3["nextjs-16"] G4["tailwind-4"] G5["pytest"] G6["playwright"] @@ -186,34 +256,3 @@ flowchart TB style STRUCTURE fill:#5c3d1a,stroke:#ffb74d,color:#fff style DOCS fill:#1a3d4d,stroke:#4dd0e1,color:#fff ``` - -## Skills Included - -| Type | Skills | -|------|--------| -| **Generic** | typescript, react-19, nextjs-15, tailwind-4, pytest, playwright, django-drf, zod-4, zustand-5, ai-sdk-5 | -| **Prowler** | prowler, prowler-sdk-check, prowler-api, prowler-ui, prowler-mcp, prowler-provider, prowler-compliance, prowler-compliance-review, prowler-docs, prowler-pr, prowler-ci | -| **Testing** | prowler-test-sdk, prowler-test-api, prowler-test-ui | -| **Meta** | skill-creator, skill-sync | - -## Skill Structure - -Each skill follows the [Agent Skills spec](https://agentskills.io): - -``` -skills/{skill-name}/ -├── SKILL.md # Patterns, rules, decision trees -├── assets/ # Code templates, schemas -└── references/ # Links to local docs (single source of truth) -``` - -## Key Design Decisions - -1. **Self-contained skills** - Critical patterns inline for fast loading -2. **Local doc references** - No web URLs, points to `docs/developer-guide/*.mdx` -3. **Single source of truth** - Skills reference docs, no duplication -4. **On-demand loading** - AI loads only what's needed for the task - -## Creating New Skills - -Use the `skill-creator` meta-skill to create new skills that follow the Agent Skills spec. See `AGENTS.md` for the full list of available skills and their triggers. diff --git a/docs/developer-guide/end2end-testing.mdx b/docs/developer-guide/end2end-testing.mdx index 0a62251531..9013a32245 100644 --- a/docs/developer-guide/end2end-testing.mdx +++ b/docs/developer-guide/end2end-testing.mdx @@ -221,9 +221,9 @@ Before running E2E tests: ``` - **Ensure Prowler API is available** - - By default, Playwright uses `NEXT_PUBLIC_API_BASE_URL=http://localhost:8080/api/v1` (configured in `playwright.config.ts`). + - By default, Playwright uses `UI_API_BASE_URL=http://localhost:8080/api/v1` (configured in `playwright.config.ts`). - Start Prowler API so it is reachable on that URL (for example, via `docker-compose-dev.yml` or the development orchestration used locally). - - If a different API URL is required, set `NEXT_PUBLIC_API_BASE_URL` accordingly before running the tests. + - If a different API URL is required, set `UI_API_BASE_URL` accordingly before running the tests. - **Ensure Prowler App UI is available** - Playwright automatically starts the Next.js server through the `webServer` block in `playwright.config.ts` (`pnpm run dev` by default). diff --git a/docs/developer-guide/environment-variables.mdx b/docs/developer-guide/environment-variables.mdx new file mode 100644 index 0000000000..1f4d17ee2e --- /dev/null +++ b/docs/developer-guide/environment-variables.mdx @@ -0,0 +1,53 @@ +--- +title: 'Environment Variable Naming Convention' +--- + +Prowler is a monorepo composed of several runtime components — Prowler App (the web user interface), Prowler API (the backend), Prowler SDK, and Prowler MCP Server (Model Context Protocol) — that frequently share a single `.env` file. To keep that shared configuration unambiguous, each component namespaces its environment variables with a component-specific prefix. + +## Component Prefixes + +Each component owns a dedicated prefix for the environment variables it reads: + +| Component | Prefix | Status | +|-----------|--------|--------| +| Prowler App (web UI) | `UI_` | Adopted | +| Prowler API (backend) | `API_` | Planned | +| Prowler SDK | `SDK_` | Planned | +| Prowler MCP Server | `MCP_` | Planned | + +## Why Component Prefixes Matter + +Component prefixes solve three concrete problems in a shared configuration file: + +- **Collisions in a shared `.env`:** Several components historically read identically named variables. The API base URL, for example, is consumed by more than one component, so a single unprefixed name is ambiguous. A component prefix removes that ambiguity. +- **Explicit ownership:** A prefix states, at a glance, which component consumes a variable. +- **Reduced accidental exposure:** For Prowler App, scoping browser-facing configuration under one intentional prefix prevents server-only values from leaking into the client bundle. + +## Prowler App + +Prowler App has adopted the `UI_` prefix. Its public configuration is resolved from the container environment at runtime rather than inlined at build time, so a single pre-built image serves any deployment. For the operational details on changing these values without rebuilding the image, see [Troubleshooting](/troubleshooting). + +The former build-time variables map to the new runtime variables as follows: + +| Former variable | New variable | +|-----------------|--------------| +| `NEXT_PUBLIC_API_BASE_URL` | `UI_API_BASE_URL` | +| `NEXT_PUBLIC_API_DOCS_URL` | `UI_API_DOCS_URL` | +| `NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID` | `UI_GOOGLE_TAG_MANAGER_ID` | +| `NEXT_PUBLIC_SENTRY_DSN`, `SENTRY_DSN` | `UI_SENTRY_DSN` | +| `NEXT_PUBLIC_SENTRY_ENVIRONMENT`, `SENTRY_ENVIRONMENT` | `UI_SENTRY_ENVIRONMENT` | + +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 the App's runtime configuration. + +## Upcoming Breaking Change + + +Adopting the `API_`, `SDK_`, and `MCP_` prefixes for Prowler API, Prowler SDK, and Prowler MCP Server is a planned breaking change in a future release. Migrate environment configuration to the new names when upgrading. + + +Prowler API, Prowler SDK, and Prowler MCP Server have not yet adopted the convention. In a future release, the variables each of these components reads will be namespaced under `API_`, `SDK_`, and `MCP_` respectively. The per-component mapping from current to prefixed names will be documented when each change is released. + +## Deprecated Names + +- **Prowler App:** The bare server-side `SENTRY_DSN` and `SENTRY_ENVIRONMENT` are no longer read; the server and edge runtimes now read `UI_SENTRY_DSN` and `UI_SENTRY_ENVIRONMENT`. The former `NEXT_PUBLIC_*` build-time variables are deprecated but still read at runtime as a fallback when the matching `UI_*` variable is unset. This fallback will be removed in a future release, so set the `UI_*` runtime variables on the running container. +- **Prowler API, Prowler SDK, and Prowler MCP Server:** The current, unprefixed variable names are deprecated. They continue to work today and will be removed once the prefixed convention is adopted for each component, as described in [Upcoming Breaking Change](#upcoming-breaking-change). diff --git a/docs/developer-guide/introduction.mdx b/docs/developer-guide/introduction.mdx index 117640c669..1d434912ef 100644 --- a/docs/developer-guide/introduction.mdx +++ b/docs/developer-guide/introduction.mdx @@ -108,6 +108,39 @@ uv sync source .venv/bin/activate ``` +### Running the Local API Development Stack + +For API development, Prowler provides a Makefile-based local stack in addition to the manual and Docker Compose workflows documented in the API README. PostgreSQL, Valkey, and Neo4j run with Docker Compose, while Django and the Celery worker run natively through `uv`. + +Before using this method, ensure `docker compose`, `tmux`, and `uv` are installed. + +This workflow is designed for macOS and should also work on Linux when Docker, `tmux`, and `uv` are available. Windows requires script changes before it can be supported. + +To start the local API stack, run: + +```shell +make dev +``` + +This command starts the required services, creates a `tmux` session with panes for the API, worker, and PostgreSQL logs, waits until the API responds, and prints the API URL and log file paths. The API is available at: + +```text +http://localhost:8080/api/v1 +``` + +Use these commands to manage the stack: + +```shell +make dev-setup # Bootstrap dependencies, migrations, and fixtures +make dev-attach # Attach to the tmux session +make dev-launch # Start the stack on fixed ports and attach +make dev-stop # Stop the tmux session and containers +make dev-clean # Remove stopped development containers +make dev-wipe # Stop everything and delete local development data +make dev-status # Show development container status +``` + +The UI is not started by this workflow. Start it separately by following the UI development instructions in the `ui/` directory. ### Pre-Commit Hooks diff --git a/docs/developer-guide/security-compliance-framework.mdx b/docs/developer-guide/security-compliance-framework.mdx index 431849689f..cf756c4da0 100644 --- a/docs/developer-guide/security-compliance-framework.mdx +++ b/docs/developer-guide/security-compliance-framework.mdx @@ -2,40 +2,228 @@ title: 'Creating a New Security Compliance Framework in Prowler' --- -This guide explains how to add a new security compliance framework to Prowler, end to end. It covers directory layout, the JSON schema, check mapping conventions, the Pydantic models that validate each framework, the CSV output formatter, local validation, testing, and the pull request process. +This guide explains how to add a new security compliance framework to Prowler, end to end. It covers directory layout, the two supported JSON schemas (universal and legacy), the Pydantic models that validate each framework, check mapping conventions, output formatting, local validation, testing, and the pull request process. ## Introduction -A compliance framework in Prowler maps a public or custom control catalog (for example CIS, NIST 800-53, PCI DSS, HIPAA, ENS, CCC) to the security checks that Prowler already runs. Each requirement links to zero, one or more Prowler checks. When a scan executes, findings are aggregated per requirement to produce the compliance report rendered by Prowler CLI and Prowler Cloud. +A compliance framework in Prowler maps a public or custom control catalog (for example CIS, NIST 800-53, PCI DSS, HIPAA, ENS, CCC, DORA) to the security checks that Prowler already runs. Each requirement links to zero, one or more Prowler checks. When a scan executes, findings are aggregated per requirement to produce the compliance report rendered by Prowler CLI and Prowler Cloud. -Prowler ships with 85+ compliance frameworks across All Providers. The catalog lives under `prowler/compliance//` (or `prowler/compliance/` for universal compliance frameworks) +Prowler ships 85+ compliance frameworks across all providers. The catalog lives under `prowler/compliance//` (legacy, per-provider) or `prowler/compliance/` (universal, multi-provider). -A compliance framework must represent the **complete state** of the source catalog. Every requirement defined by the framework has to be present in the JSON file, even when none of the existing Prowler checks can automate it. In that case, leave `Checks` as an empty array, but do not omit the requirement. +A compliance framework must represent the **complete state** of the source catalog. Every requirement defined by the framework has to be present in the JSON file, even when no Prowler check can automate it. In that case, leave the requirement's check list empty, but do not omit the requirement. 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 + +| Schema | When to use | File location | Discovered as | +| --- | --- | --- | --- | +| **Universal (recommended for new frameworks)** | Multi-provider frameworks, or single-provider frameworks that benefit from declarative table/PDF rendering | `prowler/compliance/.json` (top-level) | Available for **every** provider whose key appears in any `requirement.checks` dict | +| **Legacy provider-specific** | Single-provider frameworks with framework-specific attribute classes already declared in the codebase (CIS, ENS, ISO 27001, etc.) | `prowler/compliance//__.json` | Available only under that provider | + +Auto-discovery happens in `get_bulk_compliance_frameworks_universal(provider)` (`prowler/lib/check/compliance_models.py:915`), which scans **both** the top-level `prowler/compliance/` directory and every per-provider sub-directory. Legacy frameworks are transparently converted to the universal `ComplianceFramework` model via `adapt_legacy_to_universal()` before being returned, so the rest of Prowler — CLI table rendering, CSV/OCSF outputs, PDF generation — works the same regardless of the source schema. + +> The legacy entry-point `Compliance.get_bulk(provider)` (used by older code paths) only scans per-provider sub-directories. Universal top-level files are picked up exclusively via the universal loader; this matters if you are wiring a new code path against the legacy API. + +For **new** frameworks, prefer the universal schema: it requires no Python code changes, supports multiple providers in a single file, and table/PDF rendering is driven entirely from declarative configuration inside the JSON. + +> All Pydantic models in `compliance_models.py` are imported from `pydantic.v1`. Subclasses you add for the legacy schema must use `from pydantic.v1 import BaseModel`. + ### Prerequisites Before adding a new framework, complete the following checks: -- **Verify the framework is not already supported.** Inspect `prowler/compliance//` for an existing JSON file matching the name and version. +- **Verify the framework is not already supported.** Inspect `prowler/compliance/` and every `prowler/compliance//` for an existing JSON file matching the name and version. - **Confirm the required checks exist.** Every requirement that can be automated must point to one or more existing Prowler checks. For each missing check, implement it first by following the [Prowler Checks](/developer-guide/checks) guide. -- **Review a reference framework.** Use an existing framework with a similar structure as your template. `cis_2.0_aws.json` is the canonical reference for CIS-style frameworks. `ccc_aws.json`, `ens_rd2022_aws.json`, and `nist_800_53_revision_5_aws.json` illustrate other attribute shapes. +- **Review a reference framework.** Use an existing framework with a similar structure as your template: + - Universal: `prowler/compliance/dora_2022_2554.json`, `prowler/compliance/csa_ccm_4.0.json`. + - Legacy: `prowler/compliance/aws/cis_2.0_aws.json` (canonical CIS shape), `prowler/compliance/aws/ccc_aws.json`, `prowler/compliance/aws/ens_rd2022_aws.json`, `prowler/compliance/aws/nist_800_53_revision_5_aws.json`. -## Four-Layer Architecture +## Universal Compliance Framework -A compliance framework spans four layers. A complete contribution must touch each layer that applies. +### Where the file lives -- **Layer 1 – Schema validation:** The Pydantic models in `prowler/lib/check/compliance_models.py` define the canonical schema for each attribute shape (CIS, ENS, Mitre, CCC, C5, CSA CCM, ISO 27001, KISA ISMS-P, AWS Well-Architected, Prowler ThreatScore, and a generic fallback). -- **Layer 2 – JSON catalog:** The framework JSON file in `prowler/compliance//` lists every requirement and maps it to checks. -- **Layer 3 – Output formatter:** The Python module in `prowler/lib/outputs/compliance//` builds the CSV row model, the per-provider transformer, and the CLI summary table. -- **Layer 4 – Output dispatchers:** The dispatchers in `prowler/lib/outputs/compliance/compliance.py` and `prowler/lib/outputs/compliance/compliance_output.py` route findings to the right formatter based on the framework identifier. +Place the file at the top level of the compliance directory: -The rest of this guide walks each layer in order. +``` +prowler/compliance/.json +``` -## Directory Structure and File Naming +Examples in the repository: `prowler/compliance/csa_ccm_4.0.json`, `prowler/compliance/dora_2022_2554.json`. + +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 + +```json +{ + "framework": "", + "name": "", + "version": "", + "description": "", + "icon": "", + "attributes_metadata": [ /* see below */ ], + "outputs": { /* see below — optional */ }, + "requirements": [ /* see below */ ] +} +``` + +A `provider` field at the top level is **optional**. The framework's effective provider list is derived by `ComplianceFramework.get_providers()` (`compliance_models.py:739`) from the union of all keys appearing in `requirement.checks` across all requirements; the explicit `provider` field is used **only as a fallback** when no requirement carries any `checks` key. This is what enables a single file (e.g. `dora_2022_2554.json`) to cover AWS today and add Azure / GCP / etc. tomorrow without restructuring. + +Provider keys inside `requirement.checks` must match the directory names under `prowler/providers/`. The valid keys at present are: `aws`, `azure`, `gcp`, `m365`, `kubernetes`, `iac`, `github`, `googleworkspace`, `alibabacloud`, `cloudflare`, `mongodbatlas`, `nhn`, `openstack`, `oraclecloud`, `llm`. Comparison in `supports_provider()` is case-insensitive, but lowercase is the convention used everywhere in the repository. + +### `attributes_metadata` + +Declares the shape of the per-requirement `attributes` dict. When this field is present, the root validator `validate_attributes_against_metadata` (`compliance_models.py:669`) enforces the schema at load time and rejects: + +- Missing keys marked `required: true`. +- Keys present in `attributes` but not declared in `attributes_metadata` (typo / drift guard). +- Values that violate a declared `enum`. +- Values whose Python type does not match a declared `int`, `float` or `bool`. + +The runtime type check **only** covers `int`, `float` and `bool`. For `str`, `list_str` and `list_dict` the type is documentation-only — non-conforming values won't fail validation. If `attributes_metadata` is omitted, **no per-requirement validation runs at all**. + +```json +"attributes_metadata": [ + { + "key": "Pillar", + "label": "Pillar", + "type": "str", + "required": true, + "enum": [ + "ICT Risk Management", + "ICT-Related Incident Reporting", + "Digital Operational Resilience Testing", + "ICT Third-Party Risk Management", + "Information Sharing" + ], + "output_formats": { "csv": true, "ocsf": true } + }, + { + "key": "Article", + "label": "Article", + "type": "str", + "required": true, + "output_formats": { "csv": true, "ocsf": true } + } +] +``` + +Per attribute: + +- `key` (required): attribute name as it will appear in `requirement.attributes`. +- `label`: human-readable label used in CSV headers and PDF. +- `type`: one of `str`, `int`, `float`, `bool`, `list_str`, `list_dict`. Defaults to `str`. +- `enum`: optional list of allowed values; non-conforming values are rejected at load time. +- `required`: if `true`, every requirement must include this key with a non-null value. +- `enum_display` / `enum_order`: optional per-enum-value visual metadata (label, abbreviation, color, icon) and explicit ordering for PDF rendering. +- `output_formats`: `{ "csv": , "ocsf": }` — toggles inclusion in each output format. Both default to `true`. + +### `outputs` + +Optional. Controls how the framework is rendered in the console table and in the generated PDF report. Skipping it falls back to sensible defaults. + +```json +"outputs": { + "table_config": { + "group_by": "Pillar" + }, + "pdf_config": { + "language": "en", + "primary_color": "#003399", + "secondary_color": "#0055A5", + "bg_color": "#F0F4FA", + "group_by_field": "Pillar", + "sections": [ "ICT Risk Management", "ICT-Related Incident Reporting", "..." ], + "section_short_names": { "ICT Risk Management": "ICT Risk Mgmt" }, + "charts": [ + { + "id": "pillar_compliance", + "type": "horizontal_bar", + "group_by": "Pillar", + "title": "Compliance Score by Pillar", + "y_label": "Pillar", + "x_label": "Compliance %", + "value_source": "compliance_percent", + "color_mode": "by_value" + } + ], + "filter": { "only_failed": true, "include_manual": false } + } +} +``` + +`table_config.group_by` must reference an attribute key declared in `attributes_metadata`. The same applies to `pdf_config.group_by_field` and to every `charts[].group_by`. + +For frameworks with weighted scoring (e.g. ThreatScore) declare `pdf_config.scoring` with `risk_field` / `weight_field` / `risk_boost_factor`. For column splitting (e.g. CIS Level 1 vs Level 2) use `table_config.split_by`. + +### `requirements` + +```json +"requirements": [ + { + "id": "DORA-Art5", + "name": "Governance and organisation", + "description": "Financial entities shall have a sound, comprehensive and well-documented ICT internal governance and control framework. ...", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 5", + "ArticleTitle": "Governance and organisation" + }, + "checks": { + "aws": [ + "iam_avoid_root_usage", + "iam_no_root_access_key", + "iam_root_mfa_enabled" + ], + "azure": [], + "gcp": [] + } + } +] +``` + +Per requirement: + +- `id` (required): unique identifier within the framework. +- `description` (required): the requirement text as authored by the framework. +- `name`: short title shown alongside the id. +- `attributes`: flat dict; keys must conform to `attributes_metadata`. +- `checks`: dict keyed by provider name (the same lowercase keys listed in the previous section). Each value is a list of Prowler check names that evidence this requirement for that provider. The list **may be empty** and the dict itself defaults to `{}` if omitted; either way the requirement is still loaded and listed by `--list-compliance-requirements`, it just has zero checks to execute. Note: there is **no automatic check-existence validation** at load time — referencing a non-existent check name will silently produce a requirement with no findings. Validate this yourself (see "Validating Your Framework" below). + +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 + +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. + +When extending an existing universal framework with a new provider, the only change required is editing `requirement.checks`: + +```diff + "checks": { + "aws": ["iam_avoid_root_usage", "iam_no_root_access_key"], ++ "azure": ["entra_policy_ensure_mfa_for_admin_roles"] + } +``` + +No code changes, no new file, no registration step. + +## Legacy Provider-Specific Compliance Framework + +The legacy schema is still fully supported and remains the format used by most frameworks shipped today (CIS, NIST, ISO 27001, FedRAMP, PCI DSS, GDPR, HIPAA, ENS, etc.). It binds a framework to a single provider and validates each requirement against a framework-specific Pydantic attribute class. + +The legacy schema spans **four layers** — a complete contribution must touch every layer that applies: + +- **Layer 1 — Schema validation:** the Pydantic models in `prowler/lib/check/compliance_models.py` define the canonical schema for each attribute shape. +- **Layer 2 — JSON catalog:** the framework JSON file in `prowler/compliance//` lists every requirement and maps it to checks. +- **Layer 3 — Output formatter:** the Python module in `prowler/lib/outputs/compliance//` builds the CSV row model, the per-provider transformer, and the CLI summary table. +- **Layer 4 — Output dispatchers:** the dispatchers in `prowler/lib/outputs/compliance/compliance.py` and `prowler/lib/outputs/compliance/compliance_output.py` route findings to the right formatter based on the framework identifier. + +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 Compliance frameworks live at: @@ -46,8 +234,8 @@ prowler/compliance//__.json The filename conventions are: - All lowercase, words separated with underscores. -- `` is a supported provider identifier: `aws`, `azure`, `gcp`, `kubernetes`, `m365`, `github`, `googleworkspace`, `alibabacloud`, `oraclecloud`, `cloudflare`, `mongodbatlas`, `nhn`, `openstack`, `iac`, `llm`. -- `` is optional. Omit it when the framework has no versioning, as in `ccc_aws.json`. +- `` is a supported provider identifier (same lowercase list as the universal section above). +- `` is optional but recommended. Omit only when the framework has no versioning (e.g. `ccc_aws.json`). - The file basename (without `.json`) is the framework key that Prowler CLI accepts via `--compliance`. Examples: @@ -62,48 +250,50 @@ The output formatter directory mirrors the framework name: ``` prowler/lib/outputs/compliance// -├── .py # CLI summary-table dispatcher +├── .py # CLI summary-table dispatcher ├── _.py # Per-provider transformer class ├── models.py # Pydantic CSV row model └── __init__.py ``` -## JSON Schema Reference +### JSON schema reference -Every compliance file is a JSON document with the following top-level keys. +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:329`). | Field | Type | Required | Description | |---|---|---|---| | `Framework` | string | Yes | Canonical framework identifier, for example `CIS`, `NIST-800-53-Revision-5`, `ENS`, `CCC`. | | `Name` | string | Yes | Human-readable framework name displayed by Prowler App. | -| `Version` | string | Yes | Framework version, for example `2.0`. Use an empty string only for frameworks without versioning. See [Version Handling](#version-handling). | +| `Version` | string | Yes (recommended) | Framework version, e.g. `2.0`. See [Version Handling](#version-handling). | | `Provider` | string | Yes | Upper-cased provider identifier: `AWS`, `AZURE`, `GCP`, `KUBERNETES`, `M365`, `GITHUB`, `GOOGLEWORKSPACE`, and so on. | | `Description` | string | Yes | Short description of the framework's scope and purpose. | | `Requirements` | array | Yes | List of [requirement objects](#requirement-object). | -### Requirement Object +#### Requirement Object Each entry in `Requirements` describes one control or requirement. | Field | Type | Required | Description | |---|---|---|---| | `Id` | string | Yes | Unique identifier within the framework, for example `1.10` or `CCC.Core.CN01.AR01`. | -| `Name` | string | No | Optional human-readable name used by frameworks that distinguish control name from description, such as NIST. | +| `Name` | string | No | Optional human-readable name (frameworks like NIST distinguish control name from description). | | `Description` | string | Yes | Verbatim description from the source framework. | | `Attributes` | array | Yes | List of [attribute objects](#attribute-objects). The shape depends on the framework. | | `Checks` | array of strings | Yes | Prowler check identifiers that automate the requirement. Leave the list empty when the control cannot be automated. | -### Attribute Objects +#### Attribute Objects -Attributes carry the metadata that Prowler App and the CSV output display for each requirement. The object shape is framework-specific and is validated by a dedicated Pydantic model in `prowler/lib/check/compliance_models.py`. The most common shapes are summarized below. +`Attributes` is parsed against the union declared in `Compliance_Requirement.Attributes` (`compliance_models.py:293`). Pydantic v1 tries each member of the union in declaration order and falls back to `Generic_Compliance_Requirement_Attribute` (the last entry) when nothing else matches — so a brand-new shape that doesn't match any existing class will silently be accepted as Generic, losing its specific fields. -#### CIS_Requirement_Attribute +As of today, the registered attribute classes are: `CIS_Requirement_Attribute`, `ENS_Requirement_Attribute`, `ASDEssentialEight_Requirement_Attribute`, `ISO27001_2013_Requirement_Attribute`, `AWS_Well_Architected_Requirement_Attribute`, `KISA_ISMSP_Requirement_Attribute`, `Prowler_ThreatScore_Requirement_Attribute`, `CCC_Requirement_Attribute`, `C5Germany_Requirement_Attribute`, `CSA_CCM_Requirement_Attribute`, and `Generic_Compliance_Requirement_Attribute` (fallback). MITRE-style frameworks use the separate `Mitre_Requirement` model with `Tactics` / `SubTechniques` / `Platforms` / `TechniqueURL` at the requirement top level. The most common shapes are summarized below. + +##### CIS_Requirement_Attribute Used by every CIS benchmark. | Field | Type | Required | Notes | |---|---|---|---| -| `Section` | string | Yes | Top-level section, for example `1 Identity and Access Management`. | +| `Section` | string | Yes | Top-level section, e.g. `1 Identity and Access Management`. | | `SubSection` | string | No | Optional second-level grouping. | | `Profile` | enum | Yes | One of `Level 1`, `Level 2`, `E3 Level 1`, `E3 Level 2`, `E5 Level 1`, `E5 Level 2`. | | `AssessmentStatus` | enum | Yes | `Manual` or `Automated`. | @@ -116,7 +306,7 @@ Used by every CIS benchmark. | `DefaultValue` | string | No | Default configuration value, when relevant. | | `References` | string | Yes | Colon-separated list of reference URLs. | -#### ENS_Requirement_Attribute +##### ENS_Requirement_Attribute Used by the Spanish ENS (Esquema Nacional de Seguridad) frameworks. @@ -132,13 +322,13 @@ Used by the Spanish ENS (Esquema Nacional de Seguridad) frameworks. | `ModoEjecucion` | string | Yes | Execution mode (`manual`, `automático`, `híbrido`). | | `Dependencias` | array of strings | Yes | Ids of prerequisite controls. Empty list when none. | -#### CCC_Requirement_Attribute +##### CCC_Requirement_Attribute Used by the Common Cloud Controls Catalog. | Field | Type | Required | Notes | |---|---|---|---| -| `FamilyName` | string | Yes | Control family, for example `Data`. | +| `FamilyName` | string | Yes | Control family, e.g. `Data`. | | `FamilyDescription` | string | Yes | Description of the family. | | `Section` | string | Yes | Section title. | | `SubSection` | string | Yes | Subsection title, or empty string. | @@ -148,9 +338,9 @@ Used by the Common Cloud Controls Catalog. | `SectionThreatMappings` | array of objects | Yes | Each entry has `ReferenceId` and `Identifiers`. | | `SectionGuidelineMappings` | array of objects | Yes | Each entry has `ReferenceId` and `Identifiers`. | -#### Generic_Compliance_Requirement_Attribute +##### Generic_Compliance_Requirement_Attribute -The fallback attribute model used when no framework-specific schema applies (for example NIST 800-53, PCI DSS, GDPR, HIPAA). +The fallback attribute model used when no framework-specific schema applies (e.g. NIST 800-53, PCI DSS, GDPR, HIPAA). It is **always the last** element of the `Compliance_Requirement.Attributes` Union; that ordering is load-bearing. | Field | Type | Required | Notes | |---|---|---|---| @@ -158,17 +348,17 @@ The fallback attribute model used when no framework-specific schema applies (for | `Section` | string | No | Section name. | | `SubSection` | string | No | Subsection name. | | `SubGroup` | string | No | Subgroup name. | -| `Service` | string | No | Affected service, for example `aws`, `iam`. | +| `Service` | string | No | Affected service, e.g. `iam`. | | `Type` | string | No | Control type. | | `Comment` | string | No | Free-form comment. | -Additional per-framework attribute models exist for `AWS_Well_Architected_Requirement_Attribute`, `ISO27001_2013_Requirement_Attribute`, `Mitre_Requirement_Attribute_`, `KISA_ISMSP_Requirement_Attribute`, `Prowler_ThreatScore_Requirement_Attribute`, `C5Germany_Requirement_Attribute`, and `CSA_CCM_Requirement_Attribute`. Consult `prowler/lib/check/compliance_models.py` for their full field sets. +For the remaining attribute classes (`AWS_Well_Architected_Requirement_Attribute`, `ISO27001_2013_Requirement_Attribute`, `Mitre_Requirement_Attribute_`, `KISA_ISMSP_Requirement_Attribute`, `Prowler_ThreatScore_Requirement_Attribute`, `C5Germany_Requirement_Attribute`, `CSA_CCM_Requirement_Attribute`) consult `prowler/lib/check/compliance_models.py` for the full field sets. -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. +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. @@ -214,26 +404,26 @@ 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. -- **Include every requirement from the source catalog.** The framework file must mirror the full control list, one-to-one. Compliance percentages, dashboards, and exported metadata are computed against the total requirement count, so omitting an unmappable control inflates coverage and misrepresents the framework. -- List every check by its canonical identifier, the value of `CheckID` inside the check's `.metadata.json` file. +- **Include every requirement from the source catalog.** The framework file must mirror the full control list, one-to-one. Compliance percentages, dashboards, and exported metadata are computed against the total requirement count. +- List every check by its canonical identifier — the value of `CheckID` inside the check's `.metadata.json` file. - One requirement can reference multiple checks. The requirement is evaluated as FAIL when any referenced check produces a FAIL finding for a resource in scope. -- Leave `Checks` as an empty array when the requirement cannot be automated. The requirement still appears in the report, contributes to the total, and resolves to `MANUAL`. An empty mapping is valid; a missing requirement is not. +- Leave `Checks` (legacy) or `checks.` (universal) as an empty array when the requirement cannot be automated. The requirement still appears in the report and contributes to the total. - Reuse checks across requirements when the same control applies in multiple places. Do not duplicate check logic to match framework structure. -- Avoid referencing checks from a different provider. A compliance file is bound to one provider, and cross-provider checks will never match findings in the scan. +- Avoid referencing checks from a different provider. A legacy compliance file is bound to one provider, and cross-provider checks will never match findings in the scan. -To discover available checks, run: +To discover available checks: ```bash uv run python prowler-cli.py --list-checks ``` -## Supporting Multiple Providers +### Supporting multiple providers (legacy) -Each compliance file targets a single provider. To cover several providers with the same framework (for example CIS across AWS, Azure, and GCP), ship one JSON file per provider: +The legacy schema binds each file to a single provider. To cover several providers with the same framework, ship one JSON file per provider: ``` prowler/compliance/aws/cis_2.0_aws.json @@ -241,15 +431,15 @@ prowler/compliance/azure/cis_2.0_azure.json prowler/compliance/gcp/cis_2.0_gcp.json ``` -Keep the `Framework` and `Version` values identical across the files so the dispatcher matches them, and change only the `Provider`, `Checks`, and provider-specific metadata. +Keep the `Framework` and `Version` values identical across the files so the dispatcher matches them; change only the `Provider`, `Checks`, and provider-specific metadata. The CIS output formatter already supports every provider listed above. -The CIS output formatter already supports every provider listed above. For a brand-new framework that spans several providers, add one transformer per provider in `prowler/lib/outputs/compliance//` and extend the summary-table dispatcher accordingly. See [Output Formatter](#output-formatter). +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 -Prowler renders every compliance framework 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. +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. -For a new framework named `my_framework`, create: +For a new legacy framework named `my_framework`, create: ``` prowler/lib/outputs/compliance/my_framework/ @@ -259,19 +449,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 Class +#### 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. @@ -280,49 +470,94 @@ 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. -## Version Handling +### Legacy-to-universal adapter + +At load time, every legacy file is transparently adapted to a `ComplianceFramework` via `adapt_legacy_to_universal()` (`compliance_models.py:819`), 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. + +Loader-error behaviour differs between the two entry points: + +- `load_compliance_framework()` (legacy) is **fail-fast**: it calls `sys.exit(1)` on any `ValidationError` (`compliance_models.py:464`). +- `load_compliance_framework_universal()` is more lenient — it logs the error and returns `None`, so `get_bulk_compliance_frameworks_universal()` simply skips the broken file and keeps loading the rest. + +## Version handling Prowler matches frameworks by concatenating `Framework` and `Version`. A missing or empty `Version` collapses several frameworks to the same key and breaks CLI filtering with `--compliance`. -- Always set `Version` to a non-empty string, even for frameworks that rename editions rather than version them. Use the edition identifier (for example `RD2022`, `v2025.10`, `4.0`). +- Always set `Version` (or `version` for universal frameworks) to a non-empty string, even for frameworks that rename editions rather than version them. Use the edition identifier (for example `RD2022`, `v2025.10`, `4.0`, `2022/2554`). - When the source catalog has no version, use the first year of adoption or the release date. -- Make sure the version substring embedded in the filename matches `Version`, because the CLI dispatcher reads `compliance_framework.split("_")[1]` to select the correct version. +- For **legacy** files, make sure the version substring embedded in the filename matches `Version`, because the CLI dispatcher reads `compliance_framework.split("_")[1]` to select the correct version. -## Validating the Framework Locally +## Validating Your Framework -Follow the steps below before opening a pull request. +Before opening a PR, validate the JSON loads cleanly against the model and that every referenced check actually exists. -### 1. Run the Compliance Model Validator +### 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`. + +```python +from prowler.lib.check.compliance_models import ( + load_compliance_framework_universal, + get_bulk_compliance_frameworks_universal, +) + +fw = load_compliance_framework_universal("prowler/compliance/.json") +assert fw is not None, "load returned None — check the logs for the validation error" +print(fw.framework, len(fw.requirements), fw.get_providers()) + +bulk = get_bulk_compliance_frameworks_universal("aws") +assert "" in bulk +``` + +### 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: + +```python +import os +real = set() +for svc in os.listdir("prowler/providers/aws/services"): + svc_path = f"prowler/providers/aws/services/{svc}" + if not os.path.isdir(svc_path): + continue + for entry in os.listdir(svc_path): + if os.path.isfile(f"{svc_path}/{entry}/{entry}.metadata.json"): + real.add(entry) + +referenced = {c for r in fw.requirements for c in r.checks.get("aws", [])} +missing = referenced - real +assert not missing, f"checks referenced in framework but not found in repo: {sorted(missing)}" +``` + +### 3. CLI smoke test ```bash uv run python prowler-cli.py --list-compliance ``` -The framework must appear in the output. A validation error indicates a schema mismatch between the JSON file and the attribute model. - -### 2. Run a Scan Filtered by the Framework +The framework must appear in the output. A validation error indicates a schema mismatch. ```bash uv run python prowler-cli.py \ - --compliance __ \ + --compliance \ --log-level ERROR ``` Verify that: - Prowler produces a CSV file under `output/compliance/` with the expected name. -- The CLI summary table lists every section in the framework. +- The CLI summary table lists every section / pillar of the framework. - Findings roll up under the expected requirements. -### 3. Inspect the CSV Output +### 4. Inspect the CSV output Open the generated CSV and confirm: -- All columns defined in `models.py` appear. -- Every requirement has at least one row per scanned resource. -- Values such as `Requirements_Attributes_Section` reflect the JSON content. +- All columns defined in `models.py` (legacy) or in `attributes_metadata` (universal) appear. +- Every requirement has at least one row per scanned resource (when there are findings). +- Attribute values such as `Requirements_Attributes_Section` reflect the JSON content. -### 4. Verify the Framework in Prowler App +### 5. Verify the framework in Prowler App Launch Prowler App locally (`docker compose up` from the repository root) and run a scan with the new compliance framework. Confirm the compliance page renders the requirements, sections, and status widgets correctly. @@ -331,7 +566,7 @@ Launch Prowler App locally (`docker compose up` from the repository root) and ru Compliance contributions require two layers of tests. - **Schema tests** exercise the Pydantic models. Extend `tests/lib/check/universal_compliance_models_test.py` with a case that loads the new JSON file and asserts the attribute type matches the expected model. -- **Output tests** exercise the transformer. Mirror the structure under `tests/lib/outputs/compliance//` with fixtures that feed synthetic findings through the transformer and assert the resulting CSV rows. +- **Output tests** (legacy frameworks only) exercise the transformer. Mirror the structure under `tests/lib/outputs/compliance//` with fixtures that feed synthetic findings through the transformer and assert the resulting CSV rows. Run the suite with: @@ -342,7 +577,20 @@ uv run pytest -n auto tests/lib/check/universal_compliance_models_test.py \ For guidance on writing Prowler SDK tests, refer to [Unit Testing](/developer-guide/unit-testing). -## Submitting the Pull Request +## Running and listing your framework + +Once the file is in place, the CLI auto-discovers it: + +```sh +prowler --list-compliance # framework appears in the list +prowler --compliance --list-checks +prowler --compliance # full scan + compliance report +prowler --compliance --list-compliance-requirements +``` + +For end-user-facing tutorials (recommended for high-profile frameworks), add a dedicated page under `docs/user-guide/compliance/tutorials/` and register it in the `"Compliance"` group of `docs/docs.json`. See `docs/user-guide/compliance/tutorials/threatscore.mdx` as a reference. + +## Submitting the pull request Before opening the pull request: @@ -352,28 +600,31 @@ Before opening the pull request: uv run pytest -n auto ``` 2. Add a changelog entry under the `### 🚀 Added` section of `prowler/CHANGELOG.md`, describing the new framework and the providers it covers. -3. Follow the [Pull Request Template](https://github.com/prowler-cloud/prowler/blob/master/.github/pull_request_template.md) and set the PR title using Conventional Commits, for example `feat(compliance): add My Framework 1.0 for AWS`. +3. Follow the [Pull Request Template](https://github.com/prowler-cloud/prowler/blob/master/.github/pull_request_template.md) and set the PR title using Conventional Commits, e.g. `feat(compliance): add My Framework 1.0 for AWS`. 4. Request review from the compliance codeowners listed in `.github/CODEOWNERS`. ## Troubleshooting The following issues are the most common when contributing a compliance framework. -- **`ValidationError: field required` during scan.** The JSON is missing a required attribute field. Re-check the matching Pydantic model in `prowler/lib/check/compliance_models.py`. -- **All attributes collapse to `Generic_Compliance_Requirement_Attribute` values.** The Pydantic `Union` is ordered incorrectly, or the JSON matches only the generic shape. Move the generic model to the last Union position and ensure every required field is present in the JSON. -- **`--compliance` filter does not find the framework.** The filename does not match the expected pattern `__.json`, the version is empty, or the file lives outside `prowler/compliance//`. -- **CLI summary table is empty but the CSV is populated.** The dispatcher branch in `prowler/lib/outputs/compliance/compliance.py` is missing or its substring match does not catch the framework key. -- **CSV file is missing after the scan.** The transformer class is not registered in `prowler/lib/outputs/compliance/compliance_output.py`, or `transform()` raises silently. Run the scan with `--log-level DEBUG`. -- **Findings do not roll up under a requirement.** A check listed in `Checks` either does not exist for that provider or is spelled incorrectly. Run `--list-checks | grep ` to confirm. +- **`ValidationError: field required` during scan (legacy).** The JSON is missing a required attribute field. Re-check the matching Pydantic model in `prowler/lib/check/compliance_models.py`. +- **All attributes collapse to `Generic_Compliance_Requirement_Attribute` values (legacy).** The Pydantic `Union` is ordered incorrectly, or the JSON matches only the generic shape. Keep the generic model in the last Union position and ensure every required field is present in the JSON. +- **`attributes_metadata validation failed` (universal).** The root validator in `compliance_models.py:669` rejected the file. The error message lists each offending requirement; common causes are unknown attribute keys (typo or missing entry in `attributes_metadata`), enum violations, or missing required keys. +- **`--compliance` filter does not find the framework.** For legacy: the filename does not match `__.json`, the version is empty, or the file lives outside `prowler/compliance//`. For universal: the file is not at the top level of `prowler/compliance/` or it loaded as `None` (check logs for the validation error). +- **CLI summary table is empty but the CSV is populated (legacy).** The dispatcher branch in `prowler/lib/outputs/compliance/compliance.py` is missing or its substring match does not catch the framework key. +- **CSV file is missing after the scan (legacy).** The transformer class is not registered in `prowler/lib/outputs/compliance/compliance_output.py`, or `transform()` raises silently. Run the scan with `--log-level DEBUG`. +- **Findings do not roll up under a requirement.** A check listed in `Checks` either does not exist for that provider or is spelled incorrectly. Run `--list-checks | grep ` to confirm, or run the check-existence cross-check from "Validating Your Framework". -## Reference Examples +## Reference examples Use the following files as templates when modeling a new contribution. -- `prowler/compliance/aws/cis_2.0_aws.json` – CIS attribute shape. -- `prowler/compliance/aws/nist_800_53_revision_5_aws.json` – Generic attribute shape. -- `prowler/compliance/aws/ccc_aws.json` – CCC attribute shape. -- `prowler/compliance/azure/ens_rd2022_azure.json` – ENS attribute shape. -- `prowler/lib/check/compliance_models.py` – Canonical Pydantic schemas. -- `prowler/lib/outputs/compliance/cis/` – Reference implementation of a multi-provider output formatter. -- `prowler/lib/outputs/compliance/generic/` – Reference implementation of a generic output formatter. +- `prowler/compliance/dora_2022_2554.json` — universal schema, single-provider populated (AWS), ready to extend with more providers. +- `prowler/compliance/csa_ccm_4.0.json` — universal schema, multi-provider populated (AWS, Azure, GCP, AlibabaCloud, OracleCloud). +- `prowler/compliance/aws/cis_2.0_aws.json` — legacy CIS attribute shape. +- `prowler/compliance/aws/nist_800_53_revision_5_aws.json` — legacy generic attribute shape. +- `prowler/compliance/aws/ccc_aws.json` — legacy CCC attribute shape. +- `prowler/compliance/azure/ens_rd2022_azure.json` — legacy ENS attribute shape. +- `prowler/lib/check/compliance_models.py` — canonical Pydantic schemas for both formats. +- `prowler/lib/outputs/compliance/cis/` — reference implementation of a multi-provider legacy output formatter. +- `prowler/lib/outputs/compliance/generic/` — reference implementation of a legacy generic output formatter. diff --git a/docs/developer-guide/server-sent-events.mdx b/docs/developer-guide/server-sent-events.mdx new file mode 100644 index 0000000000..c91f27227f --- /dev/null +++ b/docs/developer-guide/server-sent-events.mdx @@ -0,0 +1,241 @@ +--- +title: 'Server-Sent Events (SSE)' +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + + + +This guide explains how to add a **Server-Sent Events (SSE)** endpoint to the Prowler API. SSE lets the backend push a one-way stream of events to a client over a single long-lived HTTP connection — ideal for live progress, token-by-token LLM output, or any "the server has news for you" use case where the client should not poll. + + +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 + +| Need | Use | +|------|-----| +| Server pushes incremental updates, client only reads | **SSE** | +| Bidirectional, low-latency messaging (chat both ways, games) | WebSocket | +| Client asks, server answers once | Plain REST | + +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 + +SSE is wired through [`django-eventstream`](https://github.com/fanout/django_eventstream) and a small platform layer in `api/src/backend/api/sse/`: + +| Piece | File | Responsibility | +|-------|------|----------------| +| `BaseSSEViewSet` | `api/sse/base_views.py` | Base DRF viewset a feature subclasses. The feature implements `get_channels`; the base handles auth, the tenant transaction, and delegates streaming to `django-eventstream`. | +| `SSEChannelManager` | `api/sse/channelmanager.py` | Registered in `settings.EVENTSTREAM_CHANNELMANAGER_CLASS`. Reads the channel set off the request and enforces the platform-wide tenant gate. | +| `SSEAuthentication` | `api/authentication.py` | Same JWT/API-key stack as the rest of the API, plus an `?access_token=` fallback for browser `EventSource` clients. Lives with the other authentication classes, not in the `sse` package. | +| `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 + +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 + +``` +publisher (Celery task / view) subscriber (browser, CLI) + │ │ + │ send_event(channel, "scan.progress", …) │ GET …/event-stream + ▼ ▼ + Valkey Pub/Sub ◄────────────────────► BaseSSEViewSet.list + (EVENTSTREAM_VALKEY_DB) → get_channels() (RLS-scoped) + → SSEChannelManager (tenant gate) + → StreamingHttpResponse (text/event-stream) +``` + +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 + +The example below streams progress for a long-running **scan**. Adapt the resource, prefix, and event names to your feature. + + + + + +Channels follow the format `::`, built only through `make_channel_name`. The prefix is owned by your feature and may contain hyphens but **never colons** (the parser splits on `:`). + +```python +CHANNEL_PREFIX = "scan-progress" +``` + +The tenant id is baked into every channel name. That is what lets the platform enforce cross-tenant isolation without knowing anything about your feature. + + + + + +Create the viewset for the SSE sub-resource. The only required method is `get_channels`; it runs inside the tenant transaction set up by the base class, so any database lookup inside it is automatically RLS-scoped. + +```python +# scans/event_streams.py +from api.sse import BaseSSEViewSet, make_channel_name +from django.shortcuts import get_object_or_404 +from scans.models import Scan + +CHANNEL_PREFIX = "scan-progress" + + +class ScanEventStreamViewSet(BaseSSEViewSet): + def get_queryset(self): + # RLS already scopes to the tenant; narrow further as needed + # (e.g. only scans the requesting user may see). + return Scan.objects.filter(tenant_id=self.request.tenant_id) + + def get_channels(self) -> set[str]: + scan = get_object_or_404(self.get_queryset(), pk=self.kwargs["scan_pk"]) + return {make_channel_name(CHANNEL_PREFIX, scan.tenant_id, scan.id)} +``` + + +`get_channels` **must raise** the relevant DRF exception (`NotFound`, `PermissionDenied`, `NotAuthenticated`) when authorization fails — `get_object_or_404` does this for you. Returning an empty set surfaces as django-eventstream's confusing "No channels specified" error instead of the real cause. + + + + + + +Mount the endpoint as an `event-stream` sub-resource. Keep it **outside the DRF router**, which would force the URL into a list/detail convention. Route the `get` method to the viewset's `list` action. + +```python +# scans/urls.py +path( + "scans//event-stream", + ScanEventStreamViewSet.as_view({"get": "list"}), + name="scan-event-stream", +), +``` + + + + + +A feature owns its event types in `//events.py`: one `publish_` function per event type, each body a **single** `send_event` call so the wire-level string lives in exactly one place. + +```python +# scans/events.py +from django_eventstream import send_event + + +def publish_progress(channel: str, checked: int, total: int) -> None: + send_event(channel, "scan.progress", {"checked": checked, "total": total}) + + +def publish_end(channel: str, scan_id: str) -> None: + # Terminal event carries the canonical id so reconnecting clients + # can refetch the persisted resource over REST. + send_event(channel, "scan.end", {"scan_id": scan_id}) + + +def publish_error(channel: str, code: str, detail: str) -> None: + send_event(channel, "scan.error", {"code": code, "detail": detail}) +``` + +There is no platform-side enum, registry, or dispatch table — **the naming convention is the contract** (see below). + + + + + +Wherever the work happens — usually a Celery task — build the channel the same way and publish: + +```python +from api.sse import make_channel_name +from scans.events import publish_progress, publish_end + +channel = make_channel_name("scan-progress", scan.tenant_id, scan.id) +publish_progress(channel, checked=42, total=100) +... +publish_end(channel, scan_id=str(scan.id)) +``` + + + + + +## 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. + +| Verb | When to use | +|------|-------------| +| `delta` | An incremental piece of a stream the client concatenates (LLM text tokens, audio chunks). Standard term across OpenAI / Anthropic / LiteLLM / Vercel AI SDK. | +| `start` | Begin marker for a compound operation (e.g. a tool call whose execution will be reported by a matching `end`). | +| `end` | Terminal marker. Carries the canonical resource id so reconnecting clients can refetch persisted state via REST. | +| `progress` | Periodic checkpoint with quantifiable completion, e.g. `{"checked": 42, "total": 100}`. | +| `created` / `updated` / `deleted` | Resource-lifecycle events for cross-client sync streams. | +| `error` | Terminal failure. Carries a stable `code` for client switching and a human-readable `detail`. | + + +Payloads are **flat JSON**. The wire-level `event:` field already names the event type, so do **not** wrap the payload in `{"type": ..., "data": ...}`. Include the canonical resource UUID on terminal events so reconnecting clients can reconcile via REST. + + +## Authentication + +SSE endpoints use the same authentication stack as the rest of the API. Non-browser clients (CLI, programmatic) send the standard `Authorization` header — JWT or API key. + +Browser `EventSource` is the only widely available SSE client API and it **cannot set custom headers**. For that case only, the endpoint accepts a JWT via the `?access_token=` query parameter. The header always wins when present — a header is intentional, while a query parameter can leak into referers and logs, so it is consulted only as a fallback. + +```javascript +// Browser +const es = new EventSource( + `/api/v1/scans/${scanId}/event-stream?access_token=${jwt}` +); +``` + +```bash +# CLI / programmatic — header, exactly like every other endpoint +curl -N -H "Authorization: Bearer $JWT" \ + https:///api/v1/scans/$SCAN_ID/event-stream +``` + +## Tenant isolation & security model + +Authorization is enforced at two layers: + +1. **At connect**, `get_channels` runs under the regular DRF stack inside the tenant transaction (`rls_transaction`). Resource lookups are RLS-scoped, so a user cannot even resolve a channel for a resource they cannot see. Narrow the queryset further (e.g. `created_by=request.user`) when a resource is per-user within a tenant. +2. **After connect**, `SSEChannelManager.can_read_channel` re-verifies tenant membership by parsing the tenant id embedded in the channel name. Cross-tenant subscription is rejected even if a URL-level check ever has a bug. A malformed channel name is treated as "not authorized". + +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 + +The platform deliberately ships **without server-side replay** (`is_channel_reliable` returns `False`). When a client reconnects, it does **not** receive missed events. Instead: + +- Terminal events (`*.end`) carry the canonical resource **UUID**. +- On reconnect, the client refetches the authoritative state from the normal REST endpoint using that id. + +Design your event payloads accordingly: deltas are ephemeral and concatenated in-flight; the durable truth always lives behind a REST resource. + +## 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. + +| Env var | Default | Purpose | +|---------|---------|---------| +| `EVENTSTREAM_VALKEY_DB` | `2` | Valkey DB index for the SSE Pub/Sub bus | +| `DJANGO_WORKER_CLASS` | `asgi` | Gunicorn worker class | + +Test the stream end to end with `curl -N` (disable buffering) and an auth header: + +```bash +curl -N -H "Authorization: Bearer $JWT" \ + http://localhost:8080/api/v1/scans/$SCAN_ID/event-stream +``` + +## Testing + +The platform basis is covered by `api/tests/test_sse.py` (channel parsing, the tenant gate, and auth precedence). For a feature endpoint, test: + +- `get_channels` returns the expected channel for an authorized resource and raises `NotFound`/`PermissionDenied` otherwise. +- Each `publish_` helper emits the correct event type and flat payload (mock `send_event`). +- The producer builds the channel with `make_channel_name` using the resource's own `tenant_id`. diff --git a/docs/docs.json b/docs/docs.json index e31575878e..a74aa7e91d 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -395,7 +395,8 @@ "developer-guide/lighthouse-architecture", "developer-guide/mcp-server", "developer-guide/ai-skills", - "developer-guide/prowler-studio" + "developer-guide/prowler-studio", + "developer-guide/server-sent-events" ] }, { @@ -416,6 +417,7 @@ "group": "Miscellaneous", "pages": [ "developer-guide/documentation", + "developer-guide/environment-variables", { "group": "Testing", "pages": [ diff --git a/docs/getting-started/installation/prowler-app.mdx b/docs/getting-started/installation/prowler-app.mdx index 3f19329914..eea9075cda 100644 --- a/docs/getting-started/installation/prowler-app.mdx +++ b/docs/getting-started/installation/prowler-app.mdx @@ -20,7 +20,8 @@ Refer to the [Prowler App Tutorial](/user-guide/tutorials/prowler-app) for detai _Commands_: - ```bash + + ```bash macOS/Linux VERSION=$(curl -s https://api.github.com/repos/prowler-cloud/prowler/releases/latest | jq -r .tag_name) curl -sLO "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/${VERSION}/docker-compose.yml" # Environment variables can be customized in the .env file. Using default values in production environments is not recommended. @@ -28,6 +29,15 @@ Refer to the [Prowler App Tutorial](/user-guide/tutorials/prowler-app) for detai docker compose up -d ``` + ```powershell Windows PowerShell + $VERSION = (Invoke-RestMethod -Uri "https://api.github.com/repos/prowler-cloud/prowler/releases/latest").tag_name + Invoke-WebRequest -Uri "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/$VERSION/docker-compose.yml" -OutFile "docker-compose.yml" + # Environment variables can be customized in the .env file. Using default values in production environments is not recommended. + Invoke-WebRequest -Uri "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/$VERSION/.env" -OutFile ".env" + docker compose up -d + ``` + + For a secure setup, the API auto-generates a unique key pair, `DJANGO_TOKEN_SIGNING_KEY` and `DJANGO_TOKEN_VERIFYING_KEY`, and stores it in `~/.config/prowler-api` (non-container) or the bound Docker volume in `_data/api` (container). Never commit or reuse static/default keys. To rotate keys, delete the stored key files and restart the API. @@ -118,8 +128,8 @@ To update the environment file: Edit the `.env` file and change version values: ```env -PROWLER_UI_VERSION="5.29.0" -PROWLER_API_VERSION="5.29.0" +PROWLER_UI_VERSION="5.30.0" +PROWLER_API_VERSION="5.30.0" ``` diff --git a/docs/getting-started/installation/prowler-cli.mdx b/docs/getting-started/installation/prowler-cli.mdx index f87653dcfe..eae3d54ab1 100644 --- a/docs/getting-started/installation/prowler-cli.mdx +++ b/docs/getting-started/installation/prowler-cli.mdx @@ -4,7 +4,7 @@ title: 'Installation' ## Installation -To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler is available as a project in [PyPI](https://pypi.org/project/prowler/): +To install Prowler as a Python package, use `Python >= 3.10, <= 3.13`. Prowler is available as a project in [PyPI](https://pypi.org/project/prowler/): @@ -12,7 +12,7 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler i _Requirements_: - * `Python >= 3.10, <= 3.12` + * `Python >= 3.10, <= 3.13` * `pipx` installed: [pipx installation](https://pipx.pypa.io/stable/installation/). * AWS, GCP, Azure and/or Kubernetes credentials @@ -30,7 +30,7 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler i _Requirements_: - * `Python >= 3.10, <= 3.12` + * `Python >= 3.10, <= 3.13` * `Python pip >= 21.0.0` * AWS, GCP, Azure, M365 and/or Kubernetes credentials @@ -81,7 +81,7 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler i _Requirements_: - * `Python >= 3.10, <= 3.12` + * `Python >= 3.10, <= 3.13` * AWS, GCP, Azure and/or Kubernetes credentials _Commands_: @@ -96,8 +96,8 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler i _Requirements_: - * `Ubuntu 23.04` or above. For older Ubuntu versions, check [pipx installation](https://docs.prowler.com/projects/prowler-open-source/en/latest/#__tabbed_1_1) and ensure `Python >= 3.10, <= 3.12` is installed. - * `Python >= 3.10, <= 3.12` + * `Ubuntu 23.04` or above. For older Ubuntu versions, check [pipx installation](https://docs.prowler.com/projects/prowler-open-source/en/latest/#__tabbed_1_1) and ensure `Python >= 3.10, <= 3.13` is installed. + * `Python >= 3.10, <= 3.13` * AWS, GCP, Azure and/or Kubernetes credentials _Commands_: diff --git a/docs/troubleshooting.mdx b/docs/troubleshooting.mdx index 9d60c57321..50cc43a3c0 100644 --- a/docs/troubleshooting.mdx +++ b/docs/troubleshooting.mdx @@ -201,35 +201,29 @@ When running Prowler behind a reverse proxy (nginx, Traefik, etc.) or load balan **Root Cause:** -Next.js environment variables prefixed with `NEXT_PUBLIC_` are **bundled at build time**, not runtime. The pre-built Docker images from Docker Hub (`prowlercloud/prowler-ui:stable`) are built with default internal URLs. Simply setting `NEXT_PUBLIC_API_BASE_URL` in your `.env` file or environment variables and restarting the container will **NOT** work because these values are already compiled into the JavaScript bundle. +The API base and docs URLs are resolved from the container environment **at runtime**. A single pre-built Docker image (`prowlercloud/prowler-ui:stable`) therefore serves any environment: point the URLs at your external domain and restart the container — no rebuild is required. **Solution:** -You must **rebuild** the UI Docker image with your external URL: - -```bash -# Clone the repository (if you haven't already) -git clone https://github.com/prowler-cloud/prowler.git -cd prowler/ui - -# Build with your external URL as a build argument -docker build \ - --build-arg NEXT_PUBLIC_API_BASE_URL=https://prowler.example.com/api/v1 \ - --build-arg NEXT_PUBLIC_API_DOCS_URL=https://prowler.example.com/api/v1/docs \ - -t prowler-ui-custom:latest \ - --target prod \ - . -``` - -Then update your `docker-compose.yml` to use your custom image instead of the pre-built one: +Set the runtime environment variables to your external URL and restart the UI container: ```yaml services: ui: - image: prowler-ui-custom:latest # Use your custom-built image + image: prowlercloud/prowler-ui:stable + environment: + UI_API_BASE_URL: https://prowler.example.com/api/v1 + UI_API_DOCS_URL: https://prowler.example.com/api/v1/docs # ... rest of configuration ``` +The same values can be supplied through your `.env` file: + +```bash +UI_API_BASE_URL=https://prowler.example.com/api/v1 +UI_API_DOCS_URL=https://prowler.example.com/api/v1/docs +``` + -The `NEXT_PUBLIC_` prefix is a Next.js convention that exposes environment variables to the browser. Since the browser bundle is compiled during `docker build`, these variables must be provided as build arguments, not runtime environment variables. +Earlier releases inlined these values into the JavaScript bundle at build time (via the `NEXT_PUBLIC_` prefix) and required a rebuild with `--build-arg`. That is no longer necessary: `UI_API_BASE_URL` and `UI_API_DOCS_URL` are read at container start, so updating them and restarting is sufficient. diff --git a/docs/user-guide/cookbooks/cicd-pipeline.mdx b/docs/user-guide/cookbooks/cicd-pipeline.mdx index a88513d901..11295b1f29 100644 --- a/docs/user-guide/cookbooks/cicd-pipeline.mdx +++ b/docs/user-guide/cookbooks/cicd-pipeline.mdx @@ -127,7 +127,7 @@ Add the following to `.gitlab-ci.yml`: ```yaml prowler-scan: - image: python:3.12-slim + image: python:3.13-slim stage: test script: - pip install prowler @@ -154,7 +154,7 @@ stages: - security .prowler-base: - image: python:3.12-slim + image: python:3.13-slim stage: security before_script: - pip install prowler diff --git a/docs/user-guide/providers/gcp/authentication.mdx b/docs/user-guide/providers/gcp/authentication.mdx index 9447d73a20..ec53445c84 100644 --- a/docs/user-guide/providers/gcp/authentication.mdx +++ b/docs/user-guide/providers/gcp/authentication.mdx @@ -138,6 +138,10 @@ To keep permissions focused: 4. Continue through the wizard and finish. No principals need to be granted access in step 3 unless you want other identities to impersonate this account. + +To use this service account with `--organization-id`, additionally grant `roles/cloudasset.viewer` at the organization node and enable the Cloud Asset API in the service account's host project. See [Scanning a Specific GCP Organization](./organization). Without these, organization-wide scans silently fall back to listing only the projects accessible to the service account. + + ### Step 3: Generate a JSON Key 1. Open the newly created service account, move to the **Keys** tab, and choose **Add key > Create new key**. diff --git a/docs/user-guide/providers/gcp/organization.mdx b/docs/user-guide/providers/gcp/organization.mdx index 6f4f7658e5..6790be5827 100644 --- a/docs/user-guide/providers/gcp/organization.mdx +++ b/docs/user-guide/providers/gcp/organization.mdx @@ -11,8 +11,19 @@ prowler gcp --organization-id organization-id ``` -Ensure the credentials used have one of the following roles at the organization level: -Cloud Asset Viewer (`roles/cloudasset.viewer`), or Cloud Asset Owner (`roles/cloudasset.owner`). +Ensure the credentials used have one of the following roles bound **at the organization node** (not at a project): Cloud Asset Viewer (`roles/cloudasset.viewer`) or Cloud Asset Owner (`roles/cloudasset.owner`). The role must be bound directly on the organization so the Cloud Asset API can enumerate projects across the whole hierarchy. + +```bash +gcloud organizations add-iam-policy-binding \ + --member="serviceAccount:" \ + --role="roles/cloudasset.viewer" +``` + +The Cloud Asset API (`cloudasset.googleapis.com`) must also be enabled in the project that owns the credentials (the service account's host project, or the quota project for user credentials): + +```bash +gcloud services enable cloudasset.googleapis.com --project +``` diff --git a/docs/user-guide/providers/okta/authentication.mdx b/docs/user-guide/providers/okta/authentication.mdx index c9154ee936..2e08cac8af 100644 --- a/docs/user-guide/providers/okta/authentication.mdx +++ b/docs/user-guide/providers/okta/authentication.mdx @@ -35,14 +35,28 @@ The bundled checks require the following read-only 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` +- `okta.idps.read` Additional scopes will be needed as more services and checks are added. These are the current ones needed: | Scope | Used by | |---|---| -| `okta.policies.read` | Sign-on, password, and authentication policies | +| `okta.policies.read` | Sign-on, password, authentication, and `USER_LIFECYCLE` (Workflow > Automations) policies | | `okta.brands.read` | Sign-in page customizations (DOD Notice and Consent Banner check) | | `okta.apps.read` | First-party app settings (Okta Admin Console session), integrated app inventory, and the Authentication Policies bound to Okta applications | +| `okta.authenticators.read` | Okta authenticator configuration, including Okta Verify and Smart Card IdP | +| `okta.networkZones.read` | Network Zone inventory, anonymized-proxy blocklist checks, and API token Network Zone validation | +| `okta.apiTokens.read` | API token metadata and token network conditions | +| `okta.roles.read` | Admin role assignments for API token owners (both direct and group-inherited) | +| `okta.groups.read` | Group memberships of API token owners, used to resolve admin roles inherited via group assignment (e.g. Super Admin granted through the default admin group) | +| `okta.logStreams.read` | Log Stream configuration (`/api/v1/logStreams`) | +| `okta.idps.read` | Identity Providers, including Smart Card (X509) IdPs (`/api/v1/idps`) | ### Required Admin Role @@ -68,7 +82,9 @@ Okta filters the first-party apps (`saasure`, `okta_enduser`) out of `/api/v1/ap A fifth check — `application_admin_console_session_idle_timeout_15min` (STIG V-273187) — also requires Super Administrator: it calls `GET /api/v1/first-party-app-settings/admin-console`, which returns `403 E0000006` for every role below Super Administrator. -When the service app runs with Read-Only Administrator, the five checks listed in this section return **MANUAL** instead of PASS/FAIL — the rest of the scan keeps running. +`user_inactivity_automation_35d_enabled` (STIG V-273188) reads `USER_LIFECYCLE` policies (`list_policies(type='USER_LIFECYCLE')`) using the `okta.policies.read` scope. The Read-Only Administrator role is enough to list them; no Super Administrator requirement. + +When the service app runs with Read-Only Administrator, the checks listed in this section return **MANUAL** instead of PASS/FAIL — the rest of the scan keeps running. Read-Only Administrator stays the recommended default for the least-privilege framing that aligns with DISA STIG. Assign Super Administrator on a separate run when full coverage of the first-party app checks is needed. @@ -122,7 +138,7 @@ Okta displays the private key **only once**. If you close the modal without copy ### 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`, and `okta.apps.read`. +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) @@ -158,8 +174,8 @@ export OKTA_PRIVATE_KEY_FILE="/secure/path/to/prowler-okta.pem" # or export OKTA_PRIVATE_KEY="$(cat /secure/path/to/prowler-okta.pem)" -# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read" -export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read" +# Optional — defaults to "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,okta.idps.read" +export OKTA_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,okta.idps.read" uv run python prowler-cli.py okta ``` @@ -200,7 +216,7 @@ Prowler validates credentials at startup by listing one sign-on policy. This err 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`, or `okta.apps.read`) is not granted on the service app. Grant the missing scope from **Okta API Scopes**. +- **`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 diff --git a/docs/user-guide/providers/okta/getting-started-okta.mdx b/docs/user-guide/providers/okta/getting-started-okta.mdx index 66d3dd1808..e04e0d4a13 100644 --- a/docs/user-guide/providers/okta/getting-started-okta.mdx +++ b/docs/user-guide/providers/okta/getting-started-okta.mdx @@ -12,7 +12,7 @@ Set up authentication for Okta with the [Okta Authentication](/user-guide/provid - An Okta organization. The UI examples below use **Identity Engine** terminology such as **Global Session Policy**; Classic Engine exposes the equivalent sign-on policy concepts under older names. - A **Super Administrator** account on that organization for the one-time service-app setup. -- An **API Services** app integration in the Okta Admin Console with the `okta.policies.read`, `okta.brands.read`, and `okta.apps.read` scopes granted and an admin role assigned. **Read-Only Administrator** covers every `signon` check and runs the per-app network-zone check against the apps the service app can see (under Read-Only Administrator that is typically only the service app's own row — the rest of the org's app inventory stays invisible). **Super Administrator** is required additionally to evaluate the five first-party application checks (Okta Admin Console / Okta Dashboard idle timeout, MFA, phishing-resistant authentication) and to widen the network-zone check to the full app inventory — see [Okta Authentication](/user-guide/providers/okta/authentication#required-admin-role) for the full breakdown. +- An **API Services** app integration in the Okta Admin Console with the `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` scopes granted and an admin role assigned. **Read-Only Administrator** covers the Sign-On, Network, API Token, User, System Log, and Identity Provider checks, and runs the per-app application network-zone check against the apps the service app can see (under Read-Only Administrator that is typically only the service app's own row — the rest of the org's app inventory stays invisible). **Super Administrator** is required additionally to evaluate the five first-party application checks (Okta Admin Console / Okta Dashboard idle timeout, MFA, phishing-resistant authentication) and to widen the application network-zone check to the full app inventory — see [Okta Authentication](/user-guide/providers/okta/authentication#required-admin-role) for the full breakdown. - Python 3.10+ and Prowler 5.27.0 or later installed locally. @@ -85,8 +85,8 @@ Follow the [Okta Authentication](/user-guide/providers/okta/authentication) guid export OKTA_ORG_DOMAIN="acme.okta.com" export OKTA_CLIENT_ID="0oa1234567890abcdef" export OKTA_PRIVATE_KEY_FILE="/secure/path/to/prowler-okta.pem" -# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read" -export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read" +# Optional — defaults to "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,okta.idps.read" +export OKTA_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,okta.idps.read" ``` The private key file may contain either a PEM-encoded RSA key or a JWK JSON document. @@ -143,10 +143,16 @@ prowler okta --config-file /path/to/config.yaml Prowler for Okta includes security checks across the following services: -| Service | Description | -| --------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| **Sign-On** | Global session policy controls (idle timeout, lifetime, rule priority and ordering) | -| **Application** | Okta Admin Console sign-on settings plus Authentication Policy controls for Okta applications (session idle, MFA, phishing resistance, network zones) | +| Service | Description | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Sign-On** | Global session policy controls (idle timeout, lifetime, rule priority and ordering) | +| **Application** | Okta Admin Console sign-on settings plus Authentication Policy controls for Okta applications (session idle, MFA, phishing resistance, network zones) | +| **Authenticator** | Password Policy controls plus Okta Verify FIPS and Smart Card IdP authenticator status | +| **Network** | Network Zone blocklists for anonymized proxy sources | +| **API Token** | API token owner-role validation and Network Zone restrictions | +| **User** | User lifecycle automations (inactivity-based deprovisioning) | +| **System Log** | Log Stream configuration that off-loads audit records to a central SIEM | +| **Identity Provider** | Identity Providers, including Smart Card (X509) IdP status and certificate-chain visibility | ## Troubleshooting @@ -158,22 +164,29 @@ This is stricter than simply finding the same timeout value somewhere else in th ### Default Scopes -Prowler requests a fixed set of OAuth scopes on every token exchange. The defaults cover every bundled check across the Sign-On and Application services: +Prowler requests a fixed set of OAuth scopes on every token exchange. The defaults cover every bundled check across the Sign-On, Application, Authenticator, Network, API Token, User, System Log, and Identity Provider services: - `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` +- `okta.idps.read` -The service app must have these scopes granted in the **Okta API Scopes** tab. When the granted set is narrower than the requested set, the token request fails with an `invalid_scope` error and the scan stops at provider initialization. +The service app must have these scopes granted in the **Okta API Scopes** tab. `okta.groups.read` is required so the API token Super Admin check can resolve admin roles inherited via group membership; without it the check falls back to direct-only role assignments and emits a best-effort caveat. When the granted set is narrower than the requested set, the token request fails with an `invalid_scope` error and the scan stops at provider initialization. When additional checks are enabled — or when running against a service app that exposes a different scope set — override the default with `OKTA_SCOPES` (comma-separated string for the env var) or `--okta-scopes` (space-separated list for the CLI): ```bash # Environment variable — comma-separated -export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.users.read" +export OKTA_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,okta.idps.read,okta.users.read" # CLI flag — space-separated -prowler okta --okta-scopes okta.policies.read okta.brands.read okta.apps.read okta.users.read +prowler okta --okta-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 okta.idps.read okta.users.read ``` For the full catalog of OAuth scopes exposed by the Okta Management API, refer to the [Okta OAuth 2.0 scopes documentation](https://developer.okta.com/docs/api/oauth2/). diff --git a/docs/user-guide/tutorials/prowler-app-rbac.mdx b/docs/user-guide/tutorials/prowler-app-rbac.mdx index c6319591cf..cabea5bd35 100644 --- a/docs/user-guide/tutorials/prowler-app-rbac.mdx +++ b/docs/user-guide/tutorials/prowler-app-rbac.mdx @@ -47,7 +47,11 @@ Follow these steps to remove a user of your account: 1. Navigate to **Users** from the side menu. 2. Click the delete button of your current user. -> **Note: Each user will be able to delete himself and not others, regardless of his permissions.** +> **Note: Each user can only delete their own account, regardless of their permissions. For this reason, the delete button is only shown on your own row and not on other users' rows.** + +Deleting a user removes the **entire user account** from Prowler, not just its membership in your organization. Because a single account can belong to more than one tenant, allowing one administrator to delete it outright could affect organizations they don't manage and irreversibly remove another person's identity. To keep this destructive action under the control of the account owner, the API only permits a user to delete themselves (it rejects any other target with a `400` response), and the UI mirrors this by showing the delete button exclusively on your own row. + +To remove **another** user from your organization, use the [_Expel from organization_](/user-guide/tutorials/prowler-app-multi-tenant#expelling-a-user-from-an-organization) action instead. Expelling removes the user's membership, role grants, and active sessions for your tenant only, and deletes the underlying account just for that user if your organization was their last remaining membership. This action is reserved for tenant **owners**. Remove User diff --git a/docs/user-guide/tutorials/prowler-app-sso-google-workspace.mdx b/docs/user-guide/tutorials/prowler-app-sso-google-workspace.mdx index 2a349c0204..2f10d443ae 100644 --- a/docs/user-guide/tutorials/prowler-app-sso-google-workspace.mdx +++ b/docs/user-guide/tutorials/prowler-app-sso-google-workspace.mdx @@ -108,10 +108,10 @@ Prowler App updates user attributes each time a user logs in. Any changes made i The `userType` attribute controls which Prowler role is assigned to the user: - If `userType` matches an existing Prowler role name, the user receives that role automatically. -- If `userType` does not match any existing role, Prowler App creates a new role with that name **without permissions**. -- If `userType` is not set, the user receives the `no_permissions` role. +- If `userType` does not match any existing role, Prowler App creates a new role with that name **with read-only access** (visibility over all providers, no management permissions). A Prowler administrator can adjust its permissions afterward through the [RBAC Management](/user-guide/tutorials/prowler-app-rbac) tab. +- If `userType` is not set, the user's existing roles are left unchanged. -In all cases where the resulting role has no permissions, a Prowler administrator must configure the appropriate permissions through the [RBAC Management](/user-guide/tutorials/prowler-app-rbac) tab. The `userType` value is **case-sensitive** - for example, `Backend` and `backend` are treated as different roles. +The `userType` value is **case-sensitive** - for example, `Backend` and `backend` are treated as different roles. @@ -223,9 +223,9 @@ To test the `userType` → role mapping, set the **Department** attribute in the After a successful SSO login, the user profile in Prowler App reflects the attributes sent by Google Workspace: - **Name**: Populated from the `firstName` and `lastName` attributes. -- **Role**: Created automatically from the `userType` attribute (e.g., `Backend`). If the role did not exist previously, it is created with no permissions by default. -- **Permissions**: In the screenshot below, the user has no permissions because the `Backend` role did not exist prior to login and was created automatically without any permissions. To resolve this, a Prowler administrator can either: - - Assign the appropriate permissions to the new role via the [RBAC Management](/user-guide/tutorials/prowler-app-rbac) tab. +- **Role**: Created automatically from the `userType` attribute (e.g., `Backend`). If the role did not exist previously, it is created with read-only access by default. +- **Permissions**: If the assigned permissions need to be adjusted, a Prowler administrator can either: + - Edit the permissions of the new role via the [RBAC Management](/user-guide/tutorials/prowler-app-rbac) tab. - Set the `userType` attribute in the IdP to match an existing Prowler role that already has the desired permissions. The updated role is applied on the next SAML login. For more details on role assignment behavior and attribute mapping, refer to the [SAML SSO Configuration](/user-guide/tutorials/prowler-app-sso#configure-attribute-mapping-in-the-idp) page. diff --git a/docs/user-guide/tutorials/prowler-app-sso.mdx b/docs/user-guide/tutorials/prowler-app-sso.mdx index d4d2c18d11..1e61ec1060 100644 --- a/docs/user-guide/tutorials/prowler-app-sso.mdx +++ b/docs/user-guide/tutorials/prowler-app-sso.mdx @@ -87,7 +87,7 @@ Choose a Method: |----------------|---------------------------------------------------------------------------------------------------------|----------| | `firstName` | The user's first name. | Yes | | `lastName` | The user's last name. | Yes | - | `userType` | Determines which Prowler role the user receives (e.g., `admin`, `auditor`). If a role with that name already exists, the user receives it automatically; if it does not exist, Prowler App creates a new role with that name without permissions. If `userType` is not defined, the user is assigned the `no_permissions` role. Role permissions can be edited in the [RBAC Management tab](/user-guide/tutorials/prowler-app-rbac). | No | + | `userType` | Determines which Prowler role the user receives (e.g., `admin`, `auditor`). If a role with that name already exists, the user receives it automatically; if it does not exist, Prowler App creates a new role with that name with read-only access (visibility over all providers, no management permissions). If `userType` is not defined, the user's existing roles are left unchanged. Role permissions can be edited in the [RBAC Management tab](/user-guide/tutorials/prowler-app-rbac). | No | | `organization` | The user's company name. | No | @@ -140,7 +140,7 @@ Choose a Method: ![Okta User Profile — First Name and Last Name](/images/prowler-app/saml/okta-user-profile-name.png) * **Organization** (`organization`): Maps to the company name displayed in Prowler App. This attribute is optional. - * **User type** (`userType`): Determines the Prowler role assigned to the user. This attribute is **case-sensitive** and must match the exact name of an existing role in Prowler App. + * **User type** (`userType`): Determines the Prowler role assigned to the user. This attribute is **case-sensitive**: if it matches the exact name of an existing role in Prowler App the user receives that role; if no role with that name exists, a new one is created with read-only access. ![Okta User Profile — User Type and Organization](/images/prowler-app/saml/okta-user-profile-attributes.png) @@ -152,14 +152,10 @@ Choose a Method: The `userType` attribute controls which Prowler role is assigned to the user: * If a role with the specified name already exists in Prowler App, the user automatically receives that role. - * If the role does not exist, Prowler App creates a new role with that exact name but without any permissions, preventing the user from performing any actions. - * If `userType` is not defined in the user's Okta profile, the user is assigned the `no_permissions` role. + * If the role does not exist, Prowler App creates a new role with that exact name with read-only access: the user can see all providers and their findings but cannot manage anything. A Prowler administrator (a user whose role includes the "Manage Account" permission) can adjust its permissions afterward through the [RBAC Management tab](/user-guide/tutorials/prowler-app-rbac). + * If `userType` is not defined in the user's Okta profile, the user's existing roles in Prowler App are left unchanged. - In all cases where the resulting role has no permissions, a Prowler administrator (a user whose role includes the "Manage Account" permission) must configure the appropriate permissions through the [RBAC Management tab](/user-guide/tutorials/prowler-app-rbac). - - This behavior is intentional: by defaulting to no permissions, Prowler App ensures that a misconfiguration in Okta cannot inadvertently grant elevated access. - - **Example:** To assign the `IT` role to a user, set the `userType` value to `IT` in Okta. If a role named `IT` already exists in Prowler App, the user receives it automatically upon login. If it does not exist, Prowler App creates a new role called `IT` without permissions, and a Prowler administrator must configure the desired permissions for it. + **Example:** To assign the `IT` role to a user, set the `userType` value to `IT` in Okta. If a role named `IT` already exists in Prowler App, the user receives it automatically upon login. If it does not exist, Prowler App creates a new role called `IT` with read-only access, and a Prowler administrator can adjust its permissions as needed. diff --git a/mcp_server/Dockerfile b/mcp_server/Dockerfile index d8377a1762..c2759b20de 100644 --- a/mcp_server/Dockerfile +++ b/mcp_server/Dockerfile @@ -1,7 +1,7 @@ # ============================================================================= # Build stage - Install dependencies and build the application # ============================================================================= -FROM ghcr.io/astral-sh/uv:python3.13-alpine@sha256:8f53782bb232ab0b5558f3071e86e2bbfde884e18815f2b19cc57f2d336e9ee2 AS builder +FROM ghcr.io/astral-sh/uv:0.11.21-python3.13-alpine3.23@sha256:f09cc61ffc001f202701fdeae14dbdd50f6ca4cfcf248f41fd3234a302c8534f AS builder WORKDIR /app @@ -25,7 +25,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \ # ============================================================================= # Final stage - Minimal runtime environment # ============================================================================= -FROM python:3.13-alpine@sha256:bb1f2fdb1065c85468775c9d680dcd344f6442a2d1181ef7916b60a623f11d40 +FROM python:3.13.14-alpine3.23@sha256:b0513989fa9be54569cac73f48a60320b74bb0f9ffa886568eea7e48a2432c04 LABEL maintainer="https://github.com/prowler-cloud" diff --git a/mcp_server/uv.lock b/mcp_server/uv.lock index fd0a9349f9..14b8dcf4f3 100644 --- a/mcp_server/uv.lock +++ b/mcp_server/uv.lock @@ -857,11 +857,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.12.1" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, + { 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" }, ] [package.optional-dependencies] @@ -1132,15 +1132,15 @@ wheels = [ [[package]] name = "starlette" -version = "1.0.0" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] [[package]] diff --git a/osv-scanner.toml b/osv-scanner.toml index 5d029efdf6..59408b8709 100644 --- a/osv-scanner.toml +++ b/osv-scanner.toml @@ -12,8 +12,9 @@ reason = """ CVE-2025-45768 is disputed by the pyjwt maintainers. The advisory describes weak encryption, but the underlying issue is that callers may pick a short HMAC secret — key-length enforcement is the application's responsibility, not -a defect in the library. We are on pyjwt 2.12.1 (latest at pin time) and -enforce key strength in our own auth code, so this advisory does not apply. +a defect in the library. We are on pyjwt 2.13.0 (which now also emits an +InsecureKeyLengthWarning for short HMAC secrets) and enforce key strength in +our own auth code, so this advisory does not apply. Re-evaluate when a non-disputed advisory or upstream fix lands. """ diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index ea1707c71b..c9bfa7a1c7 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -2,6 +2,122 @@ All notable changes to the **Prowler SDK** are documented in this file. +## [5.31.0] (Prowler UNRELEASED) + +### 🚀 Added + +- Support for Python 3.13 [(#9293)](https://github.com/prowler-cloud/prowler/pull/9293) +- `securityhub_delegated_admin_enabled_all_regions` check for AWS provider, verifying that Security Hub has a delegated administrator, is active in all opted-in regions, and has organization auto-enable on [(#11259)](https://github.com/prowler-cloud/prowler/pull/11259) +- `config_delegated_admin_and_org_aggregator_all_regions` check for AWS provider, verifying that AWS Config has a delegated administrator and an organization aggregator covering all AWS regions [(#11259)](https://github.com/prowler-cloud/prowler/pull/11259) +- `sagemaker_clarify_exists` check for AWS provider [(#11211)](https://github.com/prowler-cloud/prowler/pull/11211) +- `cloudsql_instance_high_availability_enabled` check for GCP provider, verifying Cloud SQL primary instances use `REGIONAL` availability for automatic zone failover [(#11024)](https://github.com/prowler-cloud/prowler/pull/11024) +- `cloudfunction_function_inside_vpc` check for GCP provider, verifying Cloud Functions have a Serverless VPC Access connector for private egress [(#11021)](https://github.com/prowler-cloud/prowler/pull/11021) +- `identity_storage_service_level_admins_scoped` check for OCI provider CIS 3.1 control 1.15, ensuring storage service-level administrators exclude delete permissions [(#11523)](https://github.com/prowler-cloud/prowler/pull/11523) +- `cosmosdb_account_automatic_failover_enabled` check for Azure provider [(#11031)](https://github.com/prowler-cloud/prowler/pull/11031) +- `cosmosdb_account_backup_policy_continuous` check for Azure provider [(#11032)](https://github.com/prowler-cloud/prowler/pull/11032) +- `cosmosdb_account_minimum_tls_version` check for Azure provider, verifying Cosmos DB accounts enforce TLS 1.2 or higher for client connections [(#11033)](https://github.com/prowler-cloud/prowler/pull/11033) +- `cosmosdb_account_public_network_access_disabled` check for Azure provider, verifying Cosmos DB accounts have public network access disabled so connectivity is restricted to private endpoints or VNet service endpoints [(#11034)](https://github.com/prowler-cloud/prowler/pull/11034) +- `databricks_workspace_public_network_access_disabled` check for Azure provider, verifying Databricks workspaces have public network access disabled so connectivity is restricted to Azure Private Link private endpoints [(#11035)](https://github.com/prowler-cloud/prowler/pull/11035) +- `databricks_workspace_no_public_ip_enabled` check for Azure provider, verifying Databricks workspaces use secure cluster connectivity (no public IP) so compute nodes are not assigned public IP addresses [(#11036)](https://github.com/prowler-cloud/prowler/pull/11036) +- `defender_ensure_defender_cspm_is_on` check for Azure provider, verifying Microsoft Defender Cloud Security Posture Management (CSPM) is enabled on the Standard tier [(#11037)](https://github.com/prowler-cloud/prowler/pull/11037) +- `mysql_flexible_server_geo_redundant_backup_enabled` check for Azure provider, verifying MySQL Flexible Servers have geo-redundant backup enabled so backups are replicated to the paired region [(#11041)](https://github.com/prowler-cloud/prowler/pull/11041) +- `mysql_flexible_server_high_availability_enabled` check for Azure provider, verifying MySQL Flexible Servers have high availability enabled for automatic failover to a standby replica [(#11042)](https://github.com/prowler-cloud/prowler/pull/11042) +- `aks_cluster_auto_upgrade_enabled` check for Azure provider [(#11027)](https://github.com/prowler-cloud/prowler/pull/11027) +- Public `Provider.get_class()` method that resolves a provider class by name for both built-in and external (entry-point) providers [(#11398)](https://github.com/prowler-cloud/prowler/pull/11398) +- Jira timeout preventing the calls from hanging indefinitely when the Jira endpoint is unreachable or slow [(#11602)](https://github.com/prowler-cloud/prowler/pull/11602) +- TLS certificate verification in the `codepipeline_project_repo_private` check, which previously used an unverified SSL context, leaving the repository-visibility probe open to MITM tampering [(#11603)](https://github.com/prowler-cloud/prowler/pull/11603) +- DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) compliance coverage for the Azure provider, mapping existing Azure checks across the five DORA pillars [(#11551)](https://github.com/prowler-cloud/prowler/pull/11551) +- Rename DORA to DORA_2022_2554 to follow the naming _ in compliance frameworks [(#11551)](https://github.com/prowler-cloud/prowler/pull/11551) +- `entra_directory_sync_object_takeover_blocked` check for the M365 provider, verifying that hybrid Entra tenants block cloud object takeover through both soft-match and hard-match directory synchronization [(#11098)](https://github.com/prowler-cloud/prowler/pull/11098) +- `entra_conditional_access_policy_no_deleted_object_references` check for M365 provider [(#11236)](https://github.com/prowler-cloud/prowler/pull/11236) +- `aks_cluster_defender_enabled` check for Azure provider, verifying that AKS clusters have Microsoft Defender security monitoring enabled [(#11028)](https://github.com/prowler-cloud/prowler/pull/11028) + +### 🔄 Changed + +- Replaced the unmaintained `awsipranges` dependency with a small standard-library helper for the `route53_dangling_ip_subdomain_takeover` check [(#9293)](https://github.com/prowler-cloud/prowler/pull/9293) + +### 🔐 Security + +- `pytest` from 8.3.5 to 9.0.3, patching a known vulnerability in the SDK test dependency [(#11291)](https://github.com/prowler-cloud/prowler/pull/11291) +- `black` from 25.1.0 to 26.3.1, patching a known vulnerability in the SDK formatter dependency [(#11290)](https://github.com/prowler-cloud/prowler/pull/11290) +- `microsoft-kiota-*` to 1.9.9 and `aiohttp` to 3.14.0, patching known CVEs [(#11596)](https://github.com/prowler-cloud/prowler/pull/11596) +- Container base image bumped to `python:3.12.13-slim-bookworm` (patches `libgnutls30` CVE-2026-33845 and CVE-2026-42010) and `trivy` bumped to 0.71.0 (patches embedded `golang.org/x/crypto` and Go stdlib CVEs); `.trivyignore` documents remaining bookworm criticals with no-fix or not-affected rationale [(#11592)](https://github.com/prowler-cloud/prowler/pull/11592) + +--- + +## [5.30.3] (Prowler UNRELEASED) + +### 🐞 Fixed + +- CLI compliance summary tables no longer undercount findings mapped to multiple sections nor double-count a single finding mapped to several requirements within the same group/split, and the Provider column no longer leaks a value from another framework [(#11567)](https://github.com/prowler-cloud/prowler/pull/11567) + +--- + +## [5.30.2] (Prowler v5.30.2) + +### 🐞 Fixed + +- GCP `logging_log_metric_filter_and_alert_*` checks now credit org-level aggregated sinks filtered to the Admin Activity audit stream [(#11575)](https://github.com/prowler-cloud/prowler/pull/11575) +- A broken built-in provider no longer aborts the CLI when a different provider was invoked [(#11618)](https://github.com/prowler-cloud/prowler/pull/11618) +- GCP organization scans with `--organization-id` no longer silently fall back to the credentials' host project when the Cloud Asset API call fails [(#11280)](https://github.com/prowler-cloud/prowler/pull/11280) + +--- + +## [5.30.0] (Prowler v5.30.0) + +### 🚀 Added + +- DISA Okta IDaaS STIG V1R2 compliance framework for the Okta provider, with a dedicated CSV output formatter and terminal summary table [(#11428)](https://github.com/prowler-cloud/prowler/pull/11428) +- `sagemaker_models_monitor_enabled` check for AWS provider, verifying that each SageMaker monitoring schedule is in the `Scheduled` state so data and model drift is actively detected [(#11278)](https://github.com/prowler-cloud/prowler/pull/11278) +- DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) universal compliance framework with AWS provider coverage across the five DORA pillars [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131) +- Okta authenticator and password policy checks for STIG-aligned hardening requirements [(#11465)](https://github.com/prowler-cloud/prowler/pull/11465) +- Okta network zone check to detect whether anonymized proxy traffic is blocked [(#11463)](https://github.com/prowler-cloud/prowler/pull/11463) +- Okta API token checks for super admin ownership and network zone restrictions [(#11464)](https://github.com/prowler-cloud/prowler/pull/11464) +- Support for external/custom providers, checks, and compliance frameworks without modifying core code [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700) +- `elbv2_alb_drop_invalid_header_fields_enabled` check for AWS provider, verifying Application Load Balancers have `routing.http.drop_invalid_header_fields.enabled` set to `true` to mitigate HTTP desync attacks (AWS FSBP ELB.4) [(#11471)](https://github.com/prowler-cloud/prowler/pull/11471) +- `user`, `systemlog` and `idp` service for Okta provider with `user_inactivity_automation_35d_enabled`, `systemlog_streaming_enabled` and `idp_smart_card_dod_approved_ca` checks [(#11496)](https://github.com/prowler-cloud/prowler/pull/11496) +- External multi-provider compliance frameworks can be registered via the `prowler.compliance.universal` entry point group [(#11490)](https://github.com/prowler-cloud/prowler/pull/11490) +- AWS AI Security Framework support in the CLI dashboard [(#11475)](https://github.com/prowler-cloud/prowler/pull/11475) +- `entra_service_principal_privileged_role_no_owners` check for M365 provider, failing when a service principal with a permanent Tier 0 directory role has owners on the service principal or its parent app registration [(#11070)](https://github.com/prowler-cloud/prowler/issues/11070) +- `kms_key_rotation_max_90_days` check for GCP provider, verifying KMS customer-managed keys are rotated every 90 days or less in line with the CIS Benchmark [(#11516)](https://github.com/prowler-cloud/prowler/pull/11516) +- `exchange_mailbox_primary_smtp_uses_custom_domain` check for M365 provider [(#11215)](https://github.com/prowler-cloud/prowler/pull/11215) +- `bedrock_agent_role_least_privilege` check for AWS provider, flagging Bedrock Agent execution roles with full-access managed policies, broad `Resource:*` inline statements, or missing permissions boundaries [(#11335)](https://github.com/prowler-cloud/prowler/pull/11335) +- STACKIT ObjectStorage service with Object Lock, default retention policy, and access key expiration checks [(#11397)](https://github.com/prowler-cloud/prowler/pull/11397) + +### 🐞 Fixed + +- `load_and_validate_config_file` now unwraps namespaced config for every built-in and external provider, and no longer leaks the full file as the provider's config when the file is namespaced [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700) +- `entra_users_mfa_capable` no longer flags pre-provisioned users with future `employeeHireDate`; future-hire date comparisons now tolerate naive datetimes [(#11511)](https://github.com/prowler-cloud/prowler/pull/11511) +- M365 Admin Center group enumeration now follows Microsoft Graph pagination so group-scoped checks include groups beyond the first page [(#11510)](https://github.com/prowler-cloud/prowler/pull/11510) +- GCP `kms_key_rotation_enabled` check now only verifies that automatic key rotation is enabled (any interval) instead of enforcing a 90-day period, resolving the mismatch between the check and its documentation; the CIS, Prowler ThreatScore, and CCC requirements that mandate a 90-day maximum were remapped to the new `kms_key_rotation_max_90_days` check [(#11516)](https://github.com/prowler-cloud/prowler/pull/11516) +- AWS CloudWatch log metric filter checks now validate `filterPattern` clauses regardless of order [(#11345)](https://github.com/prowler-cloud/prowler/pull/11345) +- AWS `bedrock_api_key_no_long_term_credentials` now applies severity per finding (never-expires keys correctly flag as critical, no leak across findings) and aligns title and wording with AWS guidance to prefer short-term Bedrock API keys [(#11526)](https://github.com/prowler-cloud/prowler/pull/11526) + +### 🔐 Security + +- `dulwich` from 0.23.0 to 1.2.5 and `pyjwt` from 2.12.1 to 2.13.0, patching `GHSA-897w-fcg9-f6xj` (arbitrary file write) and `PYSEC-2026-179` (HMAC/JWK key confusion) [(#11499)](https://github.com/prowler-cloud/prowler/pull/11499) + +--- + +## [5.29.3] (Prowler v5.29.3) + +### 🐞 Fixed + +- GCP `logging_sink_created` now recognizes organization-level aggregated sinks with `includeChildren=True`, avoiding false failures for covered projects [(#11355)](https://github.com/prowler-cloud/prowler/pull/11355) +- GCP `logging_log_metric_filter_and_alert_*` checks now recognize organization-level aggregated sinks with `includeChildren=True`, no longer false-failing projects covered by a central bucket-scoped metric + alert [(#11488)](https://github.com/prowler-cloud/prowler/pull/11488) +- Jira integration no longer fails with `400 INVALID_INPUT` when a finding has empty fields [(#11474)](https://github.com/prowler-cloud/prowler/pull/11474) +- GCP `iam_service_account_unused` now passes disabled service accounts instead of failing them, since a disabled account cannot authenticate or be used [(#11467)](https://github.com/prowler-cloud/prowler/pull/11467) + +--- + +## [5.29.1] (Prowler v5.29.1) + +### 🐞 Fixed + +- OCSF output writer now re-raises I/O errors (e.g. `ENOSPC`) instead of logging them per finding and leaving a truncated file [(#11421)](https://github.com/prowler-cloud/prowler/pull/11421) + +--- + ## [5.29.0] (Prowler v5.29.0) ### 🚀 Added diff --git a/prowler/__main__.py b/prowler/__main__.py index a9d794c2d6..533a704359 100644 --- a/prowler/__main__.py +++ b/prowler/__main__.py @@ -10,7 +10,6 @@ from colorama import Fore, Style from colorama import init as colorama_init from prowler.config.config import ( - EXTERNAL_TOOL_PROVIDERS, cloud_api_base_url, csv_file_suffix, get_available_compliance_frameworks, @@ -20,7 +19,7 @@ from prowler.config.config import ( orange_color, sarif_file_suffix, ) -from prowler.lib.banner import print_banner +from prowler.lib.banner import print_banner, print_prowler_cloud_banner from prowler.lib.check.check import ( exclude_checks_to_run, exclude_services_to_run, @@ -85,11 +84,6 @@ from prowler.lib.outputs.compliance.compliance import ( display_compliance_table, process_universal_compliance_frameworks, ) -from prowler.lib.outputs.compliance.csa.csa_alibabacloud import AlibabaCloudCSA -from prowler.lib.outputs.compliance.csa.csa_aws import AWSCSA -from prowler.lib.outputs.compliance.csa.csa_azure import AzureCSA -from prowler.lib.outputs.compliance.csa.csa_gcp import GCPCSA -from prowler.lib.outputs.compliance.csa.csa_oraclecloud import OracleCloudCSA from prowler.lib.outputs.compliance.ens.ens_aws import AWSENS from prowler.lib.outputs.compliance.ens.ens_azure import AzureENS from prowler.lib.outputs.compliance.ens.ens_gcp import GCPENS @@ -108,6 +102,9 @@ from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_azure import ( AzureMitreAttack, ) from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_gcp import GCPMitreAttack +from prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig_okta import ( + OktaIDaaSSTIG, +) from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_alibaba import ( ProwlerThreatScoreAlibaba, ) @@ -205,14 +202,15 @@ def prowler(): if not args.no_banner: legend = args.verbose or getattr(args, "fixer", None) - print_banner(legend) + print_banner(legend, provider) # We treat the compliance framework as another output format if compliance_framework: args.output_formats.extend(compliance_framework) - # If no input compliance framework, set all, unless a specific service or check is input - # Skip for IAC and LLM providers that don't use compliance frameworks - elif default_execution and provider not in ["iac", "llm"]: + # If no input compliance framework, set all, unless a specific service or check is input. + # Skip for tool-wrapper providers (iac, llm, image, and any external plug-in + # declaring `is_external_tool_provider = True`) — they don't use compliance frameworks. + elif default_execution and not Provider.is_tool_wrapper_provider(provider): args.output_formats.extend(get_available_compliance_frameworks(provider)) # Set Logger configuration @@ -250,7 +248,7 @@ def prowler(): universal_frameworks = {} # Skip compliance frameworks for external-tool providers - if provider not in EXTERNAL_TOOL_PROVIDERS: + if not Provider.is_tool_wrapper_provider(provider): bulk_compliance_frameworks = Compliance.get_bulk(provider) # Complete checks metadata with the compliance framework specification bulk_checks_metadata = update_checks_metadata_with_compliance( @@ -318,7 +316,7 @@ def prowler(): sys.exit() # Skip service and check loading for external-tool providers - if provider not in EXTERNAL_TOOL_PROVIDERS: + if not Provider.is_tool_wrapper_provider(provider): # Import custom checks from folder if checks_folder: custom_checks = parse_checks_from_folder(global_provider, checks_folder) @@ -441,6 +439,20 @@ def prowler(): output_options = ScalewayOutputOptions( args, bulk_checks_metadata, global_provider.identity ) + else: + # Dynamic fallback: any external/custom provider + try: + output_options = global_provider.get_output_options( + args, bulk_checks_metadata + ) + except NotImplementedError: + # No provider-specific OutputOptions: use the generic default so the + # run still produces output instead of aborting. + from prowler.providers.common.models import default_output_options + + output_options = default_output_options( + global_provider, args, bulk_checks_metadata + ) # Run the quick inventory for the provider if available if hasattr(args, "quick_inventory") and args.quick_inventory: @@ -450,7 +462,7 @@ def prowler(): # Execute checks findings = [] - if provider in EXTERNAL_TOOL_PROVIDERS: + if Provider.is_tool_wrapper_provider(provider): # For external-tool providers, run the scan directly if provider == "llm": @@ -460,12 +472,19 @@ def prowler(): findings = global_provider.run_scan(streaming_callback=streaming_callback) else: - # Original behavior for IAC and Image - try: + if provider == "image": + try: + findings = global_provider.run() + except ImageBaseException 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. findings = global_provider.run() - except ImageBaseException as error: - logger.critical(f"{error}") - sys.exit(1) # 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. # Progress tracking would just be 0% → 100%. @@ -806,18 +825,6 @@ def prowler(): ) generated_outputs["compliance"].append(c5) c5.batch_write_data_to_file() - elif compliance_name == "csa_ccm_4.0_aws": - filename = ( - f"{output_options.output_directory}/compliance/" - f"{output_options.output_filename}_{compliance_name}.csv" - ) - csa_ccm_4_0_aws = AWSCSA( - findings=finding_outputs, - compliance=bulk_compliance_frameworks[compliance_name], - file_path=filename, - ) - generated_outputs["compliance"].append(csa_ccm_4_0_aws) - csa_ccm_4_0_aws.batch_write_data_to_file() else: filename = ( f"{output_options.output_directory}/compliance/" @@ -921,18 +928,6 @@ def prowler(): ) generated_outputs["compliance"].append(c5_azure) c5_azure.batch_write_data_to_file() - elif compliance_name == "csa_ccm_4.0_azure": - filename = ( - f"{output_options.output_directory}/compliance/" - f"{output_options.output_filename}_{compliance_name}.csv" - ) - csa_ccm_4_0_azure = AzureCSA( - findings=finding_outputs, - compliance=bulk_compliance_frameworks[compliance_name], - file_path=filename, - ) - generated_outputs["compliance"].append(csa_ccm_4_0_azure) - csa_ccm_4_0_azure.batch_write_data_to_file() else: filename = ( f"{output_options.output_directory}/compliance/" @@ -1036,18 +1031,6 @@ def prowler(): ) generated_outputs["compliance"].append(c5_gcp) c5_gcp.batch_write_data_to_file() - elif compliance_name == "csa_ccm_4.0_gcp": - filename = ( - f"{output_options.output_directory}/compliance/" - f"{output_options.output_filename}_{compliance_name}.csv" - ) - csa_ccm_4_0_gcp = GCPCSA( - findings=finding_outputs, - compliance=bulk_compliance_frameworks[compliance_name], - file_path=filename, - ) - generated_outputs["compliance"].append(csa_ccm_4_0_gcp) - csa_ccm_4_0_gcp.batch_write_data_to_file() else: filename = ( f"{output_options.output_directory}/compliance/" @@ -1282,18 +1265,6 @@ def prowler(): ) generated_outputs["compliance"].append(cis) cis.batch_write_data_to_file() - elif compliance_name == "csa_ccm_4.0_oraclecloud": - filename = ( - f"{output_options.output_directory}/compliance/" - f"{output_options.output_filename}_{compliance_name}.csv" - ) - csa_ccm_4_0_oraclecloud = OracleCloudCSA( - findings=finding_outputs, - compliance=bulk_compliance_frameworks[compliance_name], - file_path=filename, - ) - generated_outputs["compliance"].append(csa_ccm_4_0_oraclecloud) - csa_ccm_4_0_oraclecloud.batch_write_data_to_file() else: filename = ( f"{output_options.output_directory}/compliance/" @@ -1322,18 +1293,6 @@ def prowler(): ) generated_outputs["compliance"].append(cis) cis.batch_write_data_to_file() - elif compliance_name == "csa_ccm_4.0_alibabacloud": - filename = ( - f"{output_options.output_directory}/compliance/" - f"{output_options.output_filename}_{compliance_name}.csv" - ) - csa_ccm_4_0_alibabacloud = AlibabaCloudCSA( - findings=finding_outputs, - compliance=bulk_compliance_frameworks[compliance_name], - file_path=filename, - ) - generated_outputs["compliance"].append(csa_ccm_4_0_alibabacloud) - csa_ccm_4_0_alibabacloud.batch_write_data_to_file() elif compliance_name == "prowler_threatscore_alibabacloud": filename = ( f"{output_options.output_directory}/compliance/" @@ -1358,6 +1317,57 @@ def prowler(): ) generated_outputs["compliance"].append(generic_compliance) generic_compliance.batch_write_data_to_file() + elif provider == "okta": + for compliance_name in input_compliance_frameworks: + if compliance_name.startswith("okta_idaas_stig"): + # Generate Okta IDaaS STIG Finding Object + filename = ( + f"{output_options.output_directory}/compliance/" + f"{output_options.output_filename}_{compliance_name}.csv" + ) + okta_idaas_stig = OktaIDaaSSTIG( + findings=finding_outputs, + compliance=bulk_compliance_frameworks[compliance_name], + file_path=filename, + ) + generated_outputs["compliance"].append(okta_idaas_stig) + okta_idaas_stig.batch_write_data_to_file() + else: + filename = ( + f"{output_options.output_directory}/compliance/" + f"{output_options.output_filename}_{compliance_name}.csv" + ) + generic_compliance = GenericCompliance( + findings=finding_outputs, + compliance=bulk_compliance_frameworks[compliance_name], + file_path=filename, + ) + generated_outputs["compliance"].append(generic_compliance) + generic_compliance.batch_write_data_to_file() + else: + # Dynamic fallback: any external/custom provider + try: + global_provider.generate_compliance_output( + finding_outputs, + bulk_compliance_frameworks, + input_compliance_frameworks, + output_options, + generated_outputs, + ) + except NotImplementedError: + # Last resort: generic compliance + for compliance_name in input_compliance_frameworks: + filename = ( + f"{output_options.output_directory}/compliance/" + f"{output_options.output_filename}_{compliance_name}.csv" + ) + generic_compliance = GenericCompliance( + findings=finding_outputs, + compliance=bulk_compliance_frameworks[compliance_name], + file_path=filename, + ) + generated_outputs["compliance"].append(generic_compliance) + generic_compliance.batch_write_data_to_file() # AWS Security Hub Integration if provider == "aws": @@ -1466,6 +1476,10 @@ def prowler(): f"\nDetailed compliance results are in {Fore.YELLOW}{output_options.output_directory}/compliance/{Style.RESET_ALL}\n" ) + # Promote Prowler Cloud as the last thing the user sees after the results + if not args.no_banner and not args.only_logs: + print_prowler_cloud_banner(provider) + # If custom checks were passed, remove the modules if checks_folder: remove_custom_checks_module(checks_folder, provider) diff --git a/prowler/compliance/alibabacloud/csa_ccm_4.0_alibabacloud.json b/prowler/compliance/alibabacloud/csa_ccm_4.0_alibabacloud.json deleted file mode 100644 index 060b6e819e..0000000000 --- a/prowler/compliance/alibabacloud/csa_ccm_4.0_alibabacloud.json +++ /dev/null @@ -1,7305 +0,0 @@ -{ - "Framework": "CSA-CCM", - "Name": "CSA Cloud Controls Matrix (CCM) v4.0.13", - "Version": "4.0", - "Provider": "alibabacloud", - "Description": "The Cloud Security Alliance (CSA) Cloud Controls Matrix (CCM) is a cybersecurity control framework for cloud computing, composed of 197 control objectives structured in 17 domains covering all key aspects of cloud technology. The CCM can be used as a tool for the systematic assessment of a cloud implementation, and provides guidance on which security controls should be implemented by which actor within the cloud supply chain.", - "Requirements": [ - { - "Id": "A&A-02", - "Description": "Conduct independent audit and assurance assessments according to relevant standards at least annually.", - "Name": "Independent Assessments", - "Attributes": [ - { - "Section": "Audit & Assurance", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC4.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AAC-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.5.2", - "5.2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "AS1.1", - "AS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.2.1", - "27002: 18.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.35", - "27001: A.5.36" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-2", - "CA-2(1)", - "CA-2(2)", - "CA-7", - "CA-7(1)" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-01" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_advanced_or_enterprise_edition" - ] - }, - { - "Id": "A&A-04", - "Description": "Verify compliance with all relevant standards, regulations, legal/contractual, and statutory requirements applicable to the audit.", - "Name": "Requirements Compliance", - "Attributes": [ - { - "Section": "Audit & Assurance", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC3.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-01", - "GRM-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "7.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "AS1.1", - "AS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.3.2", - "27001: A.18.2.2", - "27002: 18.2.2", - "27001: A.18.2.3", - "27002: 18.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.3.2", - "27001: A.5.31", - "27001: A.5.32", - "27001: A.5.33", - "27001: A.5.34", - "27001: A.5.36" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-1" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-3", - "DE.DP-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-01" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_advanced_or_enterprise_edition" - ] - }, - { - "Id": "AIS-04", - "Description": "Define and implement a SDLC process for application design, development, deployment, and operation in accordance with security requirements defined by the organization.", - "Name": "Secure Application Design and Development", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AIS-01", - "AIS-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.4", - "5.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.1.1", - "27002: 14.1.1", - "27017: 14.1.1", - "27001: A.14.1.2", - "27002: 14.1.2", - "27017: 14.1.2", - "27001: A.14.2.1", - "27002: 14.2.1", - "27017: 14.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.8", - "27001: A.8.25", - "27001: A.8.26", - "27001: A.8.28" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-2", - "PL-8", - "PL-8(1)", - "SA-3", - "SA-3(1)", - "SA-4", - "SA-4(2)", - "SA-4(3)", - "SA-4(8)", - "SA-4(9)", - "SA-5", - "SA-8", - "SA-8(1)-(7)", - "SA-8(9)-(13)", - "SA-8(15)-(20)", - "SA-8(22)", - "SA-8(24)-(28)", - "SA-8(30)-(33)", - "SA-17", - "SA-17(1)-(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-6", - "PR.DS-7", - "PR.IP-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "PR.IR-01", - "PR.PS-01", - "PR.PS-02", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.1", - "6.2.3", - "6.5.2" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "AIS-05", - "Description": "Implement a testing strategy, including criteria for acceptance of new information systems, upgrades and new versions, which provides application security assurance and maintains compliance while enabling organizational speed of delivery goals. Automate when applicable and possible.", - "Name": "Automated Application Security Testing", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AIS-01", - "AIS-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.12", - "16.13" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD2.3", - "SD2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.2.8", - "27001: A.14.2.9", - "27001: A.12.1.2", - "27002: 12.1.2", - "27001: A.14.1.1", - "27002: 14.1.1", - "27001: A.14.2.2", - "27002: 14.2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.25", - "27001: A.8.29", - "27001: A.8.32", - "27002: 8.25 (e)", - "27002: 8.32 (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SA-11", - "SA-11(1)-(9)", - "SI-6", - "SI-6(2)", - "SI-6(3)", - "SI-10", - "SI-10(1)-(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.PT-3", - "PR.IP-12", - "DE.CM-8" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "ID.RA-01", - "PR.PS-01", - "PR.PS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A.3.2.2", - "A.3.2.2.1", - "6.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.4", - "6.4.1", - "6.4.2", - "6.5.1" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_vulnerability_scan_enabled" - ] - }, - { - "Id": "AIS-07", - "Description": "Define and implement a process to remediate application security vulnerabilities, automating remediation when possible.", - "Name": "Application Vulnerability Remediation", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.1", - "CC7.4", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.2", - "16.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27001: A.12.6.1", - "27002: 12.6.1", - "27017: 12.6.1", - "27018: 12.6.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.26", - "27001: A.8.8", - "27002: 5.26 (j)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SI-2", - "SI-2(2)-(6)", - "SA-11", - "SA-11(2)", - "SA-15", - "SA-15(1)-(3)", - "SA-15(5)-(8)", - "SA-15(10)-(12)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.IP-12", - "DE.CM-8", - "RS.AN-5", - "RS.MI-3", - "PR.DS-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "ID.RA-01", - "ID.RA-06", - "ID.RA-08", - "PR.PS-02", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.2", - "6.5", - "6.5.1-10" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "11.3.1", - "11.3.1.1" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_vulnerability_scan_enabled" - ] - }, - { - "Id": "BCR-08", - "Description": "Periodically backup data stored in the cloud. Ensure the confidentiality, integrity and availability of the backup, and verify data restoration from backup for resiliency.", - "Name": "Backup", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "A1.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "11.1", - "11.2", - "11.3", - "11.4", - "11.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8", - "5.2.9" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.3", - "27017: 12.3", - "27018: 12.3.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.13", - "27001: A.5.23", - "27001: A.5.30", - "27002: 8.13", - "27002: 5.23 2nd (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-4", - "CP-4(4)", - "CP-6", - "CP-6(1)-(3)", - "CP-9", - "CP-9(1)", - "CP-9(2)", - "CP-10", - "CP-10(2)", - "CP-10(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-4", - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-11", - "RC.RP-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "9.5.1", - "12.10.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1", - "10.3.3" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "BCR-09", - "Description": "Establish, document, approve, communicate, apply, evaluate and maintain a disaster response plan to recover from natural and man-made disasters. Update the plan at least annually or upon significant changes.", - "Name": "Disaster Response Plan", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "CC3.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8", - "5.2.9", - "1.6.1", - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "BC1.4", - "BC2.1", - "BC2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.29", - "27001: A.5.30", - "27002: 5.29", - "27002: 5.30" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2(1)", - "CP-2(2)", - "CP-2(3)", - "CP-2(5)", - "CP-2(6)", - "CP-2(7)", - "CP-2(8)", - "PE-13", - "PE-13(1)", - "PE-13(2)", - "PE-13(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-9", - "PR.IP-10", - "RC.IM-1", - "RC.IM-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-04" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "BCR-11", - "Description": "Supplement business-critical equipment with redundant equipment independently located at a reasonable minimum distance in accordance with applicable industry standards.", - "Name": "Equipment Redundancy", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "No", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "CC3.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-06" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "BC1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.20", - "27001: A.7.11", - "27001: A.8.14", - "27002: 5.20 (t)", - "27002: 8.14 (c)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2", - "CP-2(2)", - "CP-4(3)", - "CP-6", - "CP-6(1)", - "CP-7", - "CP-8", - "CP-8(1)-(3)", - "CP-9", - "CP-9(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.BE-4", - "ID.BE-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.OC-04", - "GV.OC-05", - "PR.IR-03" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "CCC-04", - "Description": "Restrict the unauthorized addition, removal, update, and management of organization assets.", - "Name": "Unauthorized Change Protection", - "Attributes": [ - { - "Section": "Change Control and Configuration Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "CCC-04" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.1", - "1.3.4", - "5.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.4", - "SM2.6" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.1.4", - "27002: 12.1.4", - "27001: A.12.4.2", - "27002: 12.4.2", - "27001: A.14.2.2", - "27017: 14.2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.3", - "27001: A.8.4", - "27001: A.8.15", - "27001: A.8.31", - "27001: A.8.32" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-7", - "CA-7(4)", - "CM-3", - "CM-3(1)", - "CM-3(5)", - "CM-3(7)", - "CM-3(8)", - "CM-5", - "CM-5(1)", - "CM-5(4)", - "CM-5(5)", - "CM-6", - "CM-6(1)", - "CM-6(2)", - "CM-7", - "CM-7(1)", - "CM-7(4)", - "CM-7(5)", - "CM-7(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-1", - "ID.AM-2", - "ID.AM-4", - "PR.MA-1", - "PR.MA-2", - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-01", - "ID.AM-02", - "ID.AM-04", - "ID.AM-08", - "PR.PS-02", - "PR.PS-03", - "PR.PS-05", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.5.1", - "6.5.2" - ] - } - ] - } - ], - "Checks": [ - "actiontrail_multi_region_enabled" - ] - }, - { - "Id": "CCC-07", - "Description": "Implement detection measures with proactive notification in case of changes deviating from the established baseline.", - "Name": "Detection of Baseline Deviation", - "Attributes": [ - { - "Section": "Change Control and Configuration Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-01" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.5.1", - "1.5.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.2.2", - "27001: A.14.2.4", - "27001: A.12.4.1", - "27002: 12.4.1 (g)", - "27001: A.5.1.1", - "27017: 5.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.9", - "27001: A.8.15", - "27002: 8.9" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-6", - "CM-6(2)", - "SI-2", - "SI-2(2)-(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.MA-1", - "PR.IP-1", - "DE.DP-4", - "PR.IP-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-01", - "DE.CM-09", - "DE.AE-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4.5.3", - "6.4.5.4", - "11.5", - "11.5.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "11.5.2", - "11.6.1" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_advanced_or_enterprise_edition", - "sls_security_group_changes_alert_enabled", - "sls_vpc_changes_alert_enabled", - "sls_vpc_network_route_changes_alert_enabled", - "sls_customer_created_cmk_changes_alert_enabled", - "sls_cloud_firewall_changes_alert_enabled", - "sls_management_console_authentication_failures_alert_enabled", - "sls_rds_instance_configuration_changes_alert_enabled" - ] - }, - { - "Id": "CEK-03", - "Description": "Provide cryptographic protection to data at-rest and in-transit, using cryptographic libraries certified to approved standards.", - "Name": "Data Encryption", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-03", - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.6", - "3.1", - "3.11", - "11.3", - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.1", - "27001: A.18.1.2", - "27001: A.18.1.3", - "27001: A.18.1.4", - "27001: A.18.1.5", - "27001: A.10.1", - "27002: 10.1", - "27001: A.13.2.1", - "27002: 13.2.1", - "27001: A.18", - "27002: 18", - "27001: A.14.1.2", - "27002: 14.1.2", - "27001: A.14.1.3", - "27002 14.1.3 c)", - "27001 - A.10.1.1", - "27017 - 10.1.1", - "27001 - A.10.1.2", - "27017 - 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.8.24", - "27002: 8.24 Other Information (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-19", - "AC-19(5)", - "SC-8", - "SC-8(1)", - "SC-8(3)", - "SC-8(4)", - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-28", - "SC-28(1)-(3)", - "SI-4", - "SI-4(10)", - "SI-7", - "SI-7(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "Requirement 3", - "2.2.3", - "2.3", - "3.4", - "3.5.3", - "4.1", - "8.2.1", - "PCI Glossary - Strong Cryptography" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.7", - "3.5.1", - "4.2.1", - "4.2.1.2", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "ecs_attached_disk_encrypted", - "ecs_unattached_disk_encrypted", - "rds_instance_tde_enabled", - "rds_instance_ssl_enabled", - "oss_bucket_secure_transport_enabled" - ] - }, - { - "Id": "CEK-04", - "Description": "Use encryption algorithms that are appropriate for data protection, considering the classification of data, associated risks, and usability of the encryption technology.", - "Name": "Encryption Algorithm", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.2", - "27001: 6.1.3", - "27001: A.8.2", - "27002: 8.2", - "27001: A.8.3", - "27001: A.10.1.1", - "27002: 10.1.1 (b)", - "27001: A.10.1.2", - "27002: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.2", - "27001: 6.1.3", - "27001: A.8.24", - "27001: A.5.12", - "27001: A.5.13", - "27002: 8.24 General (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2", - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02", - "ID.AM-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A2", - "Requirement 3", - "2.3", - "2.2.3", - "3.4", - "3.5.3", - "4.1", - "8.2.1", - "PCI Glossary - Strong Cryptography" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.7", - "3.5.1", - "4.2.1", - "4.2.1.2", - "4.2.2" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "CEK-08", - "Description": "CSPs must provide the capability for CSCs to manage their own data encryption keys.", - "Name": "CSC Key Management Capability", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2", - "SC2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1", - "27017: 10.1", - "27001: A.10.1.1", - "27017: 10.1.1", - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.23", - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-9", - "CP-9(8)", - "SA-9", - "SA-9(6)", - "SC-12", - "SC-12(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.SC-3", - "ID.AM-6", - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.SC-05" - ] - } - ] - } - ], - "Checks": [ - "rds_instance_tde_key_custom" - ] - }, - { - "Id": "CEK-10", - "Description": "Generate Cryptographic keys using industry accepted cryptographic libraries specifying the algorithm strength and the random number generator used.", - "Name": "Key Generation", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2", - "TS2.3", - "SY1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27002: 10.1.1 (e)", - "27017: 10.1.1", - "27001: A.10.1.2", - "27002: 10.1.2", - "27002: 10.1.2 (a)", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24", - "27002: 8.24 (d), Key management (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.2.3", - "3.6.1", - "PCI Glossary - Cryptographic Key Generation" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1", - "3.6.1.1", - "3.7.1" - ] - } - ] - } - ], - "Checks": [ - "rds_instance_tde_key_custom" - ] - }, - { - "Id": "CEK-12", - "Description": "Rotate cryptographic keys in accordance with the calculated cryptoperiod, which includes provisions for considering the risk of information disclosure and legal and regulatory requirements.", - "Name": "Key Rotation", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27017: 10.1.1", - "27001: A.10.1.2", - "27002: 10.1.2 e)", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.31", - "27001: A.8.24", - "27002: 5.31 Cryptography", - "27002: 8.24 Key management (e,m)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "ID.GV-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05", - "GV.OC-03" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.7.4", - "3.7.5" - ] - } - ] - } - ], - "Checks": [ - "ram_rotate_access_key_90_days" - ] - }, - { - "Id": "CEK-14", - "Description": "Define, implement and evaluate processes, procedures and technical measures to destroy keys stored outside a secure environment and revoke keys stored in Hardware Security Modules (HSMs) when they are no longer needed, which include provisions for legal and regulatory requirements.", - "Name": "Key Destruction", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27017: 10.1.1", - "27017: 10.1.2", - "27001: A.10.1.2", - "27002: 10.1.2 (j)", - "27001: A.18.1.3", - "27002: 18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.31", - "27001: A.8.24", - "27002: 5.31 Cryptography", - "27002: 8.24 Key management (j,m)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.IP-6", - "ID.GV-3", - "PR.DS-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05", - "ID.AM-08", - "GV.OC-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.6.4", - "3.6.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.7.4", - "3.7.5" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "DCS-06", - "Description": "Catalogue and track all relevant physical and logical assets located at all of the CSP's sites within a secured system.", - "Name": "Assets Cataloguing and Tracking", - "Attributes": [ - { - "Section": "Datacenter Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DCS - 01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "1.1", - "2.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SM2.6" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.1.1", - "27002: 8.1.1", - "27017: 8.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.9" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-8", - "CM-8(1)", - "CM-8(2)", - "CM-8(4)", - "CM-8(7)", - "CM-8(8)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-1", - "ID.AM-2", - "ID.AM-4", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-01", - "ID.AM-02", - "ID.AM-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.4", - "9.7.1", - "9.9.1", - "9.9.1.a", - "9.9.1.b", - "9.9.1.c", - "12.3.3", - "12.3.4" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1.1", - "6.3.2", - "9.4.2", - "9.4.3", - "12.5.1" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_all_assets_agent_installed" - ] - }, - { - "Id": "DSP-02", - "Description": "Apply industry accepted methods for the secure disposal of data from storage media such that data is not recoverable by any forensic means.", - "Name": "Secure Disposal", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3", - "CC6.4", - "CC6.5", - "CC6.7", - "P4.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DSI-07" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.3.3", - "7.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.3.2", - "27002: 8.3.2", - "27001: A.11.2.7", - "27002: 11.2.7" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.7.10", - "27001: A.7.14", - "27001: A.8.10", - "27002: 7.10 (Secure reuse or disposal)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-22", - "SI-12", - "SI-12(3)", - "SI-18", - "SI-18(1)", - "SI-18(4)", - "SI-18(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.SC-10", - "PR.PS-02", - "PR.PS-03", - "ID.AM-08" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.1", - "9.8", - "9.8.1", - "9.8.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1", - "3.7.5", - "9.4.7" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "DSP-03", - "Description": "Create and maintain a data inventory, at least for any sensitive data and personal data.", - "Name": "Data Inventory", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1", - "1.3.2", - "1.3.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.1.1", - "27002: 8.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.9", - "27001: A.8.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-12", - "CM-12(1)", - "PM-5", - "PM-5(1)", - "SI-12", - "SI-12(1)", - "SI-19", - "SI-19(1)", - "SI-19(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1", - "9.4.5" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_all_assets_agent_installed" - ] - }, - { - "Id": "DSP-04", - "Description": "Classify data according to its type and sensitivity level.", - "Name": "Data Classification", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "C1.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DSI-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.7" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1", - "1.3.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.2.1", - "27002: 8.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-16", - "AC-16(9)", - "PM-22", - "PM-23", - "PT-2", - "PT-2(1)", - "SI-18", - "SI-18(2)", - "SI-19", - "SI-19(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-05", - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "9.6.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "9.4.2", - "9.4.3" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "DSP-07", - "Description": "Develop systems, products, and business practices based upon a principle of security by design and industry best practices.", - "Name": "Data Protection by Design and Default", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "PI1.2", - "PI1.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.3.1", - "5.3.2", - "5.3.3", - "5.3.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD2.2", - "IM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.1.1", - "27002:14.1.1", - "27001: A.14.2.5", - "27002:14.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.27", - "27001: A.8.28", - "27001: A.8.29", - "27002: 5.8 (Information security requirements a-i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-17", - "PM-24", - "PM-25", - "PT-2", - "PT-2(2)", - "SA-3", - "SA-4", - "SA-5", - "SA-8", - "SA-8(9)", - "SA-8(13)", - "SA-8(18)", - "SA-8(20)", - "SA-8(22)", - "SA-8(23)", - "SA-8(33)", - "SA-15", - "SA-15(12)", - "SC-3", - "SC-3(3)", - "SC-7", - "SC-7(24)", - "SC-8", - "SC-8(1)-(4)", - "SC-28", - "SC-28(1)", - "SI-12", - "SI-12(1)-(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.PT-3", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.1" - ] - } - ] - } - ], - "Checks": [ - "oss_bucket_not_publicly_accessible", - "rds_instance_no_public_access_whitelist" - ] - }, - { - "Id": "DSP-10", - "Description": "Define, implement and evaluate processes, procedures and technical measures that ensure any transfer of personal or sensitive data is protected from unauthorized access and only processed within scope as permitted by the respective laws and regulations.", - "Name": "Sensitive Data Transfer", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-02", - "EKM-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.1", - "3.12", - "3.13" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "9.5.1", - "9.5.2", - "9.5.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.4", - "IM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.13.2.1", - "27002: 13.2.1", - "27001: A.8.3.3", - "27002: 8.3.3", - "27001: A.13.2.3", - "27002: 13.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.7.10" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-4", - "AC-4(23)-(25)", - "CA-3", - "CA-3(6)", - "CA-6", - "CA-6(1)", - "CA-6(2)", - "SC-4", - "SC-4(2)", - "SC-7", - "SC-7(10)", - "SC-7(24)", - "SC-8", - "SC-8(1)-(5)", - "SC-16", - "SC-16(1)-(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2", - "PR.DS-5", - "PR.PT-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02", - "PR.IR-01", - "ID.AM-03", - "GV.OC-03", - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "4.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "4.1.1", - "4.2.1", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "oss_bucket_secure_transport_enabled", - "rds_instance_ssl_enabled" - ] - }, - { - "Id": "DSP-16", - "Description": "Data retention, archiving and deletion is managed in accordance with business requirements, applicable laws and regulations.", - "Name": "Data Retention and Deletion", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "C1.1", - "C1.2", - "CC3.1", - "P4.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-02", - "BCR-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.4", - "3.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.3.1", - "7.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.33", - "27001: A.8.10", - "27002: 5.33 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SI-12", - "SI-12(1)-(3)", - "SI-18", - "SI-18(1)", - "SI-18(4)", - "SI-18(5)", - "SI-19", - "SI-19(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-3", - "PR.IP-6", - "ID.GV-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "GV.OC-03", - "GV.SC-10", - "PR.DS-11" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1" - ] - } - ] - } - ], - "Checks": [ - "sls_logstore_retention_period", - "rds_instance_sql_audit_retention" - ] - }, - { - "Id": "DSP-17", - "Description": "Define and implement, processes, procedures and technical measures to protect sensitive data throughout it's lifecycle.", - "Name": "Sensitive Data Protection", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSC-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.1", - "CC6.1", - "CC6.3", - "CC6.7", - "CC8.1", - "C1.1", - "P2.0", - "P3.0", - "P4.0", - "P5.0", - "P6.0" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.1", - "3.1", - "3.14" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.3.3", - "9.1.1", - "9.2.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3", - "27002: 18.1.3", - "27001:A.18.1.4", - "27002:18.1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.11", - "27001: A.8.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-2", - "PM-22", - "PM-24", - "PT-7", - "PT-7(1)", - "PT-7(2)", - "PT-8", - "SC-8", - "SC-8(1)-(5)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2", - "PR.DS-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02", - "PR.DS-10" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.0 (including all subsections)", - "4.0 (including all subsections)" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.1.1", - "4.1.1" - ] - } - ] - } - ], - "Checks": [ - "oss_bucket_not_publicly_accessible", - "rds_instance_no_public_access_whitelist", - "ecs_attached_disk_encrypted", - "ecs_unattached_disk_encrypted", - "rds_instance_tde_enabled" - ] - }, - { - "Id": "GRC-05", - "Description": "Develop and implement an Information Security Program, which includes programs for all the relevant domains of the CCM.", - "Name": "Information Security Program", - "Attributes": [ - { - "Section": "Governance, Risk and Compliance", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "14.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SG2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 4.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-1", - "PM-3", - "PM-14", - "PL-2", - "PM-18", - "PM-31" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.4.1", - "A.3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.4.1", - "A3.1.1" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_advanced_or_enterprise_edition" - ] - }, - { - "Id": "IAM-02", - "Description": "Establish, document, approve, communicate, implement, apply, evaluate and maintain strong password policies and procedures. Review and update the policies and procedures at least annually.", - "Name": "Strong Password Policy and Procedures", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-12", - "GRM-06", - "GRM-09" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.1.1", - "1.5.1", - "4.1.2", - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1", - "SA1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5", - "27002: 5", - "27001: A.9.4.3", - "27002: 9.4.3", - "27017: 9.4.3", - "27018: 9.4.3", - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27001: A.7.2.2", - "27002: 7.2.2", - "27001: A.9.2.6", - "27002: 9.2.6", - "27001: A.9.2.3", - "27002: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5.1", - "27001: A.5.4", - "27001: A.5.17", - "27001: A.6.3", - "27001: A.8.5", - "27001: A.5.37" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(3)", - "AC-2(11)", - "AC-3", - "AC-3(3)", - "AC-12", - "AC-12(1)", - "IA-2", - "IA-2(10)", - "IA-5", - "IA-5(1)", - "IA-5(18)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "PR.AC-1", - "PR.AC-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.PO-01", - "GV.PO-02", - "ID.IM-03", - "PR.AA-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.4", - "12.1", - "12.1.1", - "12.11" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.1.1", - "8.3.8" - ] - } - ] - } - ], - "Checks": [ - "ram_password_policy_minimum_length", - "ram_password_policy_lowercase", - "ram_password_policy_uppercase", - "ram_password_policy_number", - "ram_password_policy_symbol", - "ram_password_policy_password_reuse_prevention", - "ram_password_policy_max_password_age", - "ram_password_policy_max_login_attempts" - ] - }, - { - "Id": "IAM-03", - "Description": "Manage, store, and review the information of system identities, and level of access.", - "Name": "Identity Inventory", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-04", - "IAM-08", - "IAM-10" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1", - "5.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.2 (c)", - "27001: A.8.1.1", - "27002: 8.1.1", - "27001: A.9.4.1", - "27002: 9.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.2 (c)", - "27001: A.5.15", - "27001: A.5.16", - "27001: A.5.18", - "27001: A.7.4", - "27001: A.8.15", - "27001: A.8.2", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-10", - "AU-10(1)", - "AU-10(2)", - "AU-16", - "AU-16(1)", - "IA-4", - "IA-4(8)", - "IA-4(9)", - "IA-5", - "IA-5(5)", - "IA-8", - "IA-8(4)", - "PM-5(1)", - "SA-8", - "SA-8(22)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-04", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.4.a" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.5", - "7.2.5.1" - ] - } - ] - } - ], - "Checks": [ - "ram_user_console_access_unused" - ] - }, - { - "Id": "IAM-04", - "Description": "Employ the separation of duties principle when implementing information system access.", - "Name": "Separation of Duties", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC1.3", - "CC5.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.2.2", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.6.1.2", - "27002: 6.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.18", - "27001: A.5.3", - "27001: A.8.2" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(3)", - "AC-2(11)", - "AC-6", - "AC-6(1)-(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4", - "6.4.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.5.3", - "6.5.4", - "7.2.1", - "7.2.2" - ] - } - ] - } - ], - "Checks": [ - "ram_policy_attached_only_to_group_or_roles" - ] - }, - { - "Id": "IAM-05", - "Description": "Employ the least privilege principle when implementing information system access.", - "Name": "Least Privilege", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-06", - "IVS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.1.1", - "27002: 9.1.1", - "27001: A.9.1.2", - "27002: 9.1.2", - "27001: A.9.2.3", - "27002: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.8.2", - "27002: 5.15 (Other information 2nd (a))" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(4)", - "IA-12", - "IA-12(2)", - "IA-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "7.1", - "7.1.1", - "7.1.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "7.2.2", - "7.2.5", - "7.2.6" - ] - } - ] - } - ], - "Checks": [ - "ram_policy_no_administrative_privileges" - ] - }, - { - "Id": "IAM-07", - "Description": "De-provision or respectively modify access of movers / leavers or system identity changes in a timely manner in order to effectively adopt and communicate identity and access management policies.", - "Name": "User Access Changes and Revocation", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.3", - "6.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.18" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(1)", - "AC-2(2)", - "AC-2(6)", - "AC-2(8)", - "AC-3", - "AC-3(8)", - "AC-6", - "AC-6(7)", - "AU-10", - "AU-10(4)", - "AU-16", - "AU-16(1)", - "CM-7", - "CM-7(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4", - "PR.IP-11" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.RR-04", - "GV.SC-10", - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1.2", - "8.1.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.2.5", - "8.2.6" - ] - } - ] - } - ], - "Checks": [ - "ram_user_console_access_unused" - ] - }, - { - "Id": "IAM-08", - "Description": "Review and revalidate user access for least privilege and separation of duties with a frequency that is commensurate with organizational risk tolerance.", - "Name": "User Access Review", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-10" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.5", - "27001: A.9.2.6", - "27001: A.9.4.1", - "27017: 9.4.1", - "27001: A.6.1.2", - "27001: A 9.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.3", - "27001: A.5.18", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(4)", - "AC-6(8)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.5.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.5.1", - "7.2.5", - "7.2.4" - ] - } - ] - } - ], - "Checks": [ - "ram_user_console_access_unused", - "ram_rotate_access_key_90_days" - ] - }, - { - "Id": "IAM-09", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the segregation of privileged access roles such that administrative access to data, encryption and key management capabilities and logging capabilities are distinct and separated.", - "Name": "Segregation of Privileged Access Roles", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.1", - "CC6.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.3", - "27002: 9.2.3", - "27017: 9.2.3", - "27018: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.2", - "27001: A.8.18", - "27002: 8.2 (j)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-3(7)", - "AC-6(4)", - "AC-6(8)", - "IA-5", - "IA-5(6)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.3", - "3.5.2", - "7.1.2", - "7.1.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1", - "3.7.6", - "6.5.3", - "6.5.4", - "7.2.1", - "7.2.2", - "10.3.1" - ] - } - ] - } - ], - "Checks": [ - "ram_policy_attached_only_to_group_or_roles", - "ram_no_root_access_key" - ] - }, - { - "Id": "IAM-10", - "Description": "Define and implement an access process to ensure privileged access roles and rights are granted for a time limited period, and implement procedures to prevent the culmination of segregated privileged access.", - "Name": "Management of Privileged Access Roles", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1", - "6.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.3", - "27002: 9.2.3", - "27017: 9.2.3", - "27018: 9.2.3", - "27001: A.9.4.4", - "27002: 9.4.4", - "27017: 9.4.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.2", - "27001: A.8.18", - "27002: 8.2 (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(7)", - "AC-3", - "AC-3(4)", - "AC-3(11)", - "AC-3(13)", - "AC-3(14)", - "AC-6", - "AC-6(4)", - "AC-6(5)", - "AC-6(8)", - "AC-12", - "AC-12(3)", - "AC-17", - "AC-17(4)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "7.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "7.2.2" - ] - } - ] - } - ], - "Checks": [ - "ram_no_root_access_key", - "ram_policy_no_administrative_privileges" - ] - }, - { - "Id": "IAM-12", - "Description": "Define, implement and evaluate processes, procedures and technical measures to ensure the logging infrastructure is read-only for all with write access, including privileged access roles, and that the ability to disable it is controlled through a procedure that ensures the segregation of duties and break glass procedures.", - "Name": "Safeguard Logs Integrity", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.3" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1", - "27018: 12.4.1", - "27001: A.12.4.2", - "27002: 12.4.2", - "27017: 12.4.2", - "27018: 12.4.2", - "27001: A.12.4.3", - "27002: 12.4.3", - "27017: 12.4.3", - "27018: 12.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15", - "27001: A.8.18", - "27002: 8.15 Protection of Logs" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(11)", - "AC-2(12)", - "IA-8", - "IA-8(4)", - "SA-8", - "SA-8(22)", - "SC-34", - "SC-34(1)", - "SC-34(2)", - "SC-36", - "SI-4", - "SI-4(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4" - ] - } - ] - } - ], - "Checks": [ - "actiontrail_oss_bucket_not_publicly_accessible" - ] - }, - { - "Id": "IAM-13", - "Description": "Define, implement and evaluate processes, procedures and technical measures that ensure users are identifiable through unique IDs or which can associate individuals to the usage of user IDs.", - "Name": "Uniquely Identifiable Users", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.1", - "27002: 9.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.16" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-3", - "AC-3(14)", - "AC-24", - "AC-24(2)", - "AU-10", - "AU-10(1)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(12)", - "IA-4", - "IA-4(1)", - "SA-8", - "SA-8(22)", - "SC-23", - "SC-23(3)", - "SC-40(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1", - "8.2", - "8.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.2.1", - "8.2.2", - "8.2.4" - ] - } - ] - } - ], - "Checks": [ - "ram_user_mfa_enabled_console_access" - ] - }, - { - "Id": "IAM-14", - "Description": "Define, implement and evaluate processes, procedures and technical measures for authenticating access to systems, application and data assets, including multifactor authentication for at least privileged user and sensitive data access. Adopt digital certificates or alternatives which achieve an equivalent level of security for system identities.", - "Name": "Strong Authentication", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.3", - "6.5", - "12.5", - "12.7" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3", - "SA1.4", - "SA1.8" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.1.2", - "27002: 9.1.2", - "27017: 9.1.2", - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27001: A.9.4.2", - "27002: 9.4.2", - "27017: 9.4.2", - "27018: 9.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.17", - "27001: A.8.5", - "27001: A.8.24", - "27002: 8.5", - "27002: 8.24 other information (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(5)", - "AC-7", - "AC-7(4)", - "AU-10", - "AU-10(2)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(8)", - "IA-2(12)", - "IA-3", - "IA-3(1)", - "IA-5", - "IA-5(2)", - "IA-5(7)", - "IA-5(9)", - "IA-5(10)", - "IA-5(12)", - "IA-5(14)-(16)", - "IA-8", - "IA-8(1)", - "IA-8(6)", - "SC-23", - "SC-23(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6", - "PR.AC-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1.2", - "8.1.3", - "8.1.6", - "8.2", - "8.3", - "8.3.2", - "12.3.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "8.3.1", - "8.3.2", - "8.4.1", - "8.4.2", - "8.4.3" - ] - } - ] - } - ], - "Checks": [ - "ram_user_mfa_enabled_console_access" - ] - }, - { - "Id": "IAM-15", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the secure management of passwords.", - "Name": "Passwords Management", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27018: 9.2.4", - "27001: A.9.3.1", - "27002: 9.3.1", - "27017: 9.3.1", - "27018: 9.3.1", - "27001: A.9.4.3", - "27002: 9.4.3", - "27017: 9.4.3", - "27018: 9.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.17" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IA-4", - "IA-4(8)", - "IA-5", - "IA-5(1)", - "IA-5(8)", - "IA-5(18)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.2", - "8.2.1-6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.2", - "2.3.1", - "8.3.5", - "8.3.6", - "8.3.7", - "8.3.8", - "8.3.9", - "8.3.10", - "8.3.10.1", - "8.6.2" - ] - } - ] - } - ], - "Checks": [ - "ram_password_policy_minimum_length", - "ram_password_policy_password_reuse_prevention", - "ram_password_policy_max_password_age" - ] - }, - { - "Id": "IAM-16", - "Description": "Define, implement and evaluate processes, procedures and technical measures to verify access to data and system functions is authorized.", - "Name": "Authorization Mechanisms", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3", - "SA1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.5", - "27002: 9.2.5", - "27017: 9.2.5", - "27018: 9.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.18" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-3", - "AC-3(5)", - "AC-4", - "AC-4(17)", - "AC-4(21)", - "AC-4(22)", - "AC-6", - "AC-6(8)", - "AC-6(9)", - "AC-12", - "AC-12(1)", - "AC-20", - "AC-20(1)", - "AU-10", - "AU-10(1)", - "AU-10(2)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(12)", - "IA-3", - "IA-3(1)", - "IA-5(1)", - "IA-5(2)", - "IA-5(5)", - "IA-5(8)", - "IA-5(10)", - "IA-5(12)", - "IA-8", - "IA-8(1)", - "IA-8(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4", - "PR.AC-6", - "PR.AC-7", - "PR.PT-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-03", - "PR.AA-04", - "PR.AA-05", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.3", - "7.1.4" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.4", - "7.2.3", - "7.2.5.1" - ] - } - ] - } - ], - "Checks": [ - "ram_policy_no_administrative_privileges", - "cs_kubernetes_rbac_enabled" - ] - }, - { - "Id": "IPY-03", - "Description": "Implement cryptographically secure and standardized network protocols for the management, import and export of data.", - "Name": "Secure Interoperability and Portability Management", - "Attributes": [ - { - "Section": "Interoperability & Portability", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IPY-04" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY1.1", - "SY1.2", - "NC1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1", - "27001: A.15.1.1", - "27002: 15.1.1", - "27017: 15.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.19", - "27001: A.5.23", - "27001: A.5.31", - "27001: A.5.32", - "27001: A.5.33", - "27001: A.5.34" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PT-2", - "PT-2(2)", - "SA-4", - "SC-16", - "SC-16(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.2.1", - "1.2.5", - "1.2.6", - "2.2.4", - "2.2.5", - "2.2.7", - "4.2.1" - ] - } - ] - } - ], - "Checks": [ - "oss_bucket_secure_transport_enabled" - ] - }, - { - "Id": "IVS-02", - "Description": "Plan and monitor the availability, quality, and adequate capacity of resources in order to deliver the required system performance as determined by the business.", - "Name": "Capacity and Resource Planning", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "No", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-04" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.3", - "27001: 6.1", - "27001: 9.1", - "27001: A.12.1.3", - "27002: 12.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.3 (b)", - "27001: 6.1", - "27001: 9.1", - "27001: A.8.6", - "27001: A.8.14" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2", - "CP-2(2)", - "SC-5", - "SC-5(2)", - "SC-4", - "SI-4" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-4", - "ID.BE-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.IR-04", - "GV.OC-04" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "IVS-03", - "Description": "Monitor, encrypt and restrict communications between environments to only authenticated and authorized connections, as justified by the business. Review these configurations at least annually, and support them by a documented justification of all allowed services, protocols, ports, and compensating controls.", - "Name": "Network Security", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-06" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.8", - "3.1", - "12.2", - "13.6", - "13.9" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "NC1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.13.1.1", - "27002: 13.1.1", - "27001: A.13.1.2", - "27002: 13.1.2", - "27001: A.13.1.3", - "27002: 13.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.5.15", - "27001: A.5.37", - "27001: A.8.5", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.21", - "27001: A.8.22", - "27001: A.8.24", - "27002: A.5.15 2nd c)", - "27002: 8.20", - "27002: 8.21", - "27002: 8.22", - "27002: 8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-1", - "SC-4", - "SC-7", - "SC-7(4)", - "SC-7(5)", - "SC-7(8)", - "SC-7(9)", - "SC-7(11)", - "SC-8", - "SC-8(1)", - "SC-11", - "SC-12", - "SC-16", - "SC-23", - "SC-29", - "SC-29(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-5", - "PR.AC-7", - "PR.PT-4", - "DE.CM-1", - "DE.CM-7", - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.IR-01", - "PR.AA-03", - "PR.AA-05", - "DE.CM-01", - "PR.DS-02", - "ID.AM-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "1.1.6", - "1.2", - "1.2.3", - "2.2", - "4.1.1", - "10.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.2.5", - "1.2.6", - "1.2.7", - "1.4.2", - "2.2.4", - "2.2.5", - "2.2.7", - "4.2.1", - "10.1.1" - ] - } - ] - } - ], - "Checks": [ - "vpc_flow_logs_enabled", - "ecs_securitygroup_restrict_ssh_internet", - "ecs_securitygroup_restrict_rdp_internet" - ] - }, - { - "Id": "IVS-04", - "Description": "Harden host and guest OS, hypervisor or infrastructure control plane according to their respective best practices, and supported by technical controls, as part of a security baseline.", - "Name": "OS Hardening and Base Controls", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.8", - "CC7.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-07", - "IVS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "4.1", - "4.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3", - "5.2.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY1.1", - "SY1.3", - "SY1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.14.2.2", - "27002: 14.2.2", - "27001: A.14.2.3", - "27001 A.14.2.4", - "27018: 12.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.5.37", - "27001: A.8.5", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.22", - "27001: A.8.24", - "27002: 8.20", - "27002: 8.22", - "27002: 8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-6", - "CM-6(1)", - "SC-29", - "SC-29(1)", - "SC-2", - "SC-7", - "SC-7(12)", - "SC-30", - "SC-34", - "SC-35", - "SC-39", - "SC-44" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-1", - "PR.PT-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.1" - ] - } - ] - } - ], - "Checks": [ - "ecs_instance_latest_os_patches_applied", - "ecs_instance_endpoint_protection_installed" - ] - }, - { - "Id": "IVS-06", - "Description": "Design, develop, deploy and configure applications and infrastructures such that CSP and CSC (tenant) user access and intra-tenant access is appropriately segmented and segregated, monitored and restricted from other tenants.", - "Name": "Segmentation and Segregation", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-09" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.3.4", - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SC2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.1", - "27001: A.13.1.3", - "27002: 13.1.3", - "27017: 13.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.1", - "27001: A.5.15", - "27001: A.5.20", - "27001: A.8.3", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.22", - "27002: 5.15 (b)", - "27002: 8.3 (b)", - "27002: 8.16 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-3", - "SC-7", - "SC-7(20)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.AC-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.IR-01", - "PR.PS-01", - "PR.PS-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.6", - "8.3.1", - "10.8", - "11.3", - "A3.2.1", - "A3.3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "A1.1.1", - "A1.1.2", - "A1.1.3" - ] - } - ] - } - ], - "Checks": [ - "cs_kubernetes_network_policy_enabled", - "cs_kubernetes_private_cluster_enabled", - "ecs_instance_no_legacy_network" - ] - }, - { - "Id": "IVS-07", - "Description": "Use secure and encrypted communication channels when migrating servers, services, applications, or data to cloud environments. Such channels must include only up-to-date and approved protocols.", - "Name": "Migration to Cloud Environments", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-10" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.4", - "IM1.4", - "NC1.4", - "SC2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.13.1.1", - "27002: 13.1.1", - "27017: 13.1.1", - "27018: 13.1.1", - "27001: A.13.1.2", - "27002: 13.1.2", - "27017: 13.1.2", - "27018: 13.1.2", - "27001: A.13.1.3", - "27002: 13.1.3", - "27017: 13.1.3", - "27018: 13.1.3", - "27001: A.13.2.1", - "27002: 13.2.1", - "27017: 13.2.1", - "27018: 13.2.1", - "27001: A.13.2.2", - "27002: 13.2.2", - "27017: 13.2.2", - "27018: 13.2.2", - "27001: A.13.2.3", - "27002: 13.2.3", - "27017: 13.2.3", - "27018: 13.2.3", - "27001: A.13.2.4", - "27002: 13.2.4", - "27017: 13.2.4", - "27018: 13.2.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.8.20", - "27001: A.8.24", - "27002: 8.20 (e)", - "27002: 8.24 Guidance (b,f), other information (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-17", - "AC-20", - "SC-7", - "SC-7(28)", - "SC-8", - "SC-8(1)", - "SC-12", - "SC-23", - "SC-29", - "SI-7", - "SI-7(1)-(3)", - "SI-7(5)-(10)", - "SI-7(12)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2", - "PR.PT-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "4.2.1" - ] - } - ] - } - ], - "Checks": [ - "rds_instance_ssl_enabled" - ] - }, - { - "Id": "IVS-09", - "Description": "Define, implement and evaluate processes, procedures and defense-in-depth techniques for protection, detection, and timely response to network-based attacks.", - "Name": "Network Defense", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.6", - "CC6.8", - "CC7.1", - "CC7.2", - "CC7.5" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-13" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "13.3", - "13.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.3", - "5.2.4", - "5.2.5", - "5.2.7", - "5.3.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "NC1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1", - "27001: 6.2", - "27001: A.14.1.2", - "27002: 14.1.2", - "27017: 14.1.2", - "27001: A.11.1.4", - "27002: 11.1.4", - "27017: 11.1.4", - "27018: 16.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1", - "27001: 6.2", - "27001: A.5.24", - "27001: A.5.26", - "27001: A.8.8", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.21", - "27001: A.8.22", - "27001: A.8.26", - "27002: 8.8 (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-8", - "PL-8(1)", - "SC-5", - "SC-5(1)", - "SC-5(3)", - "SC-7", - "SC-7(13)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.DP-1", - "DE.CM-1", - "DE.CM-7", - "PR.AC-5", - "RS.MI-2", - "PR.DS-2", - "RS.RP-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-03", - "DE.CM-01", - "PR.IR-01", - "RS.MA-01", - "RS.MI-01", - "RS.MI-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.6", - "1.1", - "1.2", - "1.3", - "1.5", - "12.10.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.1.1", - "1.3.1", - "1.3.2", - "1.3.3", - "1.4.1", - "1.4.2", - "1.4.3", - "1.4.4", - "1.4.5", - "1.5.1", - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_advanced_or_enterprise_edition", - "sls_cloud_firewall_changes_alert_enabled" - ] - }, - { - "Id": "LOG-02", - "Description": "Define, implement and evaluate processes, procedures and technical measures to ensure the security and retention of audit logs.", - "Name": "Audit Logs Protection", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.1", - "8.9", - "8.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "3.1.3", - "5.1.2", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3", - "27002: 18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.28", - "27001: A.5.33", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-4", - "AU-11" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.IP-4", - "PR.IP-6", - "PR.PT-1", - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.DS-01", - "PR.DS-02", - "ID.AM-08", - "PR.DS-11", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5", - "10.7" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4", - "10.5.1" - ] - } - ] - } - ], - "Checks": [ - "actiontrail_oss_bucket_not_publicly_accessible", - "sls_logstore_retention_period" - ] - }, - { - "Id": "LOG-03", - "Description": "Identify and monitor security-related events within applications and the underlying infrastructure. Define and implement a system to generate alerts to responsible stakeholders based on such events and corresponding metrics.", - "Name": "Security Monitoring and Alerting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-03", - "SEF-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4", - "5.2.7", - "1.6.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2", - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.28", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-5", - "AU-5(2)", - "AU-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.AE-2", - "DE.AE-3", - "DE.AE-5", - "DE.CM-1", - "DE.CM-2", - "DE.CM-3", - "DE.CM-4", - "DE.CM-5", - "DE.CM-6", - "DE.CM-7", - "DE.DP-1", - "DE.DP-4", - "DE.AE-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.AE-02", - "DE.AE-03", - "DE.AE-04", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1", - "10.2.2", - "10.4.1.1", - "10.4.2.1", - "10.4.3" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_advanced_or_enterprise_edition", - "securitycenter_notification_enabled_high_risk", - "sls_unauthorized_api_calls_alert_enabled", - "sls_root_account_usage_alert_enabled", - "sls_management_console_signin_without_mfa_alert_enabled" - ] - }, - { - "Id": "LOG-04", - "Description": "Restrict audit logs access to authorized personnel and maintain records that provide unique access accountability.", - "Name": "Audit Logs Access and Accountability", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.14" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "3.1.1", - "4.1.2", - "4.1.3", - "4.2.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.2", - "27001: A.12.4.1", - "27002: 12.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.33", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(4)", - "AU-9(6)", - "AU-10" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.1", - "10.2.1", - "10.2.3", - "10.5.1", - "10.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1.3", - "10.3.1" - ] - } - ] - } - ], - "Checks": [ - "actiontrail_oss_bucket_not_publicly_accessible" - ] - }, - { - "Id": "LOG-05", - "Description": "Monitor security audit logs to detect activity outside of typical or expected patterns. Establish and follow a defined process to review and take appropriate and timely actions on detected anomalies.", - "Name": "Audit Logs Monitoring and Response", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.8", - "8.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.1", - "1.6.2", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.3", - "27002: 12.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15", - "27001: A.8.16" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-6", - "AU-6(1)", - "AU-6(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-3", - "PR.PT-1", - "RS.AN-1", - "RS.CO-1.", - "DE.AE-1", - "DE.AE-5", - "DE.DP-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-03", - "PR.PS-04", - "DE.AE-02", - "DE.AE-03", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.6", - "10.6.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.4.1.1", - "10.4.2.1" - ] - } - ] - } - ], - "Checks": [ - "sls_unauthorized_api_calls_alert_enabled", - "sls_root_account_usage_alert_enabled", - "sls_management_console_signin_without_mfa_alert_enabled", - "sls_ram_role_changes_alert_enabled", - "sls_security_group_changes_alert_enabled", - "sls_vpc_changes_alert_enabled", - "sls_vpc_network_route_changes_alert_enabled", - "sls_management_console_authentication_failures_alert_enabled", - "sls_customer_created_cmk_changes_alert_enabled", - "sls_oss_bucket_policy_changes_alert_enabled", - "sls_oss_permission_changes_alert_enabled", - "sls_cloud_firewall_changes_alert_enabled", - "sls_rds_instance_configuration_changes_alert_enabled" - ] - }, - { - "Id": "LOG-07", - "Description": "Establish, document and implement which information meta/data system events should be logged. Review and update the scope at least annually or whenever there is a change in the threat environment.", - "Name": "Logging Scope", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5.3", - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5.3", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-1", - "AU-14", - "AU-16" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.SC-3", - "ID.SC-4", - "PR.PT-1", - "ID.GV-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1", - "10.2.2" - ] - } - ] - } - ], - "Checks": [ - "actiontrail_multi_region_enabled", - "vpc_flow_logs_enabled" - ] - }, - { - "Id": "LOG-08", - "Description": "Generate audit records containing relevant security information.", - "Name": "Log Records", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-3", - "AU-3(1)", - "AU-3(3)", - "AU-6", - "AU-6(8)", - "AU-12", - "AU-12(1)", - "AU-12(2)", - "AU-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.PT-1", - "DE.AE-3", - "DE.CM-1", - "DE.CM-2", - "DE.CM-3", - "DE.CM-6", - "DE.CM-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.2" - ] - } - ] - } - ], - "Checks": [ - "actiontrail_multi_region_enabled", - "vpc_flow_logs_enabled", - "oss_bucket_logging_enabled", - "rds_instance_sql_audit_enabled", - "cs_kubernetes_log_service_enabled", - "rds_instance_postgresql_log_connections_enabled", - "rds_instance_postgresql_log_disconnections_enabled", - "rds_instance_postgresql_log_duration_enabled" - ] - }, - { - "Id": "LOG-09", - "Description": "The information system protects audit records from unauthorized access, modification, and deletion.", - "Name": "Log Protection", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-04", - "IVS-01" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.2", - "27002: 12.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(2)", - "AU-9(3)", - "AU-9(4)", - "AU-12(3)", - "AU-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.IP-4", - "PR.IP-6", - "PR.PT-1", - "PR.DS-1", - "PR.DS-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.DS-01", - "PR.DS-02", - "PR.DS-11" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5", - "10.5.1", - "10.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4" - ] - } - ] - } - ], - "Checks": [ - "actiontrail_oss_bucket_not_publicly_accessible" - ] - }, - { - "Id": "LOG-10", - "Description": "Establish and maintain a monitoring and internal reporting capability over the operations of cryptographic, encryption and key management policies, processes, procedures, and controls.", - "Name": "Encryption Monitoring and Reporting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-02", - "EKM-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1", - "27002: 10.1", - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-1", - "AU-9", - "AU-9(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "PR.PT-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.1.1", - "10.2.1", - "10.4.1" - ] - } - ] - } - ], - "Checks": [ - "sls_customer_created_cmk_changes_alert_enabled" - ] - }, - { - "Id": "LOG-11", - "Description": "Log and monitor key lifecycle management events to enable auditing and reporting on usage of cryptographic keys.", - "Name": "Transaction/Activity Logging", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.PT-1", - "DE.AE-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-09" - ] - } - ] - } - ], - "Checks": [ - "actiontrail_multi_region_enabled" - ] - }, - { - "Id": "LOG-13", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the reporting of anomalies and failures of the monitoring system and provide immediate notification to the accountable party.", - "Name": "Failures and Anomalies Reporting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.3", - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.1", - "27002: 16.1.1", - "27001: A.16.1.2", - "27017: 16.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.24", - "27001: A.6.8", - "27002: 6.8 (g)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-5", - "AU-5(2)", - "AU-6", - "AU-6(3)", - "AU-6(4)", - "AU-6(5)", - "AU-16" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-3", - "DE.DP-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.AE-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.4.3", - "10.7.1", - "10.7.2", - "10.7.3" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_advanced_or_enterprise_edition", - "securitycenter_notification_enabled_high_risk" - ] - }, - { - "Id": "SEF-03", - "Description": "'Establish, document, approve, communicate, apply, evaluate and maintain a security incident response plan, which includes but is not limited to: relevant internal departments, impacted CSCs, and other business critical relationships (such as supply-chain) that may be impacted.'", - "Name": "Incident Response Plans", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2", - "CC7.3", - "CC7.4" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "17.2", - "17.4" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27017: CLD.12.1.5", - "27018: 16.1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: A.5.26", - "27002: 5.26 (e,f)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IR-1", - "IR-2", - "IR-2(1)-(3)", - "IR-3", - "IR-3(1)-(3)", - "IR-4", - "IR-4(1)-(15)", - "IR-5", - "IR-5(1)", - "IR-6", - "IR-6(1)-(3)", - "IR-7", - "IR-7(1)", - "IR-7(2)", - "IR-8", - "IR-8(1)", - "IR-9", - "IR-9(1)-(4)", - "PM-12" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "RS.CO-1", - "RS.CO-4", - "ID.AM-6", - "ID.GV-2", - "ID.SC-5", - "PR.IP-9", - "PR.IP10" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AT-01", - "PR.AT-02", - "RS.MA-01", - "GV.SC-08", - "ID.IM-02", - "ID.IM-04", - "RC.RP-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.1", - "12.10.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1", - "12.10.5" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "SEF-06", - "Description": "Define, implement and evaluate processes, procedures and technical measures supporting business processes to triage security-related events.", - "Name": "Event Triage Processes", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.4", - "27002: 16.1.4", - "27017: 16.1.4", - "27018: 16.1.4", - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27018: 16.1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.25" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-7", - "CA-7(3)", - "CA-7(4)", - "CA-7(5)", - "CA-7(6)", - "IR-4", - "IR-4(1)", - "IR-4(3)", - "IR-4(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.AE-2", - "DE.AE-4", - "RS.RP-1", - "RS.AN-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "RS.MA-02", - "RS.MA-03", - "RS.AN-03", - "DE.AE-02", - "DE.AE-04", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "RS.MI-02", - "RC.RP-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_advanced_or_enterprise_edition" - ] - }, - { - "Id": "SEF-08", - "Description": "Maintain points of contact for applicable regulation authorities, national and local law enforcement, and other legal jurisdictional authorities.", - "Name": "Points of Contact Maintenance", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "17.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 4.2", - "27001: A.6.1.3", - "27002: 6.1.3", - "27017: 6.1.3", - "27018: 6.1.3", - "27001: A.16.1.1", - "27002: 16.1.1", - "27001: A.18.1.1", - "27002: 18.1.1", - "27017: 18.1.1", - "27018: 18.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.5", - "27001: A.5.24", - "27002: 5.24 Incident management procedure (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IR-4", - "IR-4(8)", - "IR-6", - "IR-6(3)", - "IR-7", - "IR-7(2)", - "PM-21", - "PM-23", - "PM-26" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-2", - "RS.CO-3", - "RS.CO-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.RR-02", - "RS.CO-02", - "RS.CO-03" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "TVM-02", - "Description": "Establish, document, approve, communicate, apply, evaluate and maintain policies and procedures to protect against malware on managed assets. Review and update the policies and procedures at least annually.", - "Name": "Malware Protection Policy and Procedures", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC6.8" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-01", - "GRM-06", - "GRM-09" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "9.7", - "10.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.1.1", - "1.5.1", - "5.2.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS1.2", - "TS1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5", - "27002: 5", - "27001: A.12.2.1", - "27001: A.6.2.1", - "27002: 6.2.1 (h)", - "27001: A.6.2.2", - "27002: 6.2.2 (j)", - "27001: A.7.2.2", - "27002: 7.2.2 (d)", - "27001: A.10.1.1", - "27002: 10.1.1 (g)", - "27001: A.13.2.1", - "27002: 13.2.1 (b)", - "27001: A.15.1.2", - "27017: 15.1.2", - "27001: A.12.2.1", - "27002: 12.2.1 (a),(d)", - "27017: CLD.9.5.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5.1", - "27001: A.5.4", - "27001: A.5.7", - "27001: A.5.37", - "27001: A.8.7", - "27002: 5.7 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-3", - "RA-3(3)", - "RA-5", - "RA-5(3)", - "RA-5(5)", - "SI-3", - "SI-3(4)", - "SI-3(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "DE.CM-4", - "DE.CM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.PO-01", - "GV.PO-02", - "ID.IM-03", - "DE.CM-01", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.4", - "12.1", - "12.1.1", - "12.3.1", - "12.5.1", - "12.11" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.1.1", - "12.1.2", - "5.1.1", - "5.3.2.1" - ] - } - ] - } - ], - "Checks": [ - "ecs_instance_endpoint_protection_installed" - ] - }, - { - "Id": "TVM-03", - "Description": "Define, implement and evaluate processes, procedures and technical measures to enable both scheduled and emergency responses to vulnerability identifications, based on the identified risk.", - "Name": "Vulnerability Remediation Schedule", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC7.1", - "CC7.4" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "7.2", - "7.7", - "17.9" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1", - "TM2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.12.2.1", - "27001: A.12.6.1", - "27002: 12.6.1(c)(d)(j)", - "27018: 12.6.1(k)(i)" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.8.7", - "27001: A.8.8", - "27001: A.8.32", - "27002: 8.7", - "27002: 8.8", - "27002: 8.32" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-31", - "RA-3", - "RA-3(1)", - "RA-5", - "RA-5(2)-(4)", - "RA-5(6)", - "SI-3", - "SI-3(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "RS.AN-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01", - "ID.RA-06", - "ID.RA-08", - "PR.PS-02", - "PR.PS-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "6.1.a", - "6.1.b" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.1.1", - "6.3.1", - "6.3.2", - "6.3.3", - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "ecs_instance_latest_os_patches_applied" - ] - }, - { - "Id": "TVM-04", - "Description": "Define, implement and evaluate processes, procedures and technical measures to update detection tools, threat signatures, and indicators of compromise on a weekly, or more frequent basis.", - "Name": "Detection Updates", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "No mapping" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "10.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS1.3", - "TS1.4", - "TM1.3", - "TM1.4", - "IM1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.5.1.1", - "27002: 5.1.1 (h)", - "27001: A.12.6.1", - "27002: 12.6.1 (b),(c)" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.5.1", - "27001: A.8.8", - "27001: A.8.15", - "27001: A.8.16", - "27002: 5.1", - "27002: 5.37", - "27002: 8.8", - "27002: 8.15 (d)", - "27002: 8.16 (d,e)", - "27002: 8.31 2nd (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-7", - "CM-7(4)", - "RA-3", - "RA-3(3)", - "RA-5(2)", - "SA-10", - "SA-10(5)", - "SA-11", - "SA-11(2)", - "SI-2", - "SI-2(4)", - "SI-3", - "SI-3(4)", - "SI-4", - "SI-4(9)", - "SI-4(24)", - "SI-8", - "SI-8(2)", - "SI-8(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-02", - "ID.RA-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.2", - "5.2a", - "5.2b", - "5.2c" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "5.3.1" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_advanced_or_enterprise_edition", - "securitycenter_vulnerability_scan_enabled" - ] - }, - { - "Id": "TVM-05", - "Description": "Define, implement and evaluate processes, procedures and technical measures to identify updates for applications which use third party or open source libraries according to the organization's vulnerability management policy.", - "Name": "External Library Vulnerabilities", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC3.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "No mapping" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1", - "SD2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.12.6.2", - "27002: 12.6.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A 5.6", - "27001: A.8.19", - "27001: A.8.8", - "27001: A.8.28", - "27001: A.8.31", - "27002: 5.6 (c)", - "27001: 8.19", - "27001: 8.8", - "27001: 8.28", - "27001: 8.31" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-5", - "RA-5(3)", - "SA-11", - "SA-11(2)", - "SA-11(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01", - "ID.RA-03", - "PR.PS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "6.2", - "6.3.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "6.3.2", - "6.3.3" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_vulnerability_scan_enabled" - ] - }, - { - "Id": "TVM-07", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the detection of vulnerabilities on organizationally managed assets at least monthly.", - "Name": "Vulnerability Identification", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "7.1", - "7.5", - "7.6" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.5", - "5.2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.6", - "27001: A.12.6.1", - "27002: 12.6.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.8", - "27002: 8.8" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-5", - "RA-5(4)", - "RA-5(5)", - "SA-11", - "SA-11(5)", - "SA-15(5)", - "SC-7", - "SC-7(10)", - "SI-3(8)", - "SI-3(10)", - "SI-7", - "SI-7(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.RA-1", - "DE.CM-8", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "11.2", - "11.2.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "6.3.2", - "6.3.3", - "11.3.2", - "11.3.2.1" - ] - } - ] - } - ], - "Checks": [ - "securitycenter_vulnerability_scan_enabled", - "securitycenter_advanced_or_enterprise_edition" - ] - }, - { - "Id": "UEM-08", - "Description": "Protect information from unauthorized disclosure on managed endpoint devices with storage encryption.", - "Name": "Storage Encryption", - "Attributes": [ - { - "Section": "Universal Endpoint Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "MOS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.6" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "3.1.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "PA1.2", - "PA1.3", - "PA1.5", - "PA2.2", - "PM1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.11.2.7", - "27002: 11.2.7", - "27001: A.18.1.1", - "27017: 18.1.1", - "27001: A.12.3.1", - "27017: 12.3.1", - "27018: A.11.4", - "27018: A.11.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.1", - "27002: 8.1 (h)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-19(5)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.4", - "3.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.5.1", - "3.6" - ] - } - ] - } - ], - "Checks": [ - "ecs_attached_disk_encrypted", - "ecs_unattached_disk_encrypted" - ] - }, - { - "Id": "UEM-11", - "Description": "Configure managed endpoints with Data Loss Prevention (DLP) technologies and rules in accordance with a risk assessment.", - "Name": "Data Loss Prevention", - "Attributes": [ - { - "Section": "Universal Endpoint Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.13" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.5", - "PA2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.3", - "27002: 12.3", - "27001: A.8.3.1", - "27002: 8.3.1", - "27001: A.12.2", - "27002: 12.2", - "27001: A.18.1.3", - "27002: 18.1.3", - "27001: A.6.1.1", - "27017: 6.1.1", - "27018: 12.3.1", - "27018: 10.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.12", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-7", - "SC-7(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02", - "PR.DS-10", - "PR.PS-01", - "ID.AM-08", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A3.2.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "A3.2.6" - ] - } - ] - } - ], - "Checks": [] - } - ] -} 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 a64a421c8a..cea7ad1655 100644 --- a/prowler/compliance/aws/aws_foundational_security_best_practices_aws.json +++ b/prowler/compliance/aws/aws_foundational_security_best_practices_aws.json @@ -1863,7 +1863,9 @@ "Id": "ELB.4", "Name": "Application load balancers should be configured to drop HTTP headers", "Description": "This control evaluates AWS Application Load Balancers (ALB) to ensure they are configured to drop invalid HTTP headers. The control fails if the value of routing.http.drop_invalid_header_fields.enabled is set to false. By default, ALBs are not configured to drop invalid HTTP header values. Removing these header values prevents HTTP desync attacks.", - "Checks": [], + "Checks": [ + "elbv2_alb_drop_invalid_header_fields_enabled" + ], "Attributes": [ { "ItemId": "ELB.4", diff --git a/prowler/compliance/aws/csa_ccm_4.0_aws.json b/prowler/compliance/aws/csa_ccm_4.0_aws.json deleted file mode 100644 index 98d87112c9..0000000000 --- a/prowler/compliance/aws/csa_ccm_4.0_aws.json +++ /dev/null @@ -1,7617 +0,0 @@ -{ - "Framework": "CSA-CCM", - "Name": "CSA Cloud Controls Matrix (CCM) v4.0.13", - "Version": "4.0", - "Provider": "AWS", - "Description": "The Cloud Security Alliance (CSA) Cloud Controls Matrix (CCM) is a cybersecurity control framework for cloud computing, composed of 197 control objectives structured in 17 domains covering all key aspects of cloud technology. The CCM can be used as a tool for the systematic assessment of a cloud implementation, and provides guidance on which security controls should be implemented by which actor within the cloud supply chain.", - "Requirements": [ - { - "Id": "A&A-02", - "Description": "Conduct independent audit and assurance assessments according to relevant standards at least annually.", - "Name": "Independent Assessments", - "Attributes": [ - { - "Section": "Audit & Assurance", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC4.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AAC-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.5.2", - "5.2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "AS1.1", - "AS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.2.1", - "27002: 18.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.35", - "27001: A.5.36" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-2", - "CA-2(1)", - "CA-2(2)", - "CA-7", - "CA-7(1)" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-01" - ] - } - ] - } - ], - "Checks": [ - "securityhub_enabled" - ] - }, - { - "Id": "A&A-04", - "Description": "Verify compliance with all relevant standards, regulations, legal/contractual, and statutory requirements applicable to the audit.", - "Name": "Requirements Compliance", - "Attributes": [ - { - "Section": "Audit & Assurance", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC3.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-01", - "GRM-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "7.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "AS1.1", - "AS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.3.2", - "27001: A.18.2.2", - "27002: 18.2.2", - "27001: A.18.2.3", - "27002: 18.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.3.2", - "27001: A.5.31", - "27001: A.5.32", - "27001: A.5.33", - "27001: A.5.34", - "27001: A.5.36" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-1" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-3", - "DE.DP-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-01" - ] - } - ] - } - ], - "Checks": [ - "securityhub_enabled", - "config_recorder_all_regions_enabled" - ] - }, - { - "Id": "AIS-04", - "Description": "Define and implement a SDLC process for application design, development, deployment, and operation in accordance with security requirements defined by the organization.", - "Name": "Secure Application Design and Development", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AIS-01", - "AIS-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.4", - "5.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.1.1", - "27002: 14.1.1", - "27017: 14.1.1", - "27001: A.14.1.2", - "27002: 14.1.2", - "27017: 14.1.2", - "27001: A.14.2.1", - "27002: 14.2.1", - "27017: 14.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.8", - "27001: A.8.25", - "27001: A.8.26", - "27001: A.8.28" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-2", - "PL-8", - "PL-8(1)", - "SA-3", - "SA-3(1)", - "SA-4", - "SA-4(2)", - "SA-4(3)", - "SA-4(8)", - "SA-4(9)", - "SA-5", - "SA-8", - "SA-8(1)-(7)", - "SA-8(9)-(13)", - "SA-8(15)-(20)", - "SA-8(22)", - "SA-8(24)-(28)", - "SA-8(30)-(33)", - "SA-17", - "SA-17(1)-(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-6", - "PR.DS-7", - "PR.IP-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "PR.IR-01", - "PR.PS-01", - "PR.PS-02", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.1", - "6.2.3", - "6.5.2" - ] - } - ] - } - ], - "Checks": [ - "codebuild_project_source_repo_url_no_sensitive_credentials", - "codebuild_project_no_secrets_in_variables" - ] - }, - { - "Id": "AIS-05", - "Description": "Implement a testing strategy, including criteria for acceptance of new information systems, upgrades and new versions, which provides application security assurance and maintains compliance while enabling organizational speed of delivery goals. Automate when applicable and possible.", - "Name": "Automated Application Security Testing", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AIS-01", - "AIS-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.12", - "16.13" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD2.3", - "SD2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.2.8", - "27001: A.14.2.9", - "27001: A.12.1.2", - "27002: 12.1.2", - "27001: A.14.1.1", - "27002: 14.1.1", - "27001: A.14.2.2", - "27002: 14.2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.25", - "27001: A.8.29", - "27001: A.8.32", - "27002: 8.25 (e)", - "27002: 8.32 (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SA-11", - "SA-11(1)-(9)", - "SI-6", - "SI-6(2)", - "SI-6(3)", - "SI-10", - "SI-10(1)-(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.PT-3", - "PR.IP-12", - "DE.CM-8" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "ID.RA-01", - "PR.PS-01", - "PR.PS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A.3.2.2", - "A.3.2.2.1", - "6.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.4", - "6.4.1", - "6.4.2", - "6.5.1" - ] - } - ] - } - ], - "Checks": [ - "inspector2_is_enabled", - "ecr_repositories_scan_vulnerabilities_in_latest_image", - "ecr_registry_scan_images_on_push_enabled" - ] - }, - { - "Id": "AIS-07", - "Description": "Define and implement a process to remediate application security vulnerabilities, automating remediation when possible.", - "Name": "Application Vulnerability Remediation", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.1", - "CC7.4", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.2", - "16.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27001: A.12.6.1", - "27002: 12.6.1", - "27017: 12.6.1", - "27018: 12.6.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.26", - "27001: A.8.8", - "27002: 5.26 (j)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SI-2", - "SI-2(2)-(6)", - "SA-11", - "SA-11(2)", - "SA-15", - "SA-15(1)-(3)", - "SA-15(5)-(8)", - "SA-15(10)-(12)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.IP-12", - "DE.CM-8", - "RS.AN-5", - "RS.MI-3", - "PR.DS-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "ID.RA-01", - "ID.RA-06", - "ID.RA-08", - "PR.PS-02", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.2", - "6.5", - "6.5.1-10" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "11.3.1", - "11.3.1.1" - ] - } - ] - } - ], - "Checks": [ - "inspector2_is_enabled", - "inspector2_active_findings_exist" - ] - }, - { - "Id": "BCR-08", - "Description": "Periodically backup data stored in the cloud. Ensure the confidentiality, integrity and availability of the backup, and verify data restoration from backup for resiliency.", - "Name": "Backup", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "A1.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "11.1", - "11.2", - "11.3", - "11.4", - "11.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8", - "5.2.9" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.3", - "27017: 12.3", - "27018: 12.3.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.13", - "27001: A.5.23", - "27001: A.5.30", - "27002: 8.13", - "27002: 5.23 2nd (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-4", - "CP-4(4)", - "CP-6", - "CP-6(1)-(3)", - "CP-9", - "CP-9(1)", - "CP-9(2)", - "CP-10", - "CP-10(2)", - "CP-10(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-4", - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-11", - "RC.RP-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "9.5.1", - "12.10.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1", - "10.3.3" - ] - } - ] - } - ], - "Checks": [ - "backup_plans_exist", - "backup_vaults_exist", - "backup_vaults_encrypted", - "backup_recovery_point_encrypted", - "dynamodb_tables_pitr_enabled", - "dynamodb_table_protected_by_backup_plan", - "ec2_ebs_volume_snapshots_exists", - "ec2_ebs_volume_protected_by_backup_plan", - "efs_have_backup_enabled", - "rds_instance_backup_enabled", - "rds_instance_protected_by_backup_plan", - "rds_cluster_protected_by_backup_plan", - "redshift_cluster_automated_snapshot", - "s3_bucket_object_versioning", - "documentdb_cluster_backup_enabled", - "neptune_cluster_backup_enabled", - "elasticache_redis_cluster_backup_enabled", - "fsx_file_system_copy_tags_to_backups_enabled", - "lightsail_instance_automated_snapshots", - "dlm_ebs_snapshot_lifecycle_policy_exists" - ] - }, - { - "Id": "BCR-09", - "Description": "Establish, document, approve, communicate, apply, evaluate and maintain a disaster response plan to recover from natural and man-made disasters. Update the plan at least annually or upon significant changes.", - "Name": "Disaster Response Plan", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "CC3.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8", - "5.2.9", - "1.6.1", - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "BC1.4", - "BC2.1", - "BC2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.29", - "27001: A.5.30", - "27002: 5.29", - "27002: 5.30" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2(1)", - "CP-2(2)", - "CP-2(3)", - "CP-2(5)", - "CP-2(6)", - "CP-2(7)", - "CP-2(8)", - "PE-13", - "PE-13(1)", - "PE-13(2)", - "PE-13(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-9", - "PR.IP-10", - "RC.IM-1", - "RC.IM-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-04" - ] - } - ] - } - ], - "Checks": [ - "drs_job_exist", - "ssmincidents_enabled_with_plans" - ] - }, - { - "Id": "BCR-11", - "Description": "Supplement business-critical equipment with redundant equipment independently located at a reasonable minimum distance in accordance with applicable industry standards.", - "Name": "Equipment Redundancy", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "No", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "CC3.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-06" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "BC1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.20", - "27001: A.7.11", - "27001: A.8.14", - "27002: 5.20 (t)", - "27002: 8.14 (c)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2", - "CP-2(2)", - "CP-4(3)", - "CP-6", - "CP-6(1)", - "CP-7", - "CP-8", - "CP-8(1)-(3)", - "CP-9", - "CP-9(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.BE-4", - "ID.BE-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.OC-04", - "GV.OC-05", - "PR.IR-03" - ] - } - ] - } - ], - "Checks": [ - "rds_instance_multi_az", - "rds_cluster_multi_az", - "elbv2_is_in_multiple_az", - "elb_is_in_multiple_az", - "autoscaling_group_multiple_az", - "autoscaling_group_multiple_instance_types", - "ec2_ebs_volume_protected_by_backup_plan", - "elasticache_redis_cluster_multi_az_enabled", - "elasticache_redis_cluster_automatic_failover_enabled", - "opensearch_service_domains_fault_tolerant_data_nodes", - "opensearch_service_domains_fault_tolerant_master_nodes", - "dynamodb_accelerator_cluster_multi_az", - "documentdb_cluster_multi_az_enabled", - "neptune_cluster_multi_az", - "efs_multi_az_enabled", - "vpc_vpn_connection_tunnels_up", - "directconnect_connection_redundancy", - "directconnect_virtual_interface_redundancy", - "networkfirewall_multi_az", - "vpc_endpoint_multi_az_enabled", - "awslambda_function_vpc_multi_az", - "redshift_cluster_multi_az_enabled" - ] - }, - { - "Id": "CCC-04", - "Description": "Restrict the unauthorized addition, removal, update, and management of organization assets.", - "Name": "Unauthorized Change Protection", - "Attributes": [ - { - "Section": "Change Control and Configuration Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "CCC-04" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.1", - "1.3.4", - "5.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.4", - "SM2.6" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.1.4", - "27002: 12.1.4", - "27001: A.12.4.2", - "27002: 12.4.2", - "27001: A.14.2.2", - "27017: 14.2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.3", - "27001: A.8.4", - "27001: A.8.15", - "27001: A.8.31", - "27001: A.8.32" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-7", - "CA-7(4)", - "CM-3", - "CM-3(1)", - "CM-3(5)", - "CM-3(7)", - "CM-3(8)", - "CM-5", - "CM-5(1)", - "CM-5(4)", - "CM-5(5)", - "CM-6", - "CM-6(1)", - "CM-6(2)", - "CM-7", - "CM-7(1)", - "CM-7(4)", - "CM-7(5)", - "CM-7(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-1", - "ID.AM-2", - "ID.AM-4", - "PR.MA-1", - "PR.MA-2", - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-01", - "ID.AM-02", - "ID.AM-04", - "ID.AM-08", - "PR.PS-02", - "PR.PS-03", - "PR.PS-05", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.5.1", - "6.5.2" - ] - } - ] - } - ], - "Checks": [ - "cloudtrail_multi_region_enabled", - "cloudtrail_log_file_validation_enabled", - "s3_bucket_object_lock", - "cloudwatch_log_metric_filter_aws_organizations_changes", - "servicecatalog_portfolio_shared_within_organization_only" - ] - }, - { - "Id": "CCC-07", - "Description": "Implement detection measures with proactive notification in case of changes deviating from the established baseline.", - "Name": "Detection of Baseline Deviation", - "Attributes": [ - { - "Section": "Change Control and Configuration Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-01" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.5.1", - "1.5.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.2.2", - "27001: A.14.2.4", - "27001: A.12.4.1", - "27002: 12.4.1 (g)", - "27001: A.5.1.1", - "27017: 5.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.9", - "27001: A.8.15", - "27002: 8.9" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-6", - "CM-6(2)", - "SI-2", - "SI-2(2)-(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.MA-1", - "PR.IP-1", - "DE.DP-4", - "PR.IP-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-01", - "DE.CM-09", - "DE.AE-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4.5.3", - "6.4.5.4", - "11.5", - "11.5.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "11.5.2", - "11.6.1" - ] - } - ] - } - ], - "Checks": [ - "config_recorder_all_regions_enabled", - "guardduty_is_enabled", - "securityhub_enabled", - "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_network_acls_alarm_configured", - "cloudwatch_changes_to_network_gateways_alarm_configured", - "cloudwatch_changes_to_network_route_tables_alarm_configured", - "cloudwatch_log_metric_filter_authentication_failures", - "cloudwatch_log_metric_filter_aws_organizations_changes", - "cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk" - ] - }, - { - "Id": "CEK-03", - "Description": "Provide cryptographic protection to data at-rest and in-transit, using cryptographic libraries certified to approved standards.", - "Name": "Data Encryption", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-03", - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.6", - "3.1", - "3.11", - "11.3", - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.1", - "27001: A.18.1.2", - "27001: A.18.1.3", - "27001: A.18.1.4", - "27001: A.18.1.5", - "27001: A.10.1", - "27002: 10.1", - "27001: A.13.2.1", - "27002: 13.2.1", - "27001: A.18", - "27002: 18", - "27001: A.14.1.2", - "27002: 14.1.2", - "27001: A.14.1.3", - "27002 14.1.3 c)", - "27001 - A.10.1.1", - "27017 - 10.1.1", - "27001 - A.10.1.2", - "27017 - 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.8.24", - "27002: 8.24 Other Information (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-19", - "AC-19(5)", - "SC-8", - "SC-8(1)", - "SC-8(3)", - "SC-8(4)", - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-28", - "SC-28(1)-(3)", - "SI-4", - "SI-4(10)", - "SI-7", - "SI-7(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "Requirement 3", - "2.2.3", - "2.3", - "3.4", - "3.5.3", - "4.1", - "8.2.1", - "PCI Glossary - Strong Cryptography" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.7", - "3.5.1", - "4.2.1", - "4.2.1.2", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "ec2_ebs_volume_encryption", - "ec2_ebs_default_encryption", - "ec2_ebs_snapshots_encrypted", - "s3_bucket_default_encryption", - "s3_bucket_kms_encryption", - "s3_bucket_secure_transport_policy", - "rds_instance_storage_encrypted", - "rds_cluster_storage_encrypted", - "rds_instance_transport_encrypted", - "rds_snapshots_encrypted", - "efs_encryption_at_rest_enabled", - "dynamodb_tables_kms_cmk_encryption_enabled", - "dynamodb_accelerator_cluster_encryption_enabled", - "dynamodb_accelerator_cluster_in_transit_encryption_enabled", - "kinesis_stream_encrypted_at_rest", - "firehose_stream_encrypted_at_rest", - "sns_topics_kms_encryption_at_rest_enabled", - "sqs_queues_server_side_encryption_enabled", - "cloudtrail_kms_encryption_enabled", - "cloudwatch_log_group_kms_encryption_enabled", - "opensearch_service_domains_encryption_at_rest_enabled", - "opensearch_service_domains_node_to_node_encryption_enabled", - "opensearch_service_domains_https_communications_enforced", - "redshift_cluster_encrypted_at_rest", - "redshift_cluster_in_transit_encryption_enabled", - "documentdb_cluster_storage_encrypted", - "neptune_cluster_storage_encrypted", - "neptune_cluster_snapshot_encrypted", - "elasticache_redis_cluster_rest_encryption_enabled", - "elasticache_redis_cluster_in_transit_encryption_enabled", - "kafka_cluster_in_transit_encryption_enabled", - "kafka_cluster_encryption_at_rest_uses_cmk", - "kafka_connector_in_transit_encryption_enabled", - "dms_endpoint_ssl_enabled", - "dms_endpoint_redis_in_transit_encryption_enabled", - "elb_ssl_listeners", - "elbv2_ssl_listeners", - "elbv2_insecure_ssl_ciphers", - "elbv2_nlb_tls_termination_enabled", - "cloudfront_distributions_https_enabled", - "cloudfront_distributions_origin_traffic_encrypted", - "cloudfront_distributions_custom_ssl_certificate", - "transfer_server_in_transit_encryption_enabled", - "sagemaker_notebook_instance_encryption_enabled", - "sagemaker_training_jobs_volume_and_output_encryption_enabled", - "workspaces_volume_encryption_enabled", - "storagegateway_fileshare_encryption_enabled", - "backup_vaults_encrypted", - "backup_recovery_point_encrypted", - "athena_workgroup_encryption", - "glue_data_catalogs_connection_passwords_encryption_enabled", - "glue_data_catalogs_metadata_encryption_enabled", - "glue_etl_jobs_amazon_s3_encryption_enabled", - "glue_etl_jobs_cloudwatch_logs_encryption_enabled", - "glue_etl_jobs_job_bookmark_encryption_enabled", - "glue_development_endpoints_s3_encryption_enabled", - "glue_development_endpoints_cloudwatch_logs_encryption_enabled", - "glue_development_endpoints_job_bookmark_encryption_enabled", - "glue_ml_transform_encrypted_at_rest", - "bedrock_model_invocation_logs_encryption_enabled", - "bedrock_prompt_encrypted_with_cmk", - "codebuild_project_s3_logs_encrypted", - "codebuild_report_group_export_encrypted" - ] - }, - { - "Id": "CEK-04", - "Description": "Use encryption algorithms that are appropriate for data protection, considering the classification of data, associated risks, and usability of the encryption technology.", - "Name": "Encryption Algorithm", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.2", - "27001: 6.1.3", - "27001: A.8.2", - "27002: 8.2", - "27001: A.8.3", - "27001: A.10.1.1", - "27002: 10.1.1 (b)", - "27001: A.10.1.2", - "27002: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.2", - "27001: 6.1.3", - "27001: A.8.24", - "27001: A.5.12", - "27001: A.5.13", - "27002: 8.24 General (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2", - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02", - "ID.AM-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A2", - "Requirement 3", - "2.3", - "2.2.3", - "3.4", - "3.5.3", - "4.1", - "8.2.1", - "PCI Glossary - Strong Cryptography" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.7", - "3.5.1", - "4.2.1", - "4.2.1.2", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "acm_certificates_with_secure_key_algorithms", - "elb_insecure_ssl_ciphers", - "elbv2_insecure_ssl_ciphers", - "cloudfront_distributions_using_deprecated_ssl_protocols" - ] - }, - { - "Id": "CEK-08", - "Description": "CSPs must provide the capability for CSCs to manage their own data encryption keys.", - "Name": "CSC Key Management Capability", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2", - "SC2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1", - "27017: 10.1", - "27001: A.10.1.1", - "27017: 10.1.1", - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.23", - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-9", - "CP-9(8)", - "SA-9", - "SA-9(6)", - "SC-12", - "SC-12(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.SC-3", - "ID.AM-6", - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.SC-05" - ] - } - ] - } - ], - "Checks": [ - "kms_cmk_are_used", - "s3_bucket_kms_encryption", - "dynamodb_tables_kms_cmk_encryption_enabled", - "kms_key_not_publicly_accessible", - "kms_cmk_not_multi_region" - ] - }, - { - "Id": "CEK-10", - "Description": "Generate Cryptographic keys using industry accepted cryptographic libraries specifying the algorithm strength and the random number generator used.", - "Name": "Key Generation", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2", - "TS2.3", - "SY1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27002: 10.1.1 (e)", - "27017: 10.1.1", - "27001: A.10.1.2", - "27002: 10.1.2", - "27002: 10.1.2 (a)", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24", - "27002: 8.24 (d), Key management (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.2.3", - "3.6.1", - "PCI Glossary - Cryptographic Key Generation" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1", - "3.6.1.1", - "3.7.1" - ] - } - ] - } - ], - "Checks": [ - "kms_cmk_are_used" - ] - }, - { - "Id": "CEK-12", - "Description": "Rotate cryptographic keys in accordance with the calculated cryptoperiod, which includes provisions for considering the risk of information disclosure and legal and regulatory requirements.", - "Name": "Key Rotation", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27017: 10.1.1", - "27001: A.10.1.2", - "27002: 10.1.2 e)", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.31", - "27001: A.8.24", - "27002: 5.31 Cryptography", - "27002: 8.24 Key management (e,m)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "ID.GV-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05", - "GV.OC-03" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.7.4", - "3.7.5" - ] - } - ] - } - ], - "Checks": [ - "kms_cmk_rotation_enabled", - "iam_rotate_access_key_90_days", - "secretsmanager_automatic_rotation_enabled", - "secretsmanager_secret_rotated_periodically" - ] - }, - { - "Id": "CEK-14", - "Description": "Define, implement and evaluate processes, procedures and technical measures to destroy keys stored outside a secure environment and revoke keys stored in Hardware Security Modules (HSMs) when they are no longer needed, which include provisions for legal and regulatory requirements.", - "Name": "Key Destruction", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27017: 10.1.1", - "27017: 10.1.2", - "27001: A.10.1.2", - "27002: 10.1.2 (j)", - "27001: A.18.1.3", - "27002: 18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.31", - "27001: A.8.24", - "27002: 5.31 Cryptography", - "27002: 8.24 Key management (j,m)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.IP-6", - "ID.GV-3", - "PR.DS-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05", - "ID.AM-08", - "GV.OC-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.6.4", - "3.6.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.7.4", - "3.7.5" - ] - } - ] - } - ], - "Checks": [ - "kms_cmk_not_deleted_unintentionally" - ] - }, - { - "Id": "DCS-06", - "Description": "Catalogue and track all relevant physical and logical assets located at all of the CSP's sites within a secured system.", - "Name": "Assets Cataloguing and Tracking", - "Attributes": [ - { - "Section": "Datacenter Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DCS - 01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "1.1", - "2.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SM2.6" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.1.1", - "27002: 8.1.1", - "27017: 8.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.9" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-8", - "CM-8(1)", - "CM-8(2)", - "CM-8(4)", - "CM-8(7)", - "CM-8(8)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-1", - "ID.AM-2", - "ID.AM-4", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-01", - "ID.AM-02", - "ID.AM-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.4", - "9.7.1", - "9.9.1", - "9.9.1.a", - "9.9.1.b", - "9.9.1.c", - "12.3.3", - "12.3.4" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1.1", - "6.3.2", - "9.4.2", - "9.4.3", - "12.5.1" - ] - } - ] - } - ], - "Checks": [ - "config_recorder_all_regions_enabled", - "resourceexplorer2_indexes_found" - ] - }, - { - "Id": "DSP-02", - "Description": "Apply industry accepted methods for the secure disposal of data from storage media such that data is not recoverable by any forensic means.", - "Name": "Secure Disposal", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3", - "CC6.4", - "CC6.5", - "CC6.7", - "P4.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DSI-07" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.3.3", - "7.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.3.2", - "27002: 8.3.2", - "27001: A.11.2.7", - "27002: 11.2.7" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.7.10", - "27001: A.7.14", - "27001: A.8.10", - "27002: 7.10 (Secure reuse or disposal)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-22", - "SI-12", - "SI-12(3)", - "SI-18", - "SI-18(1)", - "SI-18(4)", - "SI-18(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.SC-10", - "PR.PS-02", - "PR.PS-03", - "ID.AM-08" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.1", - "9.8", - "9.8.1", - "9.8.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1", - "3.7.5", - "9.4.7" - ] - } - ] - } - ], - "Checks": [ - "s3_bucket_lifecycle_enabled", - "dlm_ebs_snapshot_lifecycle_policy_exists", - "ecr_repositories_lifecycle_policy_enabled" - ] - }, - { - "Id": "DSP-03", - "Description": "Create and maintain a data inventory, at least for any sensitive data and personal data.", - "Name": "Data Inventory", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1", - "1.3.2", - "1.3.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.1.1", - "27002: 8.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.9", - "27001: A.8.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-12", - "CM-12(1)", - "PM-5", - "PM-5(1)", - "SI-12", - "SI-12(1)", - "SI-19", - "SI-19(1)", - "SI-19(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1", - "9.4.5" - ] - } - ] - } - ], - "Checks": [ - "macie_is_enabled", - "macie_automated_sensitive_data_discovery_enabled", - "config_recorder_all_regions_enabled" - ] - }, - { - "Id": "DSP-04", - "Description": "Classify data according to its type and sensitivity level.", - "Name": "Data Classification", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "C1.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DSI-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.7" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1", - "1.3.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.2.1", - "27002: 8.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-16", - "AC-16(9)", - "PM-22", - "PM-23", - "PT-2", - "PT-2(1)", - "SI-18", - "SI-18(2)", - "SI-19", - "SI-19(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-05", - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "9.6.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "9.4.2", - "9.4.3" - ] - } - ] - } - ], - "Checks": [ - "macie_is_enabled", - "macie_automated_sensitive_data_discovery_enabled" - ] - }, - { - "Id": "DSP-07", - "Description": "Develop systems, products, and business practices based upon a principle of security by design and industry best practices.", - "Name": "Data Protection by Design and Default", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "PI1.2", - "PI1.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.3.1", - "5.3.2", - "5.3.3", - "5.3.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD2.2", - "IM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.1.1", - "27002:14.1.1", - "27001: A.14.2.5", - "27002:14.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.27", - "27001: A.8.28", - "27001: A.8.29", - "27002: 5.8 (Information security requirements a-i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-17", - "PM-24", - "PM-25", - "PT-2", - "PT-2(2)", - "SA-3", - "SA-4", - "SA-5", - "SA-8", - "SA-8(9)", - "SA-8(13)", - "SA-8(18)", - "SA-8(20)", - "SA-8(22)", - "SA-8(23)", - "SA-8(33)", - "SA-15", - "SA-15(12)", - "SC-3", - "SC-3(3)", - "SC-7", - "SC-7(24)", - "SC-8", - "SC-8(1)-(4)", - "SC-28", - "SC-28(1)", - "SI-12", - "SI-12(1)-(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.PT-3", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.1" - ] - } - ] - } - ], - "Checks": [ - "ec2_ebs_default_encryption", - "s3_account_level_public_access_blocks", - "s3_bucket_level_public_access_block", - "ec2_ebs_snapshot_account_block_public_access", - "rds_instance_no_public_access", - "rds_snapshots_public_access" - ] - }, - { - "Id": "DSP-10", - "Description": "Define, implement and evaluate processes, procedures and technical measures that ensure any transfer of personal or sensitive data is protected from unauthorized access and only processed within scope as permitted by the respective laws and regulations.", - "Name": "Sensitive Data Transfer", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-02", - "EKM-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.1", - "3.12", - "3.13" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "9.5.1", - "9.5.2", - "9.5.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.4", - "IM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.13.2.1", - "27002: 13.2.1", - "27001: A.8.3.3", - "27002: 8.3.3", - "27001: A.13.2.3", - "27002: 13.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.7.10" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-4", - "AC-4(23)-(25)", - "CA-3", - "CA-3(6)", - "CA-6", - "CA-6(1)", - "CA-6(2)", - "SC-4", - "SC-4(2)", - "SC-7", - "SC-7(10)", - "SC-7(24)", - "SC-8", - "SC-8(1)-(5)", - "SC-16", - "SC-16(1)-(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2", - "PR.DS-5", - "PR.PT-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02", - "PR.IR-01", - "ID.AM-03", - "GV.OC-03", - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "4.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "4.1.1", - "4.2.1", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "s3_bucket_secure_transport_policy", - "opensearch_service_domains_https_communications_enforced", - "redshift_cluster_in_transit_encryption_enabled", - "rds_instance_transport_encrypted", - "transfer_server_in_transit_encryption_enabled", - "kafka_cluster_mutual_tls_authentication_enabled" - ] - }, - { - "Id": "DSP-16", - "Description": "Data retention, archiving and deletion is managed in accordance with business requirements, applicable laws and regulations.", - "Name": "Data Retention and Deletion", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "C1.1", - "C1.2", - "CC3.1", - "P4.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-02", - "BCR-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.4", - "3.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.3.1", - "7.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.33", - "27001: A.8.10", - "27002: 5.33 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SI-12", - "SI-12(1)-(3)", - "SI-18", - "SI-18(1)", - "SI-18(4)", - "SI-18(5)", - "SI-19", - "SI-19(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-3", - "PR.IP-6", - "ID.GV-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "GV.OC-03", - "GV.SC-10", - "PR.DS-11" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1" - ] - } - ] - } - ], - "Checks": [ - "s3_bucket_lifecycle_enabled", - "cloudwatch_log_group_retention_policy_specific_days_enabled", - "kinesis_stream_data_retention_period", - "ecr_repositories_lifecycle_policy_enabled" - ] - }, - { - "Id": "DSP-17", - "Description": "Define and implement, processes, procedures and technical measures to protect sensitive data throughout it's lifecycle.", - "Name": "Sensitive Data Protection", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSC-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.1", - "CC6.1", - "CC6.3", - "CC6.7", - "CC8.1", - "C1.1", - "P2.0", - "P3.0", - "P4.0", - "P5.0", - "P6.0" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.1", - "3.1", - "3.14" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.3.3", - "9.1.1", - "9.2.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3", - "27002: 18.1.3", - "27001:A.18.1.4", - "27002:18.1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.11", - "27001: A.8.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-2", - "PM-22", - "PM-24", - "PT-7", - "PT-7(1)", - "PT-7(2)", - "PT-8", - "SC-8", - "SC-8(1)-(5)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2", - "PR.DS-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02", - "PR.DS-10" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.0 (including all subsections)", - "4.0 (including all subsections)" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.1.1", - "4.1.1" - ] - } - ] - } - ], - "Checks": [ - "s3_account_level_public_access_blocks", - "s3_bucket_public_access", - "s3_bucket_policy_public_write_access", - "ec2_ebs_public_snapshot", - "rds_snapshots_public_access", - "rds_instance_no_public_access", - "ec2_ebs_volume_encryption", - "s3_bucket_default_encryption", - "rds_instance_storage_encrypted", - "secretsmanager_not_publicly_accessible", - "macie_is_enabled" - ] - }, - { - "Id": "GRC-05", - "Description": "Develop and implement an Information Security Program, which includes programs for all the relevant domains of the CCM.", - "Name": "Information Security Program", - "Attributes": [ - { - "Section": "Governance, Risk and Compliance", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "14.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SG2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 4.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-1", - "PM-3", - "PM-14", - "PL-2", - "PM-18", - "PM-31" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.4.1", - "A.3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.4.1", - "A3.1.1" - ] - } - ] - } - ], - "Checks": [ - "securityhub_enabled", - "guardduty_is_enabled" - ] - }, - { - "Id": "IAM-02", - "Description": "Establish, document, approve, communicate, implement, apply, evaluate and maintain strong password policies and procedures. Review and update the policies and procedures at least annually.", - "Name": "Strong Password Policy and Procedures", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-12", - "GRM-06", - "GRM-09" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.1.1", - "1.5.1", - "4.1.2", - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1", - "SA1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5", - "27002: 5", - "27001: A.9.4.3", - "27002: 9.4.3", - "27017: 9.4.3", - "27018: 9.4.3", - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27001: A.7.2.2", - "27002: 7.2.2", - "27001: A.9.2.6", - "27002: 9.2.6", - "27001: A.9.2.3", - "27002: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5.1", - "27001: A.5.4", - "27001: A.5.17", - "27001: A.6.3", - "27001: A.8.5", - "27001: A.5.37" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(3)", - "AC-2(11)", - "AC-3", - "AC-3(3)", - "AC-12", - "AC-12(1)", - "IA-2", - "IA-2(10)", - "IA-5", - "IA-5(1)", - "IA-5(18)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "PR.AC-1", - "PR.AC-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.PO-01", - "GV.PO-02", - "ID.IM-03", - "PR.AA-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.4", - "12.1", - "12.1.1", - "12.11" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.1.1", - "8.3.8" - ] - } - ] - } - ], - "Checks": [ - "iam_password_policy_minimum_length_14", - "iam_password_policy_lowercase", - "iam_password_policy_uppercase", - "iam_password_policy_number", - "iam_password_policy_symbol", - "iam_password_policy_reuse_24", - "iam_password_policy_expires_passwords_within_90_days_or_less", - "cognito_user_pool_password_policy_minimum_length_14", - "cognito_user_pool_password_policy_lowercase", - "cognito_user_pool_password_policy_uppercase", - "cognito_user_pool_password_policy_number", - "cognito_user_pool_password_policy_symbol" - ] - }, - { - "Id": "IAM-03", - "Description": "Manage, store, and review the information of system identities, and level of access.", - "Name": "Identity Inventory", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-04", - "IAM-08", - "IAM-10" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1", - "5.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.2 (c)", - "27001: A.8.1.1", - "27002: 8.1.1", - "27001: A.9.4.1", - "27002: 9.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.2 (c)", - "27001: A.5.15", - "27001: A.5.16", - "27001: A.5.18", - "27001: A.7.4", - "27001: A.8.15", - "27001: A.8.2", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-10", - "AU-10(1)", - "AU-10(2)", - "AU-16", - "AU-16(1)", - "IA-4", - "IA-4(8)", - "IA-4(9)", - "IA-5", - "IA-5(5)", - "IA-8", - "IA-8(4)", - "PM-5(1)", - "SA-8", - "SA-8(22)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-04", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.4.a" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.5", - "7.2.5.1" - ] - } - ] - } - ], - "Checks": [ - "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_two_active_access_key" - ] - }, - { - "Id": "IAM-04", - "Description": "Employ the separation of duties principle when implementing information system access.", - "Name": "Separation of Duties", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC1.3", - "CC5.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.2.2", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.6.1.2", - "27002: 6.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.18", - "27001: A.5.3", - "27001: A.8.2" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(3)", - "AC-2(11)", - "AC-6", - "AC-6(1)-(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4", - "6.4.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.5.3", - "6.5.4", - "7.2.1", - "7.2.2" - ] - } - ] - } - ], - "Checks": [ - "iam_policy_attached_only_to_group_or_roles", - "iam_securityaudit_role_created", - "iam_support_role_created" - ] - }, - { - "Id": "IAM-05", - "Description": "Employ the least privilege principle when implementing information system access.", - "Name": "Least Privilege", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-06", - "IVS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.1.1", - "27002: 9.1.1", - "27001: A.9.1.2", - "27002: 9.1.2", - "27001: A.9.2.3", - "27002: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.8.2", - "27002: 5.15 (Other information 2nd (a))" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(4)", - "IA-12", - "IA-12(2)", - "IA-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "7.1", - "7.1.1", - "7.1.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "7.2.2", - "7.2.5", - "7.2.6" - ] - } - ] - } - ], - "Checks": [ - "iam_inline_policy_no_wildcard_marketplace_subscribe", - "iam_policy_no_wildcard_marketplace_subscribe", - "iam_aws_attached_policy_no_administrative_privileges", - "iam_customer_attached_policy_no_administrative_privileges", - "iam_inline_policy_no_administrative_privileges", - "iam_customer_unattached_policy_no_administrative_privileges", - "iam_policy_allows_privilege_escalation", - "iam_inline_policy_allows_privilege_escalation", - "iam_no_custom_policy_permissive_role_assumption", - "iam_role_administratoraccess_policy", - "iam_user_administrator_access_policy", - "iam_group_administrator_access_policy", - "iam_administrator_access_with_mfa" - ] - }, - { - "Id": "IAM-07", - "Description": "De-provision or respectively modify access of movers / leavers or system identity changes in a timely manner in order to effectively adopt and communicate identity and access management policies.", - "Name": "User Access Changes and Revocation", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.3", - "6.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.18" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(1)", - "AC-2(2)", - "AC-2(6)", - "AC-2(8)", - "AC-3", - "AC-3(8)", - "AC-6", - "AC-6(7)", - "AU-10", - "AU-10(4)", - "AU-16", - "AU-16(1)", - "CM-7", - "CM-7(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4", - "PR.IP-11" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.RR-04", - "GV.SC-10", - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1.2", - "8.1.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.2.5", - "8.2.6" - ] - } - ] - } - ], - "Checks": [ - "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_no_setup_initial_access_key" - ] - }, - { - "Id": "IAM-08", - "Description": "Review and revalidate user access for least privilege and separation of duties with a frequency that is commensurate with organizational risk tolerance.", - "Name": "User Access Review", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-10" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.5", - "27001: A.9.2.6", - "27001: A.9.4.1", - "27017: 9.4.1", - "27001: A.6.1.2", - "27001: A 9.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.3", - "27001: A.5.18", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(4)", - "AC-6(8)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.5.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.5.1", - "7.2.5", - "7.2.4" - ] - } - ] - } - ], - "Checks": [ - "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_rotate_access_key_90_days", - "secretsmanager_secret_unused" - ] - }, - { - "Id": "IAM-09", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the segregation of privileged access roles such that administrative access to data, encryption and key management capabilities and logging capabilities are distinct and separated.", - "Name": "Segregation of Privileged Access Roles", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.1", - "CC6.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.3", - "27002: 9.2.3", - "27017: 9.2.3", - "27018: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.2", - "27001: A.8.18", - "27002: 8.2 (j)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-3(7)", - "AC-6(4)", - "AC-6(8)", - "IA-5", - "IA-5(6)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.3", - "3.5.2", - "7.1.2", - "7.1.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1", - "3.7.6", - "6.5.3", - "6.5.4", - "7.2.1", - "7.2.2", - "10.3.1" - ] - } - ] - } - ], - "Checks": [ - "iam_policy_attached_only_to_group_or_roles", - "iam_role_administratoraccess_policy", - "iam_avoid_root_usage", - "iam_no_root_access_key" - ] - }, - { - "Id": "IAM-10", - "Description": "Define and implement an access process to ensure privileged access roles and rights are granted for a time limited period, and implement procedures to prevent the culmination of segregated privileged access.", - "Name": "Management of Privileged Access Roles", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1", - "6.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.3", - "27002: 9.2.3", - "27017: 9.2.3", - "27018: 9.2.3", - "27001: A.9.4.4", - "27002: 9.4.4", - "27017: 9.4.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.2", - "27001: A.8.18", - "27002: 8.2 (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(7)", - "AC-3", - "AC-3(4)", - "AC-3(11)", - "AC-3(13)", - "AC-3(14)", - "AC-6", - "AC-6(4)", - "AC-6(5)", - "AC-6(8)", - "AC-12", - "AC-12(3)", - "AC-17", - "AC-17(4)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "7.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "7.2.2" - ] - } - ] - } - ], - "Checks": [ - "iam_avoid_root_usage", - "iam_no_root_access_key", - "iam_role_cross_account_readonlyaccess_policy", - "iam_role_cross_service_confused_deputy_prevention", - "iam_inline_policy_allows_privilege_escalation", - "iam_policy_allows_privilege_escalation" - ] - }, - { - "Id": "IAM-12", - "Description": "Define, implement and evaluate processes, procedures and technical measures to ensure the logging infrastructure is read-only for all with write access, including privileged access roles, and that the ability to disable it is controlled through a procedure that ensures the segregation of duties and break glass procedures.", - "Name": "Safeguard Logs Integrity", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.3" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1", - "27018: 12.4.1", - "27001: A.12.4.2", - "27002: 12.4.2", - "27017: 12.4.2", - "27018: 12.4.2", - "27001: A.12.4.3", - "27002: 12.4.3", - "27017: 12.4.3", - "27018: 12.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15", - "27001: A.8.18", - "27002: 8.15 Protection of Logs" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(11)", - "AC-2(12)", - "IA-8", - "IA-8(4)", - "SA-8", - "SA-8(22)", - "SC-34", - "SC-34(1)", - "SC-34(2)", - "SC-36", - "SI-4", - "SI-4(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4" - ] - } - ] - } - ], - "Checks": [ - "cloudtrail_log_file_validation_enabled", - "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", - "cloudtrail_logs_s3_bucket_access_logging_enabled", - "cloudtrail_kms_encryption_enabled", - "cloudtrail_bucket_requires_mfa_delete" - ] - }, - { - "Id": "IAM-13", - "Description": "Define, implement and evaluate processes, procedures and technical measures that ensure users are identifiable through unique IDs or which can associate individuals to the usage of user IDs.", - "Name": "Uniquely Identifiable Users", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.1", - "27002: 9.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.16" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-3", - "AC-3(14)", - "AC-24", - "AC-24(2)", - "AU-10", - "AU-10(1)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(12)", - "IA-4", - "IA-4(1)", - "SA-8", - "SA-8(22)", - "SC-23", - "SC-23(3)", - "SC-40(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1", - "8.2", - "8.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.2.1", - "8.2.2", - "8.2.4" - ] - } - ] - } - ], - "Checks": [ - "iam_user_mfa_enabled_console_access", - "iam_check_saml_providers_sts" - ] - }, - { - "Id": "IAM-14", - "Description": "Define, implement and evaluate processes, procedures and technical measures for authenticating access to systems, application and data assets, including multifactor authentication for at least privileged user and sensitive data access. Adopt digital certificates or alternatives which achieve an equivalent level of security for system identities.", - "Name": "Strong Authentication", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.3", - "6.5", - "12.5", - "12.7" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3", - "SA1.4", - "SA1.8" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.1.2", - "27002: 9.1.2", - "27017: 9.1.2", - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27001: A.9.4.2", - "27002: 9.4.2", - "27017: 9.4.2", - "27018: 9.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.17", - "27001: A.8.5", - "27001: A.8.24", - "27002: 8.5", - "27002: 8.24 other information (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(5)", - "AC-7", - "AC-7(4)", - "AU-10", - "AU-10(2)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(8)", - "IA-2(12)", - "IA-3", - "IA-3(1)", - "IA-5", - "IA-5(2)", - "IA-5(7)", - "IA-5(9)", - "IA-5(10)", - "IA-5(12)", - "IA-5(14)-(16)", - "IA-8", - "IA-8(1)", - "IA-8(6)", - "SC-23", - "SC-23(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6", - "PR.AC-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1.2", - "8.1.3", - "8.1.6", - "8.2", - "8.3", - "8.3.2", - "12.3.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "8.3.1", - "8.3.2", - "8.4.1", - "8.4.2", - "8.4.3" - ] - } - ] - } - ], - "Checks": [ - "iam_root_mfa_enabled", - "iam_root_hardware_mfa_enabled", - "iam_user_mfa_enabled_console_access", - "iam_user_hardware_mfa_enabled", - "cognito_user_pool_mfa_enabled" - ] - }, - { - "Id": "IAM-15", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the secure management of passwords.", - "Name": "Passwords Management", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27018: 9.2.4", - "27001: A.9.3.1", - "27002: 9.3.1", - "27017: 9.3.1", - "27018: 9.3.1", - "27001: A.9.4.3", - "27002: 9.4.3", - "27017: 9.4.3", - "27018: 9.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.17" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IA-4", - "IA-4(8)", - "IA-5", - "IA-5(1)", - "IA-5(8)", - "IA-5(18)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.2", - "8.2.1-6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.2", - "2.3.1", - "8.3.5", - "8.3.6", - "8.3.7", - "8.3.8", - "8.3.9", - "8.3.10", - "8.3.10.1", - "8.6.2" - ] - } - ] - } - ], - "Checks": [ - "iam_password_policy_minimum_length_14", - "iam_password_policy_reuse_24", - "iam_password_policy_expires_passwords_within_90_days_or_less", - "cognito_user_pool_password_policy_minimum_length_14", - "cognito_user_pool_temporary_password_expiration" - ] - }, - { - "Id": "IAM-16", - "Description": "Define, implement and evaluate processes, procedures and technical measures to verify access to data and system functions is authorized.", - "Name": "Authorization Mechanisms", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3", - "SA1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.5", - "27002: 9.2.5", - "27017: 9.2.5", - "27018: 9.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.18" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-3", - "AC-3(5)", - "AC-4", - "AC-4(17)", - "AC-4(21)", - "AC-4(22)", - "AC-6", - "AC-6(8)", - "AC-6(9)", - "AC-12", - "AC-12(1)", - "AC-20", - "AC-20(1)", - "AU-10", - "AU-10(1)", - "AU-10(2)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(12)", - "IA-3", - "IA-3(1)", - "IA-5(1)", - "IA-5(2)", - "IA-5(5)", - "IA-5(8)", - "IA-5(10)", - "IA-5(12)", - "IA-8", - "IA-8(1)", - "IA-8(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4", - "PR.AC-6", - "PR.AC-7", - "PR.PT-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-03", - "PR.AA-04", - "PR.AA-05", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.3", - "7.1.4" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.4", - "7.2.3", - "7.2.5.1" - ] - } - ] - } - ], - "Checks": [ - "iam_aws_attached_policy_no_administrative_privileges", - "iam_customer_attached_policy_no_administrative_privileges", - "iam_inline_policy_no_administrative_privileges", - "apigateway_restapi_authorizers_enabled", - "apigatewayv2_api_authorizers_enabled", - "awslambda_function_not_publicly_accessible", - "awslambda_function_url_public", - "cognito_user_pool_waf_acl_attached" - ] - }, - { - "Id": "IPY-03", - "Description": "Implement cryptographically secure and standardized network protocols for the management, import and export of data.", - "Name": "Secure Interoperability and Portability Management", - "Attributes": [ - { - "Section": "Interoperability & Portability", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IPY-04" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY1.1", - "SY1.2", - "NC1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1", - "27001: A.15.1.1", - "27002: 15.1.1", - "27017: 15.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.19", - "27001: A.5.23", - "27001: A.5.31", - "27001: A.5.32", - "27001: A.5.33", - "27001: A.5.34" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PT-2", - "PT-2(2)", - "SA-4", - "SC-16", - "SC-16(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.2.1", - "1.2.5", - "1.2.6", - "2.2.4", - "2.2.5", - "2.2.7", - "4.2.1" - ] - } - ] - } - ], - "Checks": [ - "s3_bucket_secure_transport_policy" - ] - }, - { - "Id": "IVS-02", - "Description": "Plan and monitor the availability, quality, and adequate capacity of resources in order to deliver the required system performance as determined by the business.", - "Name": "Capacity and Resource Planning", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "No", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-04" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.3", - "27001: 6.1", - "27001: 9.1", - "27001: A.12.1.3", - "27002: 12.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.3 (b)", - "27001: 6.1", - "27001: 9.1", - "27001: A.8.6", - "27001: A.8.14" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2", - "CP-2(2)", - "SC-5", - "SC-5(2)", - "SC-4", - "SI-4" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-4", - "ID.BE-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.IR-04", - "GV.OC-04" - ] - } - ] - } - ], - "Checks": [ - "autoscaling_group_multiple_az", - "autoscaling_group_multiple_instance_types" - ] - }, - { - "Id": "IVS-03", - "Description": "Monitor, encrypt and restrict communications between environments to only authenticated and authorized connections, as justified by the business. Review these configurations at least annually, and support them by a documented justification of all allowed services, protocols, ports, and compensating controls.", - "Name": "Network Security", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-06" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.8", - "3.1", - "12.2", - "13.6", - "13.9" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "NC1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.13.1.1", - "27002: 13.1.1", - "27001: A.13.1.2", - "27002: 13.1.2", - "27001: A.13.1.3", - "27002: 13.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.5.15", - "27001: A.5.37", - "27001: A.8.5", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.21", - "27001: A.8.22", - "27001: A.8.24", - "27002: A.5.15 2nd c)", - "27002: 8.20", - "27002: 8.21", - "27002: 8.22", - "27002: 8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-1", - "SC-4", - "SC-7", - "SC-7(4)", - "SC-7(5)", - "SC-7(8)", - "SC-7(9)", - "SC-7(11)", - "SC-8", - "SC-8(1)", - "SC-11", - "SC-12", - "SC-16", - "SC-23", - "SC-29", - "SC-29(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-5", - "PR.AC-7", - "PR.PT-4", - "DE.CM-1", - "DE.CM-7", - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.IR-01", - "PR.AA-03", - "PR.AA-05", - "DE.CM-01", - "PR.DS-02", - "ID.AM-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "1.1.6", - "1.2", - "1.2.3", - "2.2", - "4.1.1", - "10.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.2.5", - "1.2.6", - "1.2.7", - "1.4.2", - "2.2.4", - "2.2.5", - "2.2.7", - "4.2.1", - "10.1.1" - ] - } - ] - } - ], - "Checks": [ - "vpc_flow_logs_enabled", - "ec2_securitygroup_default_restrict_traffic", - "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_tcp_port_22", - "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_3389", - "ec2_securitygroup_allow_ingress_from_internet_to_high_risk_tcp_ports", - "ec2_networkacl_allow_ingress_any_port", - "ec2_networkacl_allow_ingress_tcp_port_22", - "ec2_networkacl_allow_ingress_tcp_port_3389", - "ec2_securitygroup_allow_wide_open_public_ipv4", - "vpc_peering_routing_tables_with_least_privilege", - "vpc_subnet_no_public_ip_by_default" - ] - }, - { - "Id": "IVS-04", - "Description": "Harden host and guest OS, hypervisor or infrastructure control plane according to their respective best practices, and supported by technical controls, as part of a security baseline.", - "Name": "OS Hardening and Base Controls", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.8", - "CC7.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-07", - "IVS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "4.1", - "4.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3", - "5.2.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY1.1", - "SY1.3", - "SY1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.14.2.2", - "27002: 14.2.2", - "27001: A.14.2.3", - "27001 A.14.2.4", - "27018: 12.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.5.37", - "27001: A.8.5", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.22", - "27001: A.8.24", - "27002: 8.20", - "27002: 8.22", - "27002: 8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-6", - "CM-6(1)", - "SC-29", - "SC-29(1)", - "SC-2", - "SC-7", - "SC-7(12)", - "SC-30", - "SC-34", - "SC-35", - "SC-39", - "SC-44" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-1", - "PR.PT-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.1" - ] - } - ] - } - ], - "Checks": [ - "ec2_instance_imdsv2_enabled", - "ec2_instance_account_imdsv2_enabled", - "ec2_launch_template_imdsv2_required", - "ec2_instance_managed_by_ssm", - "ssm_managed_compliant_patching" - ] - }, - { - "Id": "IVS-06", - "Description": "Design, develop, deploy and configure applications and infrastructures such that CSP and CSC (tenant) user access and intra-tenant access is appropriately segmented and segregated, monitored and restricted from other tenants.", - "Name": "Segmentation and Segregation", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-09" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.3.4", - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SC2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.1", - "27001: A.13.1.3", - "27002: 13.1.3", - "27017: 13.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.1", - "27001: A.5.15", - "27001: A.5.20", - "27001: A.8.3", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.22", - "27002: 5.15 (b)", - "27002: 8.3 (b)", - "27002: 8.16 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-3", - "SC-7", - "SC-7(20)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.AC-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.IR-01", - "PR.PS-01", - "PR.PS-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.6", - "8.3.1", - "10.8", - "11.3", - "A3.2.1", - "A3.3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "A1.1.1", - "A1.1.2", - "A1.1.3" - ] - } - ] - } - ], - "Checks": [ - "ec2_securitygroup_default_restrict_traffic", - "vpc_subnet_separate_private_public", - "vpc_peering_routing_tables_with_least_privilege", - "ec2_instance_public_ip", - "awslambda_function_inside_vpc", - "sagemaker_notebook_instance_vpc_settings_configured", - "sagemaker_models_vpc_settings_configured", - "sagemaker_training_jobs_vpc_settings_configured" - ] - }, - { - "Id": "IVS-07", - "Description": "Use secure and encrypted communication channels when migrating servers, services, applications, or data to cloud environments. Such channels must include only up-to-date and approved protocols.", - "Name": "Migration to Cloud Environments", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-10" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.4", - "IM1.4", - "NC1.4", - "SC2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.13.1.1", - "27002: 13.1.1", - "27017: 13.1.1", - "27018: 13.1.1", - "27001: A.13.1.2", - "27002: 13.1.2", - "27017: 13.1.2", - "27018: 13.1.2", - "27001: A.13.1.3", - "27002: 13.1.3", - "27017: 13.1.3", - "27018: 13.1.3", - "27001: A.13.2.1", - "27002: 13.2.1", - "27017: 13.2.1", - "27018: 13.2.1", - "27001: A.13.2.2", - "27002: 13.2.2", - "27017: 13.2.2", - "27018: 13.2.2", - "27001: A.13.2.3", - "27002: 13.2.3", - "27017: 13.2.3", - "27018: 13.2.3", - "27001: A.13.2.4", - "27002: 13.2.4", - "27017: 13.2.4", - "27018: 13.2.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.8.20", - "27001: A.8.24", - "27002: 8.20 (e)", - "27002: 8.24 Guidance (b,f), other information (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-17", - "AC-20", - "SC-7", - "SC-7(28)", - "SC-8", - "SC-8(1)", - "SC-12", - "SC-23", - "SC-29", - "SI-7", - "SI-7(1)-(3)", - "SI-7(5)-(10)", - "SI-7(12)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2", - "PR.PT-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "4.2.1" - ] - } - ] - } - ], - "Checks": [ - "dms_endpoint_ssl_enabled" - ] - }, - { - "Id": "IVS-09", - "Description": "Define, implement and evaluate processes, procedures and defense-in-depth techniques for protection, detection, and timely response to network-based attacks.", - "Name": "Network Defense", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.6", - "CC6.8", - "CC7.1", - "CC7.2", - "CC7.5" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-13" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "13.3", - "13.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.3", - "5.2.4", - "5.2.5", - "5.2.7", - "5.3.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "NC1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1", - "27001: 6.2", - "27001: A.14.1.2", - "27002: 14.1.2", - "27017: 14.1.2", - "27001: A.11.1.4", - "27002: 11.1.4", - "27017: 11.1.4", - "27018: 16.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1", - "27001: 6.2", - "27001: A.5.24", - "27001: A.5.26", - "27001: A.8.8", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.21", - "27001: A.8.22", - "27001: A.8.26", - "27002: 8.8 (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-8", - "PL-8(1)", - "SC-5", - "SC-5(1)", - "SC-5(3)", - "SC-7", - "SC-7(13)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.DP-1", - "DE.CM-1", - "DE.CM-7", - "PR.AC-5", - "RS.MI-2", - "PR.DS-2", - "RS.RP-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-03", - "DE.CM-01", - "PR.IR-01", - "RS.MA-01", - "RS.MI-01", - "RS.MI-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.6", - "1.1", - "1.2", - "1.3", - "1.5", - "12.10.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.1.1", - "1.3.1", - "1.3.2", - "1.3.3", - "1.4.1", - "1.4.2", - "1.4.3", - "1.4.4", - "1.4.5", - "1.5.1", - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "networkfirewall_in_all_vpc", - "networkfirewall_logging_enabled", - "networkfirewall_policy_rule_group_associated", - "wafv2_webacl_with_rules", - "wafv2_webacl_logging_enabled", - "elbv2_waf_acl_attached", - "cloudfront_distributions_using_waf", - "guardduty_is_enabled", - "shield_advanced_protection_in_cloudfront_distributions", - "shield_advanced_protection_in_internet_facing_load_balancers" - ] - }, - { - "Id": "LOG-02", - "Description": "Define, implement and evaluate processes, procedures and technical measures to ensure the security and retention of audit logs.", - "Name": "Audit Logs Protection", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.1", - "8.9", - "8.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "3.1.3", - "5.1.2", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3", - "27002: 18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.28", - "27001: A.5.33", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-4", - "AU-11" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.IP-4", - "PR.IP-6", - "PR.PT-1", - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.DS-01", - "PR.DS-02", - "ID.AM-08", - "PR.DS-11", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5", - "10.7" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4", - "10.5.1" - ] - } - ] - } - ], - "Checks": [ - "cloudtrail_log_file_validation_enabled", - "cloudtrail_kms_encryption_enabled", - "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", - "cloudtrail_logs_s3_bucket_access_logging_enabled", - "cloudtrail_bucket_requires_mfa_delete", - "cloudwatch_log_group_kms_encryption_enabled", - "cloudwatch_log_group_not_publicly_accessible", - "s3_bucket_object_lock" - ] - }, - { - "Id": "LOG-03", - "Description": "Identify and monitor security-related events within applications and the underlying infrastructure. Define and implement a system to generate alerts to responsible stakeholders based on such events and corresponding metrics.", - "Name": "Security Monitoring and Alerting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-03", - "SEF-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4", - "5.2.7", - "1.6.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2", - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.28", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-5", - "AU-5(2)", - "AU-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.AE-2", - "DE.AE-3", - "DE.AE-5", - "DE.CM-1", - "DE.CM-2", - "DE.CM-3", - "DE.CM-4", - "DE.CM-5", - "DE.CM-6", - "DE.CM-7", - "DE.DP-1", - "DE.DP-4", - "DE.AE-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.AE-02", - "DE.AE-03", - "DE.AE-04", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1", - "10.2.2", - "10.4.1.1", - "10.4.2.1", - "10.4.3" - ] - } - ] - } - ], - "Checks": [ - "guardduty_is_enabled", - "securityhub_enabled", - "cloudwatch_alarm_actions_enabled", - "cloudwatch_alarm_actions_alarm_state_configured", - "cloudwatch_log_metric_filter_unauthorized_api_calls", - "cloudwatch_log_metric_filter_root_usage", - "cloudwatch_log_metric_filter_sign_in_without_mfa" - ] - }, - { - "Id": "LOG-04", - "Description": "Restrict audit logs access to authorized personnel and maintain records that provide unique access accountability.", - "Name": "Audit Logs Access and Accountability", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.14" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "3.1.1", - "4.1.2", - "4.1.3", - "4.2.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.2", - "27001: A.12.4.1", - "27002: 12.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.33", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(4)", - "AU-9(6)", - "AU-10" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.1", - "10.2.1", - "10.2.3", - "10.5.1", - "10.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1.3", - "10.3.1" - ] - } - ] - } - ], - "Checks": [ - "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", - "cloudwatch_log_group_not_publicly_accessible" - ] - }, - { - "Id": "LOG-05", - "Description": "Monitor security audit logs to detect activity outside of typical or expected patterns. Establish and follow a defined process to review and take appropriate and timely actions on detected anomalies.", - "Name": "Audit Logs Monitoring and Response", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.8", - "8.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.1", - "1.6.2", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.3", - "27002: 12.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15", - "27001: A.8.16" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-6", - "AU-6(1)", - "AU-6(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-3", - "PR.PT-1", - "RS.AN-1", - "RS.CO-1.", - "DE.AE-1", - "DE.AE-5", - "DE.DP-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-03", - "PR.PS-04", - "DE.AE-02", - "DE.AE-03", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.6", - "10.6.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.4.1.1", - "10.4.2.1" - ] - } - ] - } - ], - "Checks": [ - "cloudwatch_log_metric_filter_unauthorized_api_calls", - "cloudwatch_log_metric_filter_root_usage", - "cloudwatch_log_metric_filter_sign_in_without_mfa", - "cloudwatch_log_metric_filter_policy_changes", - "cloudwatch_log_metric_filter_security_group_changes", - "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_authentication_failures", - "cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk", - "cloudwatch_log_metric_filter_for_s3_bucket_policy_changes", - "cloudwatch_log_metric_filter_aws_organizations_changes", - "guardduty_no_high_severity_findings" - ] - }, - { - "Id": "LOG-07", - "Description": "Establish, document and implement which information meta/data system events should be logged. Review and update the scope at least annually or whenever there is a change in the threat environment.", - "Name": "Logging Scope", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5.3", - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5.3", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-1", - "AU-14", - "AU-16" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.SC-3", - "ID.SC-4", - "PR.PT-1", - "ID.GV-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1", - "10.2.2" - ] - } - ] - } - ], - "Checks": [ - "cloudtrail_bedrock_logging_enabled", - "cloudtrail_multi_region_enabled", - "cloudtrail_multi_region_enabled_logging_management_events", - "cloudtrail_s3_dataevents_read_enabled", - "cloudtrail_s3_dataevents_write_enabled", - "vpc_flow_logs_enabled", - "awslambda_function_invoke_api_operations_cloudtrail_logging_enabled" - ] - }, - { - "Id": "LOG-08", - "Description": "Generate audit records containing relevant security information.", - "Name": "Log Records", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-3", - "AU-3(1)", - "AU-3(3)", - "AU-6", - "AU-6(8)", - "AU-12", - "AU-12(1)", - "AU-12(2)", - "AU-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.PT-1", - "DE.AE-3", - "DE.CM-1", - "DE.CM-2", - "DE.CM-3", - "DE.CM-6", - "DE.CM-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.2" - ] - } - ] - } - ], - "Checks": [ - "cloudtrail_multi_region_enabled", - "cloudtrail_cloudwatch_logging_enabled", - "vpc_flow_logs_enabled", - "s3_bucket_server_access_logging_enabled", - "elb_logging_enabled", - "elbv2_logging_enabled", - "cloudfront_distributions_logging_enabled", - "route53_public_hosted_zones_cloudwatch_logging_enabled", - "wafv2_webacl_logging_enabled", - "redshift_cluster_audit_logging", - "rds_cluster_integration_cloudwatch_logs", - "rds_instance_integration_cloudwatch_logs", - "opensearch_service_domains_audit_logging_enabled", - "eks_control_plane_logging_all_types_enabled", - "apigateway_restapi_logging_enabled", - "apigatewayv2_api_access_logging_enabled", - "networkfirewall_logging_enabled", - "mq_broker_logging_enabled", - "documentdb_cluster_cloudwatch_log_export", - "neptune_cluster_integration_cloudwatch_logs", - "codebuild_project_logging_enabled", - "glue_etl_jobs_logging_enabled", - "stepfunctions_statemachine_logging_enabled", - "datasync_task_logging_enabled", - "ec2_client_vpn_endpoint_connection_logging_enabled", - "elasticbeanstalk_environment_cloudwatch_logging_enabled" - ] - }, - { - "Id": "LOG-09", - "Description": "The information system protects audit records from unauthorized access, modification, and deletion.", - "Name": "Log Protection", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-04", - "IVS-01" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.2", - "27002: 12.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(2)", - "AU-9(3)", - "AU-9(4)", - "AU-12(3)", - "AU-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.IP-4", - "PR.IP-6", - "PR.PT-1", - "PR.DS-1", - "PR.DS-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.DS-01", - "PR.DS-02", - "PR.DS-11" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5", - "10.5.1", - "10.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4" - ] - } - ] - } - ], - "Checks": [ - "cloudtrail_log_file_validation_enabled", - "cloudtrail_kms_encryption_enabled", - "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", - "cloudwatch_log_group_kms_encryption_enabled", - "cloudwatch_log_group_not_publicly_accessible" - ] - }, - { - "Id": "LOG-10", - "Description": "Establish and maintain a monitoring and internal reporting capability over the operations of cryptographic, encryption and key management policies, processes, procedures, and controls.", - "Name": "Encryption Monitoring and Reporting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-02", - "EKM-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1", - "27002: 10.1", - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-1", - "AU-9", - "AU-9(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "PR.PT-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.1.1", - "10.2.1", - "10.4.1" - ] - } - ] - } - ], - "Checks": [ - "kms_cmk_rotation_enabled", - "acm_certificates_expiration_check" - ] - }, - { - "Id": "LOG-11", - "Description": "Log and monitor key lifecycle management events to enable auditing and reporting on usage of cryptographic keys.", - "Name": "Transaction/Activity Logging", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.PT-1", - "DE.AE-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-09" - ] - } - ] - } - ], - "Checks": [ - "cloudtrail_s3_dataevents_read_enabled", - "cloudtrail_s3_dataevents_write_enabled", - "cloudtrail_multi_region_enabled_logging_management_events" - ] - }, - { - "Id": "LOG-13", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the reporting of anomalies and failures of the monitoring system and provide immediate notification to the accountable party.", - "Name": "Failures and Anomalies Reporting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.3", - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.1", - "27002: 16.1.1", - "27001: A.16.1.2", - "27017: 16.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.24", - "27001: A.6.8", - "27002: 6.8 (g)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-5", - "AU-5(2)", - "AU-6", - "AU-6(3)", - "AU-6(4)", - "AU-6(5)", - "AU-16" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-3", - "DE.DP-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.AE-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.4.3", - "10.7.1", - "10.7.2", - "10.7.3" - ] - } - ] - } - ], - "Checks": [ - "guardduty_is_enabled", - "guardduty_no_high_severity_findings", - "cloudwatch_alarm_actions_enabled", - "cloudwatch_alarm_actions_alarm_state_configured" - ] - }, - { - "Id": "SEF-03", - "Description": "'Establish, document, approve, communicate, apply, evaluate and maintain a security incident response plan, which includes but is not limited to: relevant internal departments, impacted CSCs, and other business critical relationships (such as supply-chain) that may be impacted.'", - "Name": "Incident Response Plans", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2", - "CC7.3", - "CC7.4" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "17.2", - "17.4" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27017: CLD.12.1.5", - "27018: 16.1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: A.5.26", - "27002: 5.26 (e,f)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IR-1", - "IR-2", - "IR-2(1)-(3)", - "IR-3", - "IR-3(1)-(3)", - "IR-4", - "IR-4(1)-(15)", - "IR-5", - "IR-5(1)", - "IR-6", - "IR-6(1)-(3)", - "IR-7", - "IR-7(1)", - "IR-7(2)", - "IR-8", - "IR-8(1)", - "IR-9", - "IR-9(1)-(4)", - "PM-12" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "RS.CO-1", - "RS.CO-4", - "ID.AM-6", - "ID.GV-2", - "ID.SC-5", - "PR.IP-9", - "PR.IP10" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AT-01", - "PR.AT-02", - "RS.MA-01", - "GV.SC-08", - "ID.IM-02", - "ID.IM-04", - "RC.RP-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.1", - "12.10.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1", - "12.10.5" - ] - } - ] - } - ], - "Checks": [ - "ssmincidents_enabled_with_plans" - ] - }, - { - "Id": "SEF-06", - "Description": "Define, implement and evaluate processes, procedures and technical measures supporting business processes to triage security-related events.", - "Name": "Event Triage Processes", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.4", - "27002: 16.1.4", - "27017: 16.1.4", - "27018: 16.1.4", - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27018: 16.1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.25" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-7", - "CA-7(3)", - "CA-7(4)", - "CA-7(5)", - "CA-7(6)", - "IR-4", - "IR-4(1)", - "IR-4(3)", - "IR-4(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.AE-2", - "DE.AE-4", - "RS.RP-1", - "RS.AN-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "RS.MA-02", - "RS.MA-03", - "RS.AN-03", - "DE.AE-02", - "DE.AE-04", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "RS.MI-02", - "RC.RP-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "guardduty_is_enabled", - "securityhub_enabled" - ] - }, - { - "Id": "SEF-08", - "Description": "Maintain points of contact for applicable regulation authorities, national and local law enforcement, and other legal jurisdictional authorities.", - "Name": "Points of Contact Maintenance", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "17.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 4.2", - "27001: A.6.1.3", - "27002: 6.1.3", - "27017: 6.1.3", - "27018: 6.1.3", - "27001: A.16.1.1", - "27002: 16.1.1", - "27001: A.18.1.1", - "27002: 18.1.1", - "27017: 18.1.1", - "27018: 18.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.5", - "27001: A.5.24", - "27002: 5.24 Incident management procedure (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IR-4", - "IR-4(8)", - "IR-6", - "IR-6(3)", - "IR-7", - "IR-7(2)", - "PM-21", - "PM-23", - "PM-26" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-2", - "RS.CO-3", - "RS.CO-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.RR-02", - "RS.CO-02", - "RS.CO-03" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "account_maintain_current_contact_details", - "account_security_contact_information_is_registered", - "account_maintain_different_contact_details_to_security_billing_and_operations" - ] - }, - { - "Id": "TVM-02", - "Description": "Establish, document, approve, communicate, apply, evaluate and maintain policies and procedures to protect against malware on managed assets. Review and update the policies and procedures at least annually.", - "Name": "Malware Protection Policy and Procedures", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC6.8" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-01", - "GRM-06", - "GRM-09" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "9.7", - "10.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.1.1", - "1.5.1", - "5.2.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS1.2", - "TS1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5", - "27002: 5", - "27001: A.12.2.1", - "27001: A.6.2.1", - "27002: 6.2.1 (h)", - "27001: A.6.2.2", - "27002: 6.2.2 (j)", - "27001: A.7.2.2", - "27002: 7.2.2 (d)", - "27001: A.10.1.1", - "27002: 10.1.1 (g)", - "27001: A.13.2.1", - "27002: 13.2.1 (b)", - "27001: A.15.1.2", - "27017: 15.1.2", - "27001: A.12.2.1", - "27002: 12.2.1 (a),(d)", - "27017: CLD.9.5.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5.1", - "27001: A.5.4", - "27001: A.5.7", - "27001: A.5.37", - "27001: A.8.7", - "27002: 5.7 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-3", - "RA-3(3)", - "RA-5", - "RA-5(3)", - "RA-5(5)", - "SI-3", - "SI-3(4)", - "SI-3(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "DE.CM-4", - "DE.CM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.PO-01", - "GV.PO-02", - "ID.IM-03", - "DE.CM-01", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.4", - "12.1", - "12.1.1", - "12.3.1", - "12.5.1", - "12.11" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.1.1", - "12.1.2", - "5.1.1", - "5.3.2.1" - ] - } - ] - } - ], - "Checks": [ - "guardduty_ec2_malware_protection_enabled" - ] - }, - { - "Id": "TVM-03", - "Description": "Define, implement and evaluate processes, procedures and technical measures to enable both scheduled and emergency responses to vulnerability identifications, based on the identified risk.", - "Name": "Vulnerability Remediation Schedule", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC7.1", - "CC7.4" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "7.2", - "7.7", - "17.9" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1", - "TM2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.12.2.1", - "27001: A.12.6.1", - "27002: 12.6.1(c)(d)(j)", - "27018: 12.6.1(k)(i)" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.8.7", - "27001: A.8.8", - "27001: A.8.32", - "27002: 8.7", - "27002: 8.8", - "27002: 8.32" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-31", - "RA-3", - "RA-3(1)", - "RA-5", - "RA-5(2)-(4)", - "RA-5(6)", - "SI-3", - "SI-3(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "RS.AN-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01", - "ID.RA-06", - "ID.RA-08", - "PR.PS-02", - "PR.PS-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "6.1.a", - "6.1.b" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.1.1", - "6.3.1", - "6.3.2", - "6.3.3", - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "ssm_managed_compliant_patching", - "rds_instance_minor_version_upgrade_enabled", - "rds_cluster_minor_version_upgrade_enabled", - "redshift_cluster_automatic_upgrades", - "elasticbeanstalk_environment_managed_updates_enabled", - "dms_instance_minor_version_upgrade_enabled", - "elasticache_redis_cluster_auto_minor_version_upgrades", - "memorydb_cluster_auto_minor_version_upgrades", - "mq_broker_auto_minor_version_upgrades", - "opensearch_service_domains_updated_to_the_latest_service_software_version", - "kafka_cluster_uses_latest_version" - ] - }, - { - "Id": "TVM-04", - "Description": "Define, implement and evaluate processes, procedures and technical measures to update detection tools, threat signatures, and indicators of compromise on a weekly, or more frequent basis.", - "Name": "Detection Updates", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "No mapping" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "10.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS1.3", - "TS1.4", - "TM1.3", - "TM1.4", - "IM1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.5.1.1", - "27002: 5.1.1 (h)", - "27001: A.12.6.1", - "27002: 12.6.1 (b),(c)" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.5.1", - "27001: A.8.8", - "27001: A.8.15", - "27001: A.8.16", - "27002: 5.1", - "27002: 5.37", - "27002: 8.8", - "27002: 8.15 (d)", - "27002: 8.16 (d,e)", - "27002: 8.31 2nd (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-7", - "CM-7(4)", - "RA-3", - "RA-3(3)", - "RA-5(2)", - "SA-10", - "SA-10(5)", - "SA-11", - "SA-11(2)", - "SI-2", - "SI-2(4)", - "SI-3", - "SI-3(4)", - "SI-4", - "SI-4(9)", - "SI-4(24)", - "SI-8", - "SI-8(2)", - "SI-8(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-02", - "ID.RA-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.2", - "5.2a", - "5.2b", - "5.2c" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "5.3.1" - ] - } - ] - } - ], - "Checks": [ - "guardduty_is_enabled", - "inspector2_is_enabled" - ] - }, - { - "Id": "TVM-05", - "Description": "Define, implement and evaluate processes, procedures and technical measures to identify updates for applications which use third party or open source libraries according to the organization's vulnerability management policy.", - "Name": "External Library Vulnerabilities", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC3.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "No mapping" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1", - "SD2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.12.6.2", - "27002: 12.6.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A 5.6", - "27001: A.8.19", - "27001: A.8.8", - "27001: A.8.28", - "27001: A.8.31", - "27002: 5.6 (c)", - "27001: 8.19", - "27001: 8.8", - "27001: 8.28", - "27001: 8.31" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-5", - "RA-5(3)", - "SA-11", - "SA-11(2)", - "SA-11(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01", - "ID.RA-03", - "PR.PS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "6.2", - "6.3.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "6.3.2", - "6.3.3" - ] - } - ] - } - ], - "Checks": [ - "inspector2_is_enabled", - "ecr_repositories_scan_vulnerabilities_in_latest_image", - "ecr_registry_scan_images_on_push_enabled" - ] - }, - { - "Id": "TVM-07", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the detection of vulnerabilities on organizationally managed assets at least monthly.", - "Name": "Vulnerability Identification", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "7.1", - "7.5", - "7.6" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.5", - "5.2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.6", - "27001: A.12.6.1", - "27002: 12.6.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.8", - "27002: 8.8" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-5", - "RA-5(4)", - "RA-5(5)", - "SA-11", - "SA-11(5)", - "SA-15(5)", - "SC-7", - "SC-7(10)", - "SI-3(8)", - "SI-3(10)", - "SI-7", - "SI-7(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.RA-1", - "DE.CM-8", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "11.2", - "11.2.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "6.3.2", - "6.3.3", - "11.3.2", - "11.3.2.1" - ] - } - ] - } - ], - "Checks": [ - "inspector2_is_enabled", - "inspector2_active_findings_exist", - "guardduty_is_enabled", - "ecr_repositories_scan_vulnerabilities_in_latest_image" - ] - }, - { - "Id": "UEM-08", - "Description": "Protect information from unauthorized disclosure on managed endpoint devices with storage encryption.", - "Name": "Storage Encryption", - "Attributes": [ - { - "Section": "Universal Endpoint Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "MOS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.6" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "3.1.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "PA1.2", - "PA1.3", - "PA1.5", - "PA2.2", - "PM1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.11.2.7", - "27002: 11.2.7", - "27001: A.18.1.1", - "27017: 18.1.1", - "27001: A.12.3.1", - "27017: 12.3.1", - "27018: A.11.4", - "27018: A.11.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.1", - "27002: 8.1 (h)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-19(5)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.4", - "3.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.5.1", - "3.6" - ] - } - ] - } - ], - "Checks": [ - "ec2_ebs_volume_encryption", - "ec2_ebs_default_encryption", - "workspaces_volume_encryption_enabled" - ] - }, - { - "Id": "UEM-11", - "Description": "Configure managed endpoints with Data Loss Prevention (DLP) technologies and rules in accordance with a risk assessment.", - "Name": "Data Loss Prevention", - "Attributes": [ - { - "Section": "Universal Endpoint Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.13" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.5", - "PA2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.3", - "27002: 12.3", - "27001: A.8.3.1", - "27002: 8.3.1", - "27001: A.12.2", - "27002: 12.2", - "27001: A.18.1.3", - "27002: 18.1.3", - "27001: A.6.1.1", - "27017: 6.1.1", - "27018: 12.3.1", - "27018: 10.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.12", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-7", - "SC-7(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02", - "PR.DS-10", - "PR.PS-01", - "ID.AM-08", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A3.2.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "A3.2.6" - ] - } - ] - } - ], - "Checks": [ - "macie_is_enabled", - "macie_automated_sensitive_data_discovery_enabled" - ] - } - ] -} diff --git a/prowler/compliance/azure/cis_5.0_azure.json b/prowler/compliance/azure/cis_5.0_azure.json index 21785d92b6..086320fecd 100644 --- a/prowler/compliance/azure/cis_5.0_azure.json +++ b/prowler/compliance/azure/cis_5.0_azure.json @@ -2094,7 +2094,9 @@ { "Id": "8.1.1.1", "Description": "Ensure Microsoft Defender CSPM is set to 'On'", - "Checks": [], + "Checks": [ + "defender_ensure_defender_cspm_is_on" + ], "Attributes": [ { "Section": "8 Security Services", diff --git a/prowler/compliance/azure/csa_ccm_4.0_azure.json b/prowler/compliance/azure/csa_ccm_4.0_azure.json deleted file mode 100644 index b4505ac089..0000000000 --- a/prowler/compliance/azure/csa_ccm_4.0_azure.json +++ /dev/null @@ -1,7548 +0,0 @@ -{ - "Framework": "CSA-CCM", - "Name": "CSA Cloud Controls Matrix (CCM) v4.0.13", - "Version": "4.0", - "Provider": "Azure", - "Description": "The Cloud Security Alliance (CSA) Cloud Controls Matrix (CCM) is a cybersecurity control framework for cloud computing, composed of 197 control objectives structured in 17 domains covering all key aspects of cloud technology. The CCM can be used as a tool for the systematic assessment of a cloud implementation, and provides guidance on which security controls should be implemented by which actor within the cloud supply chain.", - "Requirements": [ - { - "Id": "A&A-02", - "Description": "Conduct independent audit and assurance assessments according to relevant standards at least annually.", - "Name": "Independent Assessments", - "Attributes": [ - { - "Section": "Audit & Assurance", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC4.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AAC-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.5.2", - "5.2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "AS1.1", - "AS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.2.1", - "27002: 18.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.35", - "27001: A.5.36" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-2", - "CA-2(1)", - "CA-2(2)", - "CA-7", - "CA-7(1)" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-01" - ] - } - ] - } - ], - "Checks": [ - "defender_ensure_defender_for_app_services_is_on", - "defender_ensure_defender_for_azure_sql_databases_is_on", - "defender_ensure_defender_for_databases_is_on", - "defender_ensure_defender_for_keyvault_is_on", - "defender_ensure_defender_for_server_is_on" - ] - }, - { - "Id": "A&A-04", - "Description": "Verify compliance with all relevant standards, regulations, legal/contractual, and statutory requirements applicable to the audit.", - "Name": "Requirements Compliance", - "Attributes": [ - { - "Section": "Audit & Assurance", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC3.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-01", - "GRM-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "7.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "AS1.1", - "AS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.3.2", - "27001: A.18.2.2", - "27002: 18.2.2", - "27001: A.18.2.3", - "27002: 18.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.3.2", - "27001: A.5.31", - "27001: A.5.32", - "27001: A.5.33", - "27001: A.5.34", - "27001: A.5.36" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-1" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-3", - "DE.DP-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-01" - ] - } - ] - } - ], - "Checks": [ - "defender_ensure_defender_for_app_services_is_on", - "defender_ensure_defender_for_azure_sql_databases_is_on", - "defender_ensure_defender_for_databases_is_on", - "defender_ensure_defender_for_server_is_on", - "defender_ensure_mcas_is_enabled", - "monitor_diagnostic_settings_exists", - "policy_ensure_asc_enforcement_enabled" - ] - }, - { - "Id": "AIS-04", - "Description": "Define and implement a SDLC process for application design, development, deployment, and operation in accordance with security requirements defined by the organization.", - "Name": "Secure Application Design and Development", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AIS-01", - "AIS-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.4", - "5.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.1.1", - "27002: 14.1.1", - "27017: 14.1.1", - "27001: A.14.1.2", - "27002: 14.1.2", - "27017: 14.1.2", - "27001: A.14.2.1", - "27002: 14.2.1", - "27017: 14.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.8", - "27001: A.8.25", - "27001: A.8.26", - "27001: A.8.28" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-2", - "PL-8", - "PL-8(1)", - "SA-3", - "SA-3(1)", - "SA-4", - "SA-4(2)", - "SA-4(3)", - "SA-4(8)", - "SA-4(9)", - "SA-5", - "SA-8", - "SA-8(1)-(7)", - "SA-8(9)-(13)", - "SA-8(15)-(20)", - "SA-8(22)", - "SA-8(24)-(28)", - "SA-8(30)-(33)", - "SA-17", - "SA-17(1)-(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-6", - "PR.DS-7", - "PR.IP-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "PR.IR-01", - "PR.PS-01", - "PR.PS-02", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.1", - "6.2.3", - "6.5.2" - ] - } - ] - } - ], - "Checks": [ - "app_ensure_auth_is_set_up", - "app_ftp_deployment_disabled", - "app_function_access_keys_configured", - "app_function_ftps_deployment_disabled", - "app_register_with_identity" - ] - }, - { - "Id": "AIS-05", - "Description": "Implement a testing strategy, including criteria for acceptance of new information systems, upgrades and new versions, which provides application security assurance and maintains compliance while enabling organizational speed of delivery goals. Automate when applicable and possible.", - "Name": "Automated Application Security Testing", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AIS-01", - "AIS-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.12", - "16.13" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD2.3", - "SD2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.2.8", - "27001: A.14.2.9", - "27001: A.12.1.2", - "27002: 12.1.2", - "27001: A.14.1.1", - "27002: 14.1.1", - "27001: A.14.2.2", - "27002: 14.2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.25", - "27001: A.8.29", - "27001: A.8.32", - "27002: 8.25 (e)", - "27002: 8.32 (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SA-11", - "SA-11(1)-(9)", - "SI-6", - "SI-6(2)", - "SI-6(3)", - "SI-10", - "SI-10(1)-(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.PT-3", - "PR.IP-12", - "DE.CM-8" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "ID.RA-01", - "PR.PS-01", - "PR.PS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A.3.2.2", - "A.3.2.2.1", - "6.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.4", - "6.4.1", - "6.4.2", - "6.5.1" - ] - } - ] - } - ], - "Checks": [ - "defender_auto_provisioning_vulnerabilty_assessments_machines_on", - "defender_container_images_resolved_vulnerabilities", - "defender_container_images_scan_enabled", - "defender_ensure_defender_for_containers_is_on", - "sqlserver_va_periodic_recurring_scans_enabled", - "sqlserver_vulnerability_assessment_enabled" - ] - }, - { - "Id": "AIS-07", - "Description": "Define and implement a process to remediate application security vulnerabilities, automating remediation when possible.", - "Name": "Application Vulnerability Remediation", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.1", - "CC7.4", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.2", - "16.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27001: A.12.6.1", - "27002: 12.6.1", - "27017: 12.6.1", - "27018: 12.6.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.26", - "27001: A.8.8", - "27002: 5.26 (j)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SI-2", - "SI-2(2)-(6)", - "SA-11", - "SA-11(2)", - "SA-15", - "SA-15(1)-(3)", - "SA-15(5)-(8)", - "SA-15(10)-(12)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.IP-12", - "DE.CM-8", - "RS.AN-5", - "RS.MI-3", - "PR.DS-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "ID.RA-01", - "ID.RA-06", - "ID.RA-08", - "PR.PS-02", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.2", - "6.5", - "6.5.1-10" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "11.3.1", - "11.3.1.1" - ] - } - ] - } - ], - "Checks": [ - "defender_container_images_resolved_vulnerabilities", - "defender_container_images_scan_enabled", - "defender_ensure_defender_for_containers_is_on", - "sqlserver_va_scan_reports_configured", - "sqlserver_vulnerability_assessment_enabled" - ] - }, - { - "Id": "BCR-08", - "Description": "Periodically backup data stored in the cloud. Ensure the confidentiality, integrity and availability of the backup, and verify data restoration from backup for resiliency.", - "Name": "Backup", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "A1.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "11.1", - "11.2", - "11.3", - "11.4", - "11.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8", - "5.2.9" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.3", - "27017: 12.3", - "27018: 12.3.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.13", - "27001: A.5.23", - "27001: A.5.30", - "27002: 8.13", - "27002: 5.23 2nd (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-4", - "CP-4(4)", - "CP-6", - "CP-6(1)-(3)", - "CP-9", - "CP-9(1)", - "CP-9(2)", - "CP-10", - "CP-10(2)", - "CP-10(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-4", - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-11", - "RC.RP-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "9.5.1", - "12.10.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1", - "10.3.3" - ] - } - ] - } - ], - "Checks": [ - "storage_ensure_encryption_with_customer_managed_keys", - "vm_backup_enabled", - "vm_sufficient_daily_backup_retention_period" - ] - }, - { - "Id": "BCR-09", - "Description": "Establish, document, approve, communicate, apply, evaluate and maintain a disaster response plan to recover from natural and man-made disasters. Update the plan at least annually or upon significant changes.", - "Name": "Disaster Response Plan", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "CC3.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8", - "5.2.9", - "1.6.1", - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "BC1.4", - "BC2.1", - "BC2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.29", - "27001: A.5.30", - "27002: 5.29", - "27002: 5.30" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2(1)", - "CP-2(2)", - "CP-2(3)", - "CP-2(5)", - "CP-2(6)", - "CP-2(7)", - "CP-2(8)", - "PE-13", - "PE-13(1)", - "PE-13(2)", - "PE-13(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-9", - "PR.IP-10", - "RC.IM-1", - "RC.IM-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-04" - ] - } - ] - } - ], - "Checks": [ - "defender_ensure_defender_for_server_is_on", - "vm_backup_enabled" - ] - }, - { - "Id": "BCR-11", - "Description": "Supplement business-critical equipment with redundant equipment independently located at a reasonable minimum distance in accordance with applicable industry standards.", - "Name": "Equipment Redundancy", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "No", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "CC3.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-06" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "BC1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.20", - "27001: A.7.11", - "27001: A.8.14", - "27002: 5.20 (t)", - "27002: 8.14 (c)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2", - "CP-2(2)", - "CP-4(3)", - "CP-6", - "CP-6(1)", - "CP-7", - "CP-8", - "CP-8(1)-(3)", - "CP-9", - "CP-9(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.BE-4", - "ID.BE-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.OC-04", - "GV.OC-05", - "PR.IR-03" - ] - } - ] - } - ], - "Checks": [ - "storage_blob_versioning_is_enabled", - "storage_geo_redundant_enabled", - "vm_scaleset_associated_with_load_balancer", - "vm_scaleset_not_empty" - ] - }, - { - "Id": "CCC-04", - "Description": "Restrict the unauthorized addition, removal, update, and management of organization assets.", - "Name": "Unauthorized Change Protection", - "Attributes": [ - { - "Section": "Change Control and Configuration Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "CCC-04" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.1", - "1.3.4", - "5.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.4", - "SM2.6" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.1.4", - "27002: 12.1.4", - "27001: A.12.4.2", - "27002: 12.4.2", - "27001: A.14.2.2", - "27017: 14.2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.3", - "27001: A.8.4", - "27001: A.8.15", - "27001: A.8.31", - "27001: A.8.32" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-7", - "CA-7(4)", - "CM-3", - "CM-3(1)", - "CM-3(5)", - "CM-3(7)", - "CM-3(8)", - "CM-5", - "CM-5(1)", - "CM-5(4)", - "CM-5(5)", - "CM-6", - "CM-6(1)", - "CM-6(2)", - "CM-7", - "CM-7(1)", - "CM-7(4)", - "CM-7(5)", - "CM-7(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-1", - "ID.AM-2", - "ID.AM-4", - "PR.MA-1", - "PR.MA-2", - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-01", - "ID.AM-02", - "ID.AM-04", - "ID.AM-08", - "PR.PS-02", - "PR.PS-03", - "PR.PS-05", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.5.1", - "6.5.2" - ] - } - ] - } - ], - "Checks": [ - "iam_custom_role_has_permissions_to_administer_resource_locks", - "monitor_alert_create_policy_assignment", - "monitor_diagnostic_setting_with_appropriate_categories", - "monitor_diagnostic_settings_exists", - "policy_ensure_asc_enforcement_enabled", - "storage_ensure_soft_delete_is_enabled" - ] - }, - { - "Id": "CCC-07", - "Description": "Implement detection measures with proactive notification in case of changes deviating from the established baseline.", - "Name": "Detection of Baseline Deviation", - "Attributes": [ - { - "Section": "Change Control and Configuration Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-01" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.5.1", - "1.5.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.2.2", - "27001: A.14.2.4", - "27001: A.12.4.1", - "27002: 12.4.1 (g)", - "27001: A.5.1.1", - "27017: 5.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.9", - "27001: A.8.15", - "27002: 8.9" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-6", - "CM-6(2)", - "SI-2", - "SI-2(2)-(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.MA-1", - "PR.IP-1", - "DE.DP-4", - "PR.IP-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-01", - "DE.CM-09", - "DE.AE-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4.5.3", - "6.4.5.4", - "11.5", - "11.5.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "11.5.2", - "11.6.1" - ] - } - ] - } - ], - "Checks": [ - "defender_ensure_defender_for_app_services_is_on", - "defender_ensure_defender_for_azure_sql_databases_is_on", - "defender_ensure_defender_for_server_is_on", - "defender_ensure_wdatp_is_enabled", - "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_settings_exists", - "policy_ensure_asc_enforcement_enabled" - ] - }, - { - "Id": "CEK-03", - "Description": "Provide cryptographic protection to data at-rest and in-transit, using cryptographic libraries certified to approved standards.", - "Name": "Data Encryption", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-03", - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.6", - "3.1", - "3.11", - "11.3", - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.1", - "27001: A.18.1.2", - "27001: A.18.1.3", - "27001: A.18.1.4", - "27001: A.18.1.5", - "27001: A.10.1", - "27002: 10.1", - "27001: A.13.2.1", - "27002: 13.2.1", - "27001: A.18", - "27002: 18", - "27001: A.14.1.2", - "27002: 14.1.2", - "27001: A.14.1.3", - "27002 14.1.3 c)", - "27001 - A.10.1.1", - "27017 - 10.1.1", - "27001 - A.10.1.2", - "27017 - 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.8.24", - "27002: 8.24 Other Information (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-19", - "AC-19(5)", - "SC-8", - "SC-8(1)", - "SC-8(3)", - "SC-8(4)", - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-28", - "SC-28(1)-(3)", - "SI-4", - "SI-4(10)", - "SI-7", - "SI-7(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "Requirement 3", - "2.2.3", - "2.3", - "3.4", - "3.5.3", - "4.1", - "8.2.1", - "PCI Glossary - Strong Cryptography" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.7", - "3.5.1", - "4.2.1", - "4.2.1.2", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "app_minimum_tls_version_12", - "databricks_workspace_cmk_encryption_enabled", - "mysql_flexible_server_ssl_connection_enabled", - "postgresql_flexible_server_enforce_ssl_enabled", - "sqlserver_tde_encrypted_with_cmk", - "sqlserver_tde_encryption_enabled", - "storage_ensure_encryption_with_customer_managed_keys", - "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" - ] - }, - { - "Id": "CEK-04", - "Description": "Use encryption algorithms that are appropriate for data protection, considering the classification of data, associated risks, and usability of the encryption technology.", - "Name": "Encryption Algorithm", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.2", - "27001: 6.1.3", - "27001: A.8.2", - "27002: 8.2", - "27001: A.8.3", - "27001: A.10.1.1", - "27002: 10.1.1 (b)", - "27001: A.10.1.2", - "27002: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.2", - "27001: 6.1.3", - "27001: A.8.24", - "27001: A.5.12", - "27001: A.5.13", - "27002: 8.24 General (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2", - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02", - "ID.AM-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A2", - "Requirement 3", - "2.3", - "2.2.3", - "3.4", - "3.5.3", - "4.1", - "8.2.1", - "PCI Glossary - Strong Cryptography" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.7", - "3.5.1", - "4.2.1", - "4.2.1.2", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "app_minimum_tls_version_12", - "keyvault_key_rotation_enabled", - "mysql_flexible_server_minimum_tls_version_12", - "postgresql_flexible_server_enforce_ssl_enabled", - "sqlserver_recommended_minimal_tls_version", - "storage_ensure_minimum_tls_version_12", - "storage_smb_protocol_version_is_latest" - ] - }, - { - "Id": "CEK-08", - "Description": "CSPs must provide the capability for CSCs to manage their own data encryption keys.", - "Name": "CSC Key Management Capability", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2", - "SC2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1", - "27017: 10.1", - "27001: A.10.1.1", - "27017: 10.1.1", - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.23", - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-9", - "CP-9(8)", - "SA-9", - "SA-9(6)", - "SC-12", - "SC-12(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.SC-3", - "ID.AM-6", - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.SC-05" - ] - } - ] - } - ], - "Checks": [ - "databricks_workspace_cmk_encryption_enabled", - "keyvault_access_only_through_private_endpoints", - "keyvault_private_endpoints", - "keyvault_rbac_enabled", - "storage_ensure_encryption_with_customer_managed_keys" - ] - }, - { - "Id": "CEK-10", - "Description": "Generate Cryptographic keys using industry accepted cryptographic libraries specifying the algorithm strength and the random number generator used.", - "Name": "Key Generation", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2", - "TS2.3", - "SY1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27002: 10.1.1 (e)", - "27017: 10.1.1", - "27001: A.10.1.2", - "27002: 10.1.2", - "27002: 10.1.2 (a)", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24", - "27002: 8.24 (d), Key management (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.2.3", - "3.6.1", - "PCI Glossary - Cryptographic Key Generation" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1", - "3.6.1.1", - "3.7.1" - ] - } - ] - } - ], - "Checks": [ - "keyvault_rbac_enabled", - "storage_ensure_encryption_with_customer_managed_keys" - ] - }, - { - "Id": "CEK-12", - "Description": "Rotate cryptographic keys in accordance with the calculated cryptoperiod, which includes provisions for considering the risk of information disclosure and legal and regulatory requirements.", - "Name": "Key Rotation", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27017: 10.1.1", - "27001: A.10.1.2", - "27002: 10.1.2 e)", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.31", - "27001: A.8.24", - "27002: 5.31 Cryptography", - "27002: 8.24 Key management (e,m)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "ID.GV-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05", - "GV.OC-03" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.7.4", - "3.7.5" - ] - } - ] - } - ], - "Checks": [ - "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" - ] - }, - { - "Id": "CEK-14", - "Description": "Define, implement and evaluate processes, procedures and technical measures to destroy keys stored outside a secure environment and revoke keys stored in Hardware Security Modules (HSMs) when they are no longer needed, which include provisions for legal and regulatory requirements.", - "Name": "Key Destruction", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27017: 10.1.1", - "27017: 10.1.2", - "27001: A.10.1.2", - "27002: 10.1.2 (j)", - "27001: A.18.1.3", - "27002: 18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.31", - "27001: A.8.24", - "27002: 5.31 Cryptography", - "27002: 8.24 Key management (j,m)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.IP-6", - "ID.GV-3", - "PR.DS-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05", - "ID.AM-08", - "GV.OC-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.6.4", - "3.6.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.7.4", - "3.7.5" - ] - } - ] - } - ], - "Checks": [ - "keyvault_recoverable" - ] - }, - { - "Id": "DCS-06", - "Description": "Catalogue and track all relevant physical and logical assets located at all of the CSP's sites within a secured system.", - "Name": "Assets Cataloguing and Tracking", - "Attributes": [ - { - "Section": "Datacenter Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DCS - 01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "1.1", - "2.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SM2.6" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.1.1", - "27002: 8.1.1", - "27017: 8.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.9" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-8", - "CM-8(1)", - "CM-8(2)", - "CM-8(4)", - "CM-8(7)", - "CM-8(8)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-1", - "ID.AM-2", - "ID.AM-4", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-01", - "ID.AM-02", - "ID.AM-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.4", - "9.7.1", - "9.9.1", - "9.9.1.a", - "9.9.1.b", - "9.9.1.c", - "12.3.3", - "12.3.4" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1.1", - "6.3.2", - "9.4.2", - "9.4.3", - "12.5.1" - ] - } - ] - } - ], - "Checks": [ - "defender_ensure_mcas_is_enabled", - "monitor_diagnostic_settings_exists", - "policy_ensure_asc_enforcement_enabled" - ] - }, - { - "Id": "DSP-02", - "Description": "Apply industry accepted methods for the secure disposal of data from storage media such that data is not recoverable by any forensic means.", - "Name": "Secure Disposal", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3", - "CC6.4", - "CC6.5", - "CC6.7", - "P4.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DSI-07" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.3.3", - "7.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.3.2", - "27002: 8.3.2", - "27001: A.11.2.7", - "27002: 11.2.7" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.7.10", - "27001: A.7.14", - "27001: A.8.10", - "27002: 7.10 (Secure reuse or disposal)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-22", - "SI-12", - "SI-12(3)", - "SI-18", - "SI-18(1)", - "SI-18(4)", - "SI-18(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.SC-10", - "PR.PS-02", - "PR.PS-03", - "ID.AM-08" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.1", - "9.8", - "9.8.1", - "9.8.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1", - "3.7.5", - "9.4.7" - ] - } - ] - } - ], - "Checks": [ - "storage_ensure_file_shares_soft_delete_is_enabled", - "storage_ensure_soft_delete_is_enabled", - "vm_sufficient_daily_backup_retention_period" - ] - }, - { - "Id": "DSP-03", - "Description": "Create and maintain a data inventory, at least for any sensitive data and personal data.", - "Name": "Data Inventory", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1", - "1.3.2", - "1.3.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.1.1", - "27002: 8.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.9", - "27001: A.8.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-12", - "CM-12(1)", - "PM-5", - "PM-5(1)", - "SI-12", - "SI-12(1)", - "SI-19", - "SI-19(1)", - "SI-19(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1", - "9.4.5" - ] - } - ] - } - ], - "Checks": [ - "defender_ensure_defender_for_storage_is_on", - "defender_ensure_mcas_is_enabled", - "monitor_diagnostic_settings_exists", - "policy_ensure_asc_enforcement_enabled" - ] - }, - { - "Id": "DSP-04", - "Description": "Classify data according to its type and sensitivity level.", - "Name": "Data Classification", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "C1.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DSI-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.7" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1", - "1.3.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.2.1", - "27002: 8.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-16", - "AC-16(9)", - "PM-22", - "PM-23", - "PT-2", - "PT-2(1)", - "SI-18", - "SI-18(2)", - "SI-19", - "SI-19(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-05", - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "9.6.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "9.4.2", - "9.4.3" - ] - } - ] - } - ], - "Checks": [ - "defender_ensure_defender_for_storage_is_on" - ] - }, - { - "Id": "DSP-07", - "Description": "Develop systems, products, and business practices based upon a principle of security by design and industry best practices.", - "Name": "Data Protection by Design and Default", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "PI1.2", - "PI1.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.3.1", - "5.3.2", - "5.3.3", - "5.3.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD2.2", - "IM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.1.1", - "27002:14.1.1", - "27001: A.14.2.5", - "27002:14.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.27", - "27001: A.8.28", - "27001: A.8.29", - "27002: 5.8 (Information security requirements a-i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-17", - "PM-24", - "PM-25", - "PT-2", - "PT-2(2)", - "SA-3", - "SA-4", - "SA-5", - "SA-8", - "SA-8(9)", - "SA-8(13)", - "SA-8(18)", - "SA-8(20)", - "SA-8(22)", - "SA-8(23)", - "SA-8(33)", - "SA-15", - "SA-15(12)", - "SC-3", - "SC-3(3)", - "SC-7", - "SC-7(24)", - "SC-8", - "SC-8(1)-(4)", - "SC-28", - "SC-28(1)", - "SI-12", - "SI-12(1)-(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.PT-3", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.1" - ] - } - ] - } - ], - "Checks": [ - "aisearch_service_not_publicly_accessible", - "aks_clusters_public_access_disabled", - "containerregistry_not_publicly_accessible", - "cosmosdb_account_firewall_use_selected_networks", - "sqlserver_unrestricted_inbound_access", - "storage_blob_public_access_level_is_disabled", - "storage_default_network_access_rule_is_denied", - "storage_ensure_private_endpoints_in_storage_accounts", - "vm_ensure_attached_disks_encrypted_with_cmk", - "vm_ensure_unattached_disks_encrypted_with_cmk" - ] - }, - { - "Id": "DSP-10", - "Description": "Define, implement and evaluate processes, procedures and technical measures that ensure any transfer of personal or sensitive data is protected from unauthorized access and only processed within scope as permitted by the respective laws and regulations.", - "Name": "Sensitive Data Transfer", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-02", - "EKM-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.1", - "3.12", - "3.13" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "9.5.1", - "9.5.2", - "9.5.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.4", - "IM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.13.2.1", - "27002: 13.2.1", - "27001: A.8.3.3", - "27002: 8.3.3", - "27001: A.13.2.3", - "27002: 13.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.7.10" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-4", - "AC-4(23)-(25)", - "CA-3", - "CA-3(6)", - "CA-6", - "CA-6(1)", - "CA-6(2)", - "SC-4", - "SC-4(2)", - "SC-7", - "SC-7(10)", - "SC-7(24)", - "SC-8", - "SC-8(1)-(5)", - "SC-16", - "SC-16(1)-(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2", - "PR.DS-5", - "PR.PT-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02", - "PR.IR-01", - "ID.AM-03", - "GV.OC-03", - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "4.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "4.1.1", - "4.2.1", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "app_ensure_http_is_redirected_to_https", - "app_ensure_using_http20", - "sqlserver_recommended_minimal_tls_version", - "storage_ensure_minimum_tls_version_12", - "storage_secure_transfer_required_is_enabled" - ] - }, - { - "Id": "DSP-16", - "Description": "Data retention, archiving and deletion is managed in accordance with business requirements, applicable laws and regulations.", - "Name": "Data Retention and Deletion", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "C1.1", - "C1.2", - "CC3.1", - "P4.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-02", - "BCR-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.4", - "3.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.3.1", - "7.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.33", - "27001: A.8.10", - "27002: 5.33 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SI-12", - "SI-12(1)-(3)", - "SI-18", - "SI-18(1)", - "SI-18(4)", - "SI-18(5)", - "SI-19", - "SI-19(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-3", - "PR.IP-6", - "ID.GV-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "GV.OC-03", - "GV.SC-10", - "PR.DS-11" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1" - ] - } - ] - } - ], - "Checks": [ - "monitor_storage_account_with_activity_logs_cmk_encrypted", - "monitor_storage_account_with_activity_logs_is_private", - "postgresql_flexible_server_log_retention_days_greater_3", - "sqlserver_auditing_retention_90_days", - "storage_ensure_file_shares_soft_delete_is_enabled", - "storage_ensure_soft_delete_is_enabled" - ] - }, - { - "Id": "DSP-17", - "Description": "Define and implement, processes, procedures and technical measures to protect sensitive data throughout it's lifecycle.", - "Name": "Sensitive Data Protection", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSC-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.1", - "CC6.1", - "CC6.3", - "CC6.7", - "CC8.1", - "C1.1", - "P2.0", - "P3.0", - "P4.0", - "P5.0", - "P6.0" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.1", - "3.1", - "3.14" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.3.3", - "9.1.1", - "9.2.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3", - "27002: 18.1.3", - "27001:A.18.1.4", - "27002:18.1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.11", - "27001: A.8.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-2", - "PM-22", - "PM-24", - "PT-7", - "PT-7(1)", - "PT-7(2)", - "PT-8", - "SC-8", - "SC-8(1)-(5)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2", - "PR.DS-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02", - "PR.DS-10" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.0 (including all subsections)", - "4.0 (including all subsections)" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.1.1", - "4.1.1" - ] - } - ] - } - ], - "Checks": [ - "containerregistry_not_publicly_accessible", - "cosmosdb_account_firewall_use_selected_networks", - "defender_ensure_defender_for_storage_is_on", - "sqlserver_tde_encrypted_with_cmk", - "sqlserver_tde_encryption_enabled", - "sqlserver_unrestricted_inbound_access", - "storage_account_key_access_disabled", - "storage_blob_public_access_level_is_disabled", - "storage_cross_tenant_replication_disabled", - "storage_default_network_access_rule_is_denied", - "storage_default_to_entra_authorization_enabled", - "storage_ensure_encryption_with_customer_managed_keys", - "vm_ensure_attached_disks_encrypted_with_cmk" - ] - }, - { - "Id": "GRC-05", - "Description": "Develop and implement an Information Security Program, which includes programs for all the relevant domains of the CCM.", - "Name": "Information Security Program", - "Attributes": [ - { - "Section": "Governance, Risk and Compliance", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "14.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SG2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 4.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-1", - "PM-3", - "PM-14", - "PL-2", - "PM-18", - "PM-31" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.4.1", - "A.3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.4.1", - "A3.1.1" - ] - } - ] - } - ], - "Checks": [ - "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_databases_is_on", - "defender_ensure_defender_for_dns_is_on", - "defender_ensure_defender_for_keyvault_is_on", - "defender_ensure_defender_for_server_is_on", - "defender_ensure_mcas_is_enabled", - "defender_ensure_wdatp_is_enabled" - ] - }, - { - "Id": "IAM-02", - "Description": "Establish, document, approve, communicate, implement, apply, evaluate and maintain strong password policies and procedures. Review and update the policies and procedures at least annually.", - "Name": "Strong Password Policy and Procedures", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-12", - "GRM-06", - "GRM-09" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.1.1", - "1.5.1", - "4.1.2", - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1", - "SA1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5", - "27002: 5", - "27001: A.9.4.3", - "27002: 9.4.3", - "27017: 9.4.3", - "27018: 9.4.3", - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27001: A.7.2.2", - "27002: 7.2.2", - "27001: A.9.2.6", - "27002: 9.2.6", - "27001: A.9.2.3", - "27002: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5.1", - "27001: A.5.4", - "27001: A.5.17", - "27001: A.6.3", - "27001: A.8.5", - "27001: A.5.37" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(3)", - "AC-2(11)", - "AC-3", - "AC-3(3)", - "AC-12", - "AC-12(1)", - "IA-2", - "IA-2(10)", - "IA-5", - "IA-5(1)", - "IA-5(18)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "PR.AC-1", - "PR.AC-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.PO-01", - "GV.PO-02", - "ID.IM-03", - "PR.AA-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.4", - "12.1", - "12.1.1", - "12.11" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.1.1", - "8.3.8" - ] - } - ] - } - ], - "Checks": [ - "entra_privileged_user_has_mfa", - "entra_security_defaults_enabled" - ] - }, - { - "Id": "IAM-03", - "Description": "Manage, store, and review the information of system identities, and level of access.", - "Name": "Identity Inventory", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-04", - "IAM-08", - "IAM-10" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1", - "5.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.2 (c)", - "27001: A.8.1.1", - "27002: 8.1.1", - "27001: A.9.4.1", - "27002: 9.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.2 (c)", - "27001: A.5.15", - "27001: A.5.16", - "27001: A.5.18", - "27001: A.7.4", - "27001: A.8.15", - "27001: A.8.2", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-10", - "AU-10(1)", - "AU-10(2)", - "AU-16", - "AU-16(1)", - "IA-4", - "IA-4(8)", - "IA-4(9)", - "IA-5", - "IA-5(5)", - "IA-8", - "IA-8(4)", - "PM-5(1)", - "SA-8", - "SA-8(22)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-04", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.4.a" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.5", - "7.2.5.1" - ] - } - ] - } - ], - "Checks": [ - "entra_global_admin_in_less_than_five_users" - ] - }, - { - "Id": "IAM-04", - "Description": "Employ the separation of duties principle when implementing information system access.", - "Name": "Separation of Duties", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC1.3", - "CC5.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.2.2", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.6.1.2", - "27002: 6.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.18", - "27001: A.5.3", - "27001: A.8.2" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(3)", - "AC-2(11)", - "AC-6", - "AC-6(1)-(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4", - "6.4.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.5.3", - "6.5.4", - "7.2.1", - "7.2.2" - ] - } - ] - } - ], - "Checks": [ - "entra_policy_default_users_cannot_create_security_groups", - "entra_policy_ensure_default_user_cannot_create_apps", - "iam_role_user_access_admin_restricted", - "iam_subscription_roles_owner_custom_not_created" - ] - }, - { - "Id": "IAM-05", - "Description": "Employ the least privilege principle when implementing information system access.", - "Name": "Least Privilege", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-06", - "IVS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.1.1", - "27002: 9.1.1", - "27001: A.9.1.2", - "27002: 9.1.2", - "27001: A.9.2.3", - "27002: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.8.2", - "27002: 5.15 (Other information 2nd (a))" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(4)", - "IA-12", - "IA-12(2)", - "IA-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "7.1", - "7.1.1", - "7.1.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "7.2.2", - "7.2.5", - "7.2.6" - ] - } - ] - } - ], - "Checks": [ - "app_function_identity_without_admin_privileges", - "entra_policy_ensure_default_user_cannot_create_apps", - "entra_policy_ensure_default_user_cannot_create_tenants", - "entra_policy_restricts_user_consent_for_apps", - "iam_custom_role_has_permissions_to_administer_resource_locks", - "iam_subscription_roles_owner_custom_not_created" - ] - }, - { - "Id": "IAM-07", - "Description": "De-provision or respectively modify access of movers / leavers or system identity changes in a timely manner in order to effectively adopt and communicate identity and access management policies.", - "Name": "User Access Changes and Revocation", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.3", - "6.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.18" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(1)", - "AC-2(2)", - "AC-2(6)", - "AC-2(8)", - "AC-3", - "AC-3(8)", - "AC-6", - "AC-6(7)", - "AU-10", - "AU-10(4)", - "AU-16", - "AU-16(1)", - "CM-7", - "CM-7(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4", - "PR.IP-11" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.RR-04", - "GV.SC-10", - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1.2", - "8.1.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.2.5", - "8.2.6" - ] - } - ] - } - ], - "Checks": [ - "entra_global_admin_in_less_than_five_users" - ] - }, - { - "Id": "IAM-08", - "Description": "Review and revalidate user access for least privilege and separation of duties with a frequency that is commensurate with organizational risk tolerance.", - "Name": "User Access Review", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-10" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.5", - "27001: A.9.2.6", - "27001: A.9.4.1", - "27017: 9.4.1", - "27001: A.6.1.2", - "27001: A 9.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.3", - "27001: A.5.18", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(4)", - "AC-6(8)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.5.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.5.1", - "7.2.5", - "7.2.4" - ] - } - ] - } - ], - "Checks": [ - "entra_global_admin_in_less_than_five_users", - "keyvault_non_rbac_secret_expiration_set", - "keyvault_rbac_secret_expiration_set", - "storage_key_rotation_90_days" - ] - }, - { - "Id": "IAM-09", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the segregation of privileged access roles such that administrative access to data, encryption and key management capabilities and logging capabilities are distinct and separated.", - "Name": "Segregation of Privileged Access Roles", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.1", - "CC6.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.3", - "27002: 9.2.3", - "27017: 9.2.3", - "27018: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.2", - "27001: A.8.18", - "27002: 8.2 (j)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-3(7)", - "AC-6(4)", - "AC-6(8)", - "IA-5", - "IA-5(6)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.3", - "3.5.2", - "7.1.2", - "7.1.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1", - "3.7.6", - "6.5.3", - "6.5.4", - "7.2.1", - "7.2.2", - "10.3.1" - ] - } - ] - } - ], - "Checks": [ - "entra_global_admin_in_less_than_five_users", - "entra_policy_guest_invite_only_for_admin_roles", - "entra_policy_guest_users_access_restrictions", - "iam_custom_role_has_permissions_to_administer_resource_locks", - "iam_subscription_roles_owner_custom_not_created" - ] - }, - { - "Id": "IAM-10", - "Description": "Define and implement an access process to ensure privileged access roles and rights are granted for a time limited period, and implement procedures to prevent the culmination of segregated privileged access.", - "Name": "Management of Privileged Access Roles", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1", - "6.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.3", - "27002: 9.2.3", - "27017: 9.2.3", - "27018: 9.2.3", - "27001: A.9.4.4", - "27002: 9.4.4", - "27017: 9.4.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.2", - "27001: A.8.18", - "27002: 8.2 (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(7)", - "AC-3", - "AC-3(4)", - "AC-3(11)", - "AC-3(13)", - "AC-3(14)", - "AC-6", - "AC-6(4)", - "AC-6(5)", - "AC-6(8)", - "AC-12", - "AC-12(3)", - "AC-17", - "AC-17(4)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "7.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "7.2.2" - ] - } - ] - } - ], - "Checks": [ - "entra_conditional_access_policy_require_mfa_for_management_api", - "entra_global_admin_in_less_than_five_users", - "entra_user_with_vm_access_has_mfa", - "iam_role_user_access_admin_restricted", - "iam_subscription_roles_owner_custom_not_created", - "vm_jit_access_enabled" - ] - }, - { - "Id": "IAM-12", - "Description": "Define, implement and evaluate processes, procedures and technical measures to ensure the logging infrastructure is read-only for all with write access, including privileged access roles, and that the ability to disable it is controlled through a procedure that ensures the segregation of duties and break glass procedures.", - "Name": "Safeguard Logs Integrity", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.3" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1", - "27018: 12.4.1", - "27001: A.12.4.2", - "27002: 12.4.2", - "27017: 12.4.2", - "27018: 12.4.2", - "27001: A.12.4.3", - "27002: 12.4.3", - "27017: 12.4.3", - "27018: 12.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15", - "27001: A.8.18", - "27002: 8.15 Protection of Logs" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(11)", - "AC-2(12)", - "IA-8", - "IA-8(4)", - "SA-8", - "SA-8(22)", - "SC-34", - "SC-34(1)", - "SC-34(2)", - "SC-36", - "SI-4", - "SI-4(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4" - ] - } - ] - } - ], - "Checks": [ - "entra_trusted_named_locations_exists", - "keyvault_logging_enabled", - "monitor_diagnostic_setting_with_appropriate_categories", - "monitor_storage_account_with_activity_logs_is_private" - ] - }, - { - "Id": "IAM-13", - "Description": "Define, implement and evaluate processes, procedures and technical measures that ensure users are identifiable through unique IDs or which can associate individuals to the usage of user IDs.", - "Name": "Uniquely Identifiable Users", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.1", - "27002: 9.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.16" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-3", - "AC-3(14)", - "AC-24", - "AC-24(2)", - "AU-10", - "AU-10(1)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(12)", - "IA-4", - "IA-4(1)", - "SA-8", - "SA-8(22)", - "SC-23", - "SC-23(3)", - "SC-40(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1", - "8.2", - "8.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.2.1", - "8.2.2", - "8.2.4" - ] - } - ] - } - ], - "Checks": [ - "entra_conditional_access_policy_require_mfa_for_management_api", - "entra_non_privileged_user_has_mfa", - "entra_privileged_user_has_mfa", - "entra_security_defaults_enabled", - "postgresql_flexible_server_entra_id_authentication_enabled", - "sqlserver_azuread_administrator_enabled" - ] - }, - { - "Id": "IAM-14", - "Description": "Define, implement and evaluate processes, procedures and technical measures for authenticating access to systems, application and data assets, including multifactor authentication for at least privileged user and sensitive data access. Adopt digital certificates or alternatives which achieve an equivalent level of security for system identities.", - "Name": "Strong Authentication", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.3", - "6.5", - "12.5", - "12.7" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3", - "SA1.4", - "SA1.8" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.1.2", - "27002: 9.1.2", - "27017: 9.1.2", - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27001: A.9.4.2", - "27002: 9.4.2", - "27017: 9.4.2", - "27018: 9.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.17", - "27001: A.8.5", - "27001: A.8.24", - "27002: 8.5", - "27002: 8.24 other information (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(5)", - "AC-7", - "AC-7(4)", - "AU-10", - "AU-10(2)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(8)", - "IA-2(12)", - "IA-3", - "IA-3(1)", - "IA-5", - "IA-5(2)", - "IA-5(7)", - "IA-5(9)", - "IA-5(10)", - "IA-5(12)", - "IA-5(14)-(16)", - "IA-8", - "IA-8(1)", - "IA-8(6)", - "SC-23", - "SC-23(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6", - "PR.AC-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1.2", - "8.1.3", - "8.1.6", - "8.2", - "8.3", - "8.3.2", - "12.3.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "8.3.1", - "8.3.2", - "8.4.1", - "8.4.2", - "8.4.3" - ] - } - ] - } - ], - "Checks": [ - "entra_non_privileged_user_has_mfa", - "entra_privileged_user_has_mfa", - "entra_security_defaults_enabled", - "entra_user_with_vm_access_has_mfa" - ] - }, - { - "Id": "IAM-15", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the secure management of passwords.", - "Name": "Passwords Management", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27018: 9.2.4", - "27001: A.9.3.1", - "27002: 9.3.1", - "27017: 9.3.1", - "27018: 9.3.1", - "27001: A.9.4.3", - "27002: 9.4.3", - "27017: 9.4.3", - "27018: 9.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.17" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IA-4", - "IA-4(8)", - "IA-5", - "IA-5(1)", - "IA-5(8)", - "IA-5(18)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.2", - "8.2.1-6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.2", - "2.3.1", - "8.3.5", - "8.3.6", - "8.3.7", - "8.3.8", - "8.3.9", - "8.3.10", - "8.3.10.1", - "8.6.2" - ] - } - ] - } - ], - "Checks": [ - "entra_privileged_user_has_mfa", - "entra_security_defaults_enabled" - ] - }, - { - "Id": "IAM-16", - "Description": "Define, implement and evaluate processes, procedures and technical measures to verify access to data and system functions is authorized.", - "Name": "Authorization Mechanisms", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3", - "SA1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.5", - "27002: 9.2.5", - "27017: 9.2.5", - "27018: 9.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.18" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-3", - "AC-3(5)", - "AC-4", - "AC-4(17)", - "AC-4(21)", - "AC-4(22)", - "AC-6", - "AC-6(8)", - "AC-6(9)", - "AC-12", - "AC-12(1)", - "AC-20", - "AC-20(1)", - "AU-10", - "AU-10(1)", - "AU-10(2)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(12)", - "IA-3", - "IA-3(1)", - "IA-5(1)", - "IA-5(2)", - "IA-5(5)", - "IA-5(8)", - "IA-5(10)", - "IA-5(12)", - "IA-8", - "IA-8(1)", - "IA-8(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4", - "PR.AC-6", - "PR.AC-7", - "PR.PT-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-03", - "PR.AA-04", - "PR.AA-05", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.3", - "7.1.4" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.4", - "7.2.3", - "7.2.5.1" - ] - } - ] - } - ], - "Checks": [ - "aks_cluster_rbac_enabled", - "app_function_identity_is_configured", - "app_function_identity_without_admin_privileges", - "app_function_not_publicly_accessible", - "entra_policy_user_consent_for_verified_apps", - "iam_subscription_roles_owner_custom_not_created" - ] - }, - { - "Id": "IPY-03", - "Description": "Implement cryptographically secure and standardized network protocols for the management, import and export of data.", - "Name": "Secure Interoperability and Portability Management", - "Attributes": [ - { - "Section": "Interoperability & Portability", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IPY-04" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY1.1", - "SY1.2", - "NC1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1", - "27001: A.15.1.1", - "27002: 15.1.1", - "27017: 15.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.19", - "27001: A.5.23", - "27001: A.5.31", - "27001: A.5.32", - "27001: A.5.33", - "27001: A.5.34" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PT-2", - "PT-2(2)", - "SA-4", - "SC-16", - "SC-16(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.2.1", - "1.2.5", - "1.2.6", - "2.2.4", - "2.2.5", - "2.2.7", - "4.2.1" - ] - } - ] - } - ], - "Checks": [ - "storage_ensure_azure_services_are_trusted_to_access_is_enabled", - "storage_secure_transfer_required_is_enabled" - ] - }, - { - "Id": "IVS-02", - "Description": "Plan and monitor the availability, quality, and adequate capacity of resources in order to deliver the required system performance as determined by the business.", - "Name": "Capacity and Resource Planning", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "No", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-04" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.3", - "27001: 6.1", - "27001: 9.1", - "27001: A.12.1.3", - "27002: 12.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.3 (b)", - "27001: 6.1", - "27001: 9.1", - "27001: A.8.6", - "27001: A.8.14" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2", - "CP-2(2)", - "SC-5", - "SC-5(2)", - "SC-4", - "SI-4" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-4", - "ID.BE-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.IR-04", - "GV.OC-04" - ] - } - ] - } - ], - "Checks": [ - "vm_scaleset_associated_with_load_balancer", - "vm_scaleset_not_empty" - ] - }, - { - "Id": "IVS-03", - "Description": "Monitor, encrypt and restrict communications between environments to only authenticated and authorized connections, as justified by the business. Review these configurations at least annually, and support them by a documented justification of all allowed services, protocols, ports, and compensating controls.", - "Name": "Network Security", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-06" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.8", - "3.1", - "12.2", - "13.6", - "13.9" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "NC1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.13.1.1", - "27002: 13.1.1", - "27001: A.13.1.2", - "27002: 13.1.2", - "27001: A.13.1.3", - "27002: 13.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.5.15", - "27001: A.5.37", - "27001: A.8.5", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.21", - "27001: A.8.22", - "27001: A.8.24", - "27002: A.5.15 2nd c)", - "27002: 8.20", - "27002: 8.21", - "27002: 8.22", - "27002: 8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-1", - "SC-4", - "SC-7", - "SC-7(4)", - "SC-7(5)", - "SC-7(8)", - "SC-7(9)", - "SC-7(11)", - "SC-8", - "SC-8(1)", - "SC-11", - "SC-12", - "SC-16", - "SC-23", - "SC-29", - "SC-29(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-5", - "PR.AC-7", - "PR.PT-4", - "DE.CM-1", - "DE.CM-7", - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.IR-01", - "PR.AA-03", - "PR.AA-05", - "DE.CM-01", - "PR.DS-02", - "ID.AM-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "1.1.6", - "1.2", - "1.2.3", - "2.2", - "4.1.1", - "10.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.2.5", - "1.2.6", - "1.2.7", - "1.4.2", - "2.2.4", - "2.2.5", - "2.2.7", - "4.2.1", - "10.1.1" - ] - } - ] - } - ], - "Checks": [ - "aks_clusters_created_with_private_nodes", - "aks_clusters_public_access_disabled", - "aks_network_policy_enabled", - "network_bastion_host_exists", - "network_flow_log_captured_sent", - "network_flow_log_more_than_90_days", - "network_http_internet_access_restricted", - "network_rdp_internet_access_restricted", - "network_ssh_internet_access_restricted", - "network_udp_internet_access_restricted", - "network_watcher_enabled" - ] - }, - { - "Id": "IVS-04", - "Description": "Harden host and guest OS, hypervisor or infrastructure control plane according to their respective best practices, and supported by technical controls, as part of a security baseline.", - "Name": "OS Hardening and Base Controls", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.8", - "CC7.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-07", - "IVS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "4.1", - "4.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3", - "5.2.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY1.1", - "SY1.3", - "SY1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.14.2.2", - "27002: 14.2.2", - "27001: A.14.2.3", - "27001 A.14.2.4", - "27018: 12.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.5.37", - "27001: A.8.5", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.22", - "27001: A.8.24", - "27002: 8.20", - "27002: 8.22", - "27002: 8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-6", - "CM-6(1)", - "SC-29", - "SC-29(1)", - "SC-2", - "SC-7", - "SC-7(12)", - "SC-30", - "SC-34", - "SC-35", - "SC-39", - "SC-44" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-1", - "PR.PT-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.1" - ] - } - ] - } - ], - "Checks": [ - "defender_assessments_vm_endpoint_protection_installed", - "defender_ensure_system_updates_are_applied", - "vm_ensure_using_managed_disks", - "vm_linux_enforce_ssh_authentication", - "vm_trusted_launch_enabled" - ] - }, - { - "Id": "IVS-06", - "Description": "Design, develop, deploy and configure applications and infrastructures such that CSP and CSC (tenant) user access and intra-tenant access is appropriately segmented and segregated, monitored and restricted from other tenants.", - "Name": "Segmentation and Segregation", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-09" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.3.4", - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SC2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.1", - "27001: A.13.1.3", - "27002: 13.1.3", - "27017: 13.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.1", - "27001: A.5.15", - "27001: A.5.20", - "27001: A.8.3", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.22", - "27002: 5.15 (b)", - "27002: 8.3 (b)", - "27002: 8.16 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-3", - "SC-7", - "SC-7(20)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.AC-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.IR-01", - "PR.PS-01", - "PR.PS-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.6", - "8.3.1", - "10.8", - "11.3", - "A3.2.1", - "A3.3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "A1.1.1", - "A1.1.2", - "A1.1.3" - ] - } - ] - } - ], - "Checks": [ - "app_function_not_publicly_accessible", - "app_function_vnet_integration_enabled", - "containerregistry_uses_private_link", - "cosmosdb_account_use_private_endpoints", - "databricks_workspace_vnet_injection_enabled", - "network_http_internet_access_restricted", - "storage_ensure_private_endpoints_in_storage_accounts" - ] - }, - { - "Id": "IVS-07", - "Description": "Use secure and encrypted communication channels when migrating servers, services, applications, or data to cloud environments. Such channels must include only up-to-date and approved protocols.", - "Name": "Migration to Cloud Environments", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-10" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.4", - "IM1.4", - "NC1.4", - "SC2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.13.1.1", - "27002: 13.1.1", - "27017: 13.1.1", - "27018: 13.1.1", - "27001: A.13.1.2", - "27002: 13.1.2", - "27017: 13.1.2", - "27018: 13.1.2", - "27001: A.13.1.3", - "27002: 13.1.3", - "27017: 13.1.3", - "27018: 13.1.3", - "27001: A.13.2.1", - "27002: 13.2.1", - "27017: 13.2.1", - "27018: 13.2.1", - "27001: A.13.2.2", - "27002: 13.2.2", - "27017: 13.2.2", - "27018: 13.2.2", - "27001: A.13.2.3", - "27002: 13.2.3", - "27017: 13.2.3", - "27018: 13.2.3", - "27001: A.13.2.4", - "27002: 13.2.4", - "27017: 13.2.4", - "27018: 13.2.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.8.20", - "27001: A.8.24", - "27002: 8.20 (e)", - "27002: 8.24 Guidance (b,f), other information (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-17", - "AC-20", - "SC-7", - "SC-7(28)", - "SC-8", - "SC-8(1)", - "SC-12", - "SC-23", - "SC-29", - "SI-7", - "SI-7(1)-(3)", - "SI-7(5)-(10)", - "SI-7(12)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2", - "PR.PT-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "4.2.1" - ] - } - ] - } - ], - "Checks": [ - "mysql_flexible_server_ssl_connection_enabled", - "postgresql_flexible_server_enforce_ssl_enabled" - ] - }, - { - "Id": "IVS-09", - "Description": "Define, implement and evaluate processes, procedures and defense-in-depth techniques for protection, detection, and timely response to network-based attacks.", - "Name": "Network Defense", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.6", - "CC6.8", - "CC7.1", - "CC7.2", - "CC7.5" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-13" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "13.3", - "13.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.3", - "5.2.4", - "5.2.5", - "5.2.7", - "5.3.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "NC1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1", - "27001: 6.2", - "27001: A.14.1.2", - "27002: 14.1.2", - "27017: 14.1.2", - "27001: A.11.1.4", - "27002: 11.1.4", - "27017: 11.1.4", - "27018: 16.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1", - "27001: 6.2", - "27001: A.5.24", - "27001: A.5.26", - "27001: A.8.8", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.21", - "27001: A.8.22", - "27001: A.8.26", - "27002: 8.8 (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-8", - "PL-8(1)", - "SC-5", - "SC-5(1)", - "SC-5(3)", - "SC-7", - "SC-7(13)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.DP-1", - "DE.CM-1", - "DE.CM-7", - "PR.AC-5", - "RS.MI-2", - "PR.DS-2", - "RS.RP-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-03", - "DE.CM-01", - "PR.IR-01", - "RS.MA-01", - "RS.MI-01", - "RS.MI-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.6", - "1.1", - "1.2", - "1.3", - "1.5", - "12.10.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.1.1", - "1.3.1", - "1.3.2", - "1.3.3", - "1.4.1", - "1.4.2", - "1.4.3", - "1.4.4", - "1.4.5", - "1.5.1", - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "defender_ensure_defender_for_arm_is_on", - "defender_ensure_defender_for_dns_is_on", - "defender_ensure_defender_for_server_is_on", - "defender_ensure_iot_hub_defender_is_on", - "defender_ensure_wdatp_is_enabled", - "network_flow_log_captured_sent", - "network_watcher_enabled", - "sqlserver_microsoft_defender_enabled", - "vm_jit_access_enabled" - ] - }, - { - "Id": "LOG-02", - "Description": "Define, implement and evaluate processes, procedures and technical measures to ensure the security and retention of audit logs.", - "Name": "Audit Logs Protection", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.1", - "8.9", - "8.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "3.1.3", - "5.1.2", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3", - "27002: 18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.28", - "27001: A.5.33", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-4", - "AU-11" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.IP-4", - "PR.IP-6", - "PR.PT-1", - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.DS-01", - "PR.DS-02", - "ID.AM-08", - "PR.DS-11", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5", - "10.7" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4", - "10.5.1" - ] - } - ] - } - ], - "Checks": [ - "keyvault_logging_enabled", - "monitor_diagnostic_setting_with_appropriate_categories", - "monitor_storage_account_with_activity_logs_cmk_encrypted", - "monitor_storage_account_with_activity_logs_is_private", - "storage_ensure_encryption_with_customer_managed_keys", - "storage_ensure_soft_delete_is_enabled" - ] - }, - { - "Id": "LOG-03", - "Description": "Identify and monitor security-related events within applications and the underlying infrastructure. Define and implement a system to generate alerts to responsible stakeholders based on such events and corresponding metrics.", - "Name": "Security Monitoring and Alerting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-03", - "SEF-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4", - "5.2.7", - "1.6.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2", - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.28", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-5", - "AU-5(2)", - "AU-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.AE-2", - "DE.AE-3", - "DE.AE-5", - "DE.CM-1", - "DE.CM-2", - "DE.CM-3", - "DE.CM-4", - "DE.CM-5", - "DE.CM-6", - "DE.CM-7", - "DE.DP-1", - "DE.DP-4", - "DE.AE-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.AE-02", - "DE.AE-03", - "DE.AE-04", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1", - "10.2.2", - "10.4.1.1", - "10.4.2.1", - "10.4.3" - ] - } - ] - } - ], - "Checks": [ - "defender_attack_path_notifications_properly_configured", - "defender_ensure_defender_for_app_services_is_on", - "defender_ensure_defender_for_azure_sql_databases_is_on", - "defender_ensure_defender_for_server_is_on", - "defender_ensure_notify_alerts_severity_is_high", - "defender_ensure_notify_emails_to_owners", - "defender_ensure_wdatp_is_enabled", - "monitor_alert_create_update_security_solution", - "monitor_alert_service_health_exists" - ] - }, - { - "Id": "LOG-04", - "Description": "Restrict audit logs access to authorized personnel and maintain records that provide unique access accountability.", - "Name": "Audit Logs Access and Accountability", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.14" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "3.1.1", - "4.1.2", - "4.1.3", - "4.2.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.2", - "27001: A.12.4.1", - "27002: 12.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.33", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(4)", - "AU-9(6)", - "AU-10" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.1", - "10.2.1", - "10.2.3", - "10.5.1", - "10.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1.3", - "10.3.1" - ] - } - ] - } - ], - "Checks": [ - "monitor_storage_account_with_activity_logs_is_private" - ] - }, - { - "Id": "LOG-05", - "Description": "Monitor security audit logs to detect activity outside of typical or expected patterns. Establish and follow a defined process to review and take appropriate and timely actions on detected anomalies.", - "Name": "Audit Logs Monitoring and Response", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.8", - "8.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.1", - "1.6.2", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.3", - "27002: 12.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15", - "27001: A.8.16" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-6", - "AU-6(1)", - "AU-6(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-3", - "PR.PT-1", - "RS.AN-1", - "RS.CO-1.", - "DE.AE-1", - "DE.AE-5", - "DE.DP-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-03", - "PR.PS-04", - "DE.AE-02", - "DE.AE-03", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.6", - "10.6.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.4.1.1", - "10.4.2.1" - ] - } - ] - } - ], - "Checks": [ - "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_alert_service_health_exists" - ] - }, - { - "Id": "LOG-07", - "Description": "Establish, document and implement which information meta/data system events should be logged. Review and update the scope at least annually or whenever there is a change in the threat environment.", - "Name": "Logging Scope", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5.3", - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5.3", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-1", - "AU-14", - "AU-16" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.SC-3", - "ID.SC-4", - "PR.PT-1", - "ID.GV-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1", - "10.2.2" - ] - } - ] - } - ], - "Checks": [ - "app_function_application_insights_enabled", - "appinsights_ensure_is_configured", - "monitor_diagnostic_setting_with_appropriate_categories", - "monitor_diagnostic_settings_exists", - "network_flow_log_captured_sent", - "network_flow_log_more_than_90_days" - ] - }, - { - "Id": "LOG-08", - "Description": "Generate audit records containing relevant security information.", - "Name": "Log Records", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-3", - "AU-3(1)", - "AU-3(3)", - "AU-6", - "AU-6(8)", - "AU-12", - "AU-12(1)", - "AU-12(2)", - "AU-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.PT-1", - "DE.AE-3", - "DE.CM-1", - "DE.CM-2", - "DE.CM-3", - "DE.CM-6", - "DE.CM-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.2" - ] - } - ] - } - ], - "Checks": [ - "app_function_application_insights_enabled", - "app_http_logs_enabled", - "appinsights_ensure_is_configured", - "keyvault_logging_enabled", - "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", - "postgresql_flexible_server_log_checkpoints_on", - "postgresql_flexible_server_log_connections_on", - "postgresql_flexible_server_log_disconnections_on", - "postgresql_flexible_server_log_retention_days_greater_3", - "sqlserver_auditing_enabled", - "sqlserver_auditing_retention_90_days" - ] - }, - { - "Id": "LOG-09", - "Description": "The information system protects audit records from unauthorized access, modification, and deletion.", - "Name": "Log Protection", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-04", - "IVS-01" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.2", - "27002: 12.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(2)", - "AU-9(3)", - "AU-9(4)", - "AU-12(3)", - "AU-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.IP-4", - "PR.IP-6", - "PR.PT-1", - "PR.DS-1", - "PR.DS-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.DS-01", - "PR.DS-02", - "PR.DS-11" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5", - "10.5.1", - "10.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4" - ] - } - ] - } - ], - "Checks": [ - "keyvault_logging_enabled", - "monitor_diagnostic_setting_with_appropriate_categories", - "monitor_storage_account_with_activity_logs_cmk_encrypted", - "monitor_storage_account_with_activity_logs_is_private", - "storage_ensure_encryption_with_customer_managed_keys" - ] - }, - { - "Id": "LOG-10", - "Description": "Establish and maintain a monitoring and internal reporting capability over the operations of cryptographic, encryption and key management policies, processes, procedures, and controls.", - "Name": "Encryption Monitoring and Reporting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-02", - "EKM-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1", - "27002: 10.1", - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-1", - "AU-9", - "AU-9(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "PR.PT-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.1.1", - "10.2.1", - "10.4.1" - ] - } - ] - } - ], - "Checks": [ - "keyvault_key_expiration_set_in_non_rbac", - "keyvault_key_rotation_enabled", - "keyvault_rbac_key_expiration_set" - ] - }, - { - "Id": "LOG-11", - "Description": "Log and monitor key lifecycle management events to enable auditing and reporting on usage of cryptographic keys.", - "Name": "Transaction/Activity Logging", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.PT-1", - "DE.AE-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-09" - ] - } - ] - } - ], - "Checks": [ - "monitor_diagnostic_setting_with_appropriate_categories", - "monitor_diagnostic_settings_exists" - ] - }, - { - "Id": "LOG-13", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the reporting of anomalies and failures of the monitoring system and provide immediate notification to the accountable party.", - "Name": "Failures and Anomalies Reporting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.3", - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.1", - "27002: 16.1.1", - "27001: A.16.1.2", - "27017: 16.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.24", - "27001: A.6.8", - "27002: 6.8 (g)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-5", - "AU-5(2)", - "AU-6", - "AU-6(3)", - "AU-6(4)", - "AU-6(5)", - "AU-16" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-3", - "DE.DP-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.AE-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.4.3", - "10.7.1", - "10.7.2", - "10.7.3" - ] - } - ] - } - ], - "Checks": [ - "defender_container_images_resolved_vulnerabilities", - "defender_ensure_defender_for_server_is_on", - "defender_ensure_notify_alerts_severity_is_high", - "defender_ensure_notify_emails_to_owners", - "defender_ensure_wdatp_is_enabled", - "monitor_alert_service_health_exists" - ] - }, - { - "Id": "SEF-03", - "Description": "'Establish, document, approve, communicate, apply, evaluate and maintain a security incident response plan, which includes but is not limited to: relevant internal departments, impacted CSCs, and other business critical relationships (such as supply-chain) that may be impacted.'", - "Name": "Incident Response Plans", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2", - "CC7.3", - "CC7.4" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "17.2", - "17.4" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27017: CLD.12.1.5", - "27018: 16.1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: A.5.26", - "27002: 5.26 (e,f)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IR-1", - "IR-2", - "IR-2(1)-(3)", - "IR-3", - "IR-3(1)-(3)", - "IR-4", - "IR-4(1)-(15)", - "IR-5", - "IR-5(1)", - "IR-6", - "IR-6(1)-(3)", - "IR-7", - "IR-7(1)", - "IR-7(2)", - "IR-8", - "IR-8(1)", - "IR-9", - "IR-9(1)-(4)", - "PM-12" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "RS.CO-1", - "RS.CO-4", - "ID.AM-6", - "ID.GV-2", - "ID.SC-5", - "PR.IP-9", - "PR.IP10" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AT-01", - "PR.AT-02", - "RS.MA-01", - "GV.SC-08", - "ID.IM-02", - "ID.IM-04", - "RC.RP-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.1", - "12.10.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1", - "12.10.5" - ] - } - ] - } - ], - "Checks": [ - "defender_attack_path_notifications_properly_configured", - "defender_ensure_defender_for_server_is_on", - "defender_ensure_notify_alerts_severity_is_high" - ] - }, - { - "Id": "SEF-06", - "Description": "Define, implement and evaluate processes, procedures and technical measures supporting business processes to triage security-related events.", - "Name": "Event Triage Processes", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.4", - "27002: 16.1.4", - "27017: 16.1.4", - "27018: 16.1.4", - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27018: 16.1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.25" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-7", - "CA-7(3)", - "CA-7(4)", - "CA-7(5)", - "CA-7(6)", - "IR-4", - "IR-4(1)", - "IR-4(3)", - "IR-4(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.AE-2", - "DE.AE-4", - "RS.RP-1", - "RS.AN-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "RS.MA-02", - "RS.MA-03", - "RS.AN-03", - "DE.AE-02", - "DE.AE-04", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "RS.MI-02", - "RC.RP-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "defender_ensure_defender_for_app_services_is_on", - "defender_ensure_defender_for_azure_sql_databases_is_on", - "defender_ensure_defender_for_databases_is_on", - "defender_ensure_defender_for_keyvault_is_on", - "defender_ensure_defender_for_server_is_on", - "defender_ensure_mcas_is_enabled", - "defender_ensure_wdatp_is_enabled" - ] - }, - { - "Id": "SEF-08", - "Description": "Maintain points of contact for applicable regulation authorities, national and local law enforcement, and other legal jurisdictional authorities.", - "Name": "Points of Contact Maintenance", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "17.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 4.2", - "27001: A.6.1.3", - "27002: 6.1.3", - "27017: 6.1.3", - "27018: 6.1.3", - "27001: A.16.1.1", - "27002: 16.1.1", - "27001: A.18.1.1", - "27002: 18.1.1", - "27017: 18.1.1", - "27018: 18.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.5", - "27001: A.5.24", - "27002: 5.24 Incident management procedure (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IR-4", - "IR-4(8)", - "IR-6", - "IR-6(3)", - "IR-7", - "IR-7(2)", - "PM-21", - "PM-23", - "PM-26" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-2", - "RS.CO-3", - "RS.CO-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.RR-02", - "RS.CO-02", - "RS.CO-03" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "defender_additional_email_configured_with_a_security_contact", - "defender_ensure_notify_emails_to_owners" - ] - }, - { - "Id": "TVM-02", - "Description": "Establish, document, approve, communicate, apply, evaluate and maintain policies and procedures to protect against malware on managed assets. Review and update the policies and procedures at least annually.", - "Name": "Malware Protection Policy and Procedures", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC6.8" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-01", - "GRM-06", - "GRM-09" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "9.7", - "10.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.1.1", - "1.5.1", - "5.2.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS1.2", - "TS1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5", - "27002: 5", - "27001: A.12.2.1", - "27001: A.6.2.1", - "27002: 6.2.1 (h)", - "27001: A.6.2.2", - "27002: 6.2.2 (j)", - "27001: A.7.2.2", - "27002: 7.2.2 (d)", - "27001: A.10.1.1", - "27002: 10.1.1 (g)", - "27001: A.13.2.1", - "27002: 13.2.1 (b)", - "27001: A.15.1.2", - "27017: 15.1.2", - "27001: A.12.2.1", - "27002: 12.2.1 (a),(d)", - "27017: CLD.9.5.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5.1", - "27001: A.5.4", - "27001: A.5.7", - "27001: A.5.37", - "27001: A.8.7", - "27002: 5.7 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-3", - "RA-3(3)", - "RA-5", - "RA-5(3)", - "RA-5(5)", - "SI-3", - "SI-3(4)", - "SI-3(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "DE.CM-4", - "DE.CM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.PO-01", - "GV.PO-02", - "ID.IM-03", - "DE.CM-01", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.4", - "12.1", - "12.1.1", - "12.3.1", - "12.5.1", - "12.11" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.1.1", - "12.1.2", - "5.1.1", - "5.3.2.1" - ] - } - ] - } - ], - "Checks": [ - "defender_assessments_vm_endpoint_protection_installed", - "defender_auto_provisioning_log_analytics_agent_vms_on", - "defender_ensure_defender_for_server_is_on", - "defender_ensure_wdatp_is_enabled" - ] - }, - { - "Id": "TVM-03", - "Description": "Define, implement and evaluate processes, procedures and technical measures to enable both scheduled and emergency responses to vulnerability identifications, based on the identified risk.", - "Name": "Vulnerability Remediation Schedule", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC7.1", - "CC7.4" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "7.2", - "7.7", - "17.9" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1", - "TM2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.12.2.1", - "27001: A.12.6.1", - "27002: 12.6.1(c)(d)(j)", - "27018: 12.6.1(k)(i)" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.8.7", - "27001: A.8.8", - "27001: A.8.32", - "27002: 8.7", - "27002: 8.8", - "27002: 8.32" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-31", - "RA-3", - "RA-3(1)", - "RA-5", - "RA-5(2)-(4)", - "RA-5(6)", - "SI-3", - "SI-3(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "RS.AN-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01", - "ID.RA-06", - "ID.RA-08", - "PR.PS-02", - "PR.PS-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "6.1.a", - "6.1.b" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.1.1", - "6.3.1", - "6.3.2", - "6.3.3", - "12.10.1" - ] - } - ] - } - ], - "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_ensure_system_updates_are_applied" - ] - }, - { - "Id": "TVM-04", - "Description": "Define, implement and evaluate processes, procedures and technical measures to update detection tools, threat signatures, and indicators of compromise on a weekly, or more frequent basis.", - "Name": "Detection Updates", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "No mapping" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "10.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS1.3", - "TS1.4", - "TM1.3", - "TM1.4", - "IM1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.5.1.1", - "27002: 5.1.1 (h)", - "27001: A.12.6.1", - "27002: 12.6.1 (b),(c)" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.5.1", - "27001: A.8.8", - "27001: A.8.15", - "27001: A.8.16", - "27002: 5.1", - "27002: 5.37", - "27002: 8.8", - "27002: 8.15 (d)", - "27002: 8.16 (d,e)", - "27002: 8.31 2nd (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-7", - "CM-7(4)", - "RA-3", - "RA-3(3)", - "RA-5(2)", - "SA-10", - "SA-10(5)", - "SA-11", - "SA-11(2)", - "SI-2", - "SI-2(4)", - "SI-3", - "SI-3(4)", - "SI-4", - "SI-4(9)", - "SI-4(24)", - "SI-8", - "SI-8(2)", - "SI-8(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-02", - "ID.RA-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.2", - "5.2a", - "5.2b", - "5.2c" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "5.3.1" - ] - } - ] - } - ], - "Checks": [ - "defender_auto_provisioning_vulnerabilty_assessments_machines_on", - "defender_container_images_scan_enabled", - "defender_ensure_defender_for_containers_is_on", - "defender_ensure_defender_for_cosmosdb_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_wdatp_is_enabled" - ] - }, - { - "Id": "TVM-05", - "Description": "Define, implement and evaluate processes, procedures and technical measures to identify updates for applications which use third party or open source libraries according to the organization's vulnerability management policy.", - "Name": "External Library Vulnerabilities", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC3.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "No mapping" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1", - "SD2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.12.6.2", - "27002: 12.6.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A 5.6", - "27001: A.8.19", - "27001: A.8.8", - "27001: A.8.28", - "27001: A.8.31", - "27002: 5.6 (c)", - "27001: 8.19", - "27001: 8.8", - "27001: 8.28", - "27001: 8.31" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-5", - "RA-5(3)", - "SA-11", - "SA-11(2)", - "SA-11(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01", - "ID.RA-03", - "PR.PS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "6.2", - "6.3.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "6.3.2", - "6.3.3" - ] - } - ] - } - ], - "Checks": [ - "defender_container_images_resolved_vulnerabilities", - "defender_container_images_scan_enabled", - "defender_ensure_defender_for_containers_is_on", - "sqlserver_va_emails_notifications_admins_enabled", - "sqlserver_va_periodic_recurring_scans_enabled", - "sqlserver_va_scan_reports_configured", - "sqlserver_vulnerability_assessment_enabled" - ] - }, - { - "Id": "TVM-07", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the detection of vulnerabilities on organizationally managed assets at least monthly.", - "Name": "Vulnerability Identification", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "7.1", - "7.5", - "7.6" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.5", - "5.2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.6", - "27001: A.12.6.1", - "27002: 12.6.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.8", - "27002: 8.8" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-5", - "RA-5(4)", - "RA-5(5)", - "SA-11", - "SA-11(5)", - "SA-15(5)", - "SC-7", - "SC-7(10)", - "SI-3(8)", - "SI-3(10)", - "SI-7", - "SI-7(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.RA-1", - "DE.CM-8", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "11.2", - "11.2.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "6.3.2", - "6.3.3", - "11.3.2", - "11.3.2.1" - ] - } - ] - } - ], - "Checks": [ - "defender_auto_provisioning_vulnerabilty_assessments_machines_on", - "defender_container_images_resolved_vulnerabilities", - "defender_container_images_scan_enabled", - "defender_ensure_defender_for_containers_is_on", - "defender_ensure_defender_for_server_is_on", - "defender_ensure_wdatp_is_enabled", - "sqlserver_microsoft_defender_enabled", - "sqlserver_vulnerability_assessment_enabled" - ] - }, - { - "Id": "UEM-08", - "Description": "Protect information from unauthorized disclosure on managed endpoint devices with storage encryption.", - "Name": "Storage Encryption", - "Attributes": [ - { - "Section": "Universal Endpoint Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "MOS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.6" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "3.1.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "PA1.2", - "PA1.3", - "PA1.5", - "PA2.2", - "PM1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.11.2.7", - "27002: 11.2.7", - "27001: A.18.1.1", - "27017: 18.1.1", - "27001: A.12.3.1", - "27017: 12.3.1", - "27018: A.11.4", - "27018: A.11.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.1", - "27002: 8.1 (h)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-19(5)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.4", - "3.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.5.1", - "3.6" - ] - } - ] - } - ], - "Checks": [ - "databricks_workspace_cmk_encryption_enabled", - "storage_infrastructure_encryption_is_enabled", - "vm_ensure_attached_disks_encrypted_with_cmk", - "vm_ensure_unattached_disks_encrypted_with_cmk" - ] - }, - { - "Id": "UEM-11", - "Description": "Configure managed endpoints with Data Loss Prevention (DLP) technologies and rules in accordance with a risk assessment.", - "Name": "Data Loss Prevention", - "Attributes": [ - { - "Section": "Universal Endpoint Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.13" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.5", - "PA2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.3", - "27002: 12.3", - "27001: A.8.3.1", - "27002: 8.3.1", - "27001: A.12.2", - "27002: 12.2", - "27001: A.18.1.3", - "27002: 18.1.3", - "27001: A.6.1.1", - "27017: 6.1.1", - "27018: 12.3.1", - "27018: 10.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.12", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-7", - "SC-7(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02", - "PR.DS-10", - "PR.PS-01", - "ID.AM-08", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A3.2.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "A3.2.6" - ] - } - ] - } - ], - "Checks": [ - "defender_ensure_defender_for_storage_is_on", - "defender_ensure_mcas_is_enabled" - ] - } - ] -} diff --git a/prowler/compliance/azure/hipaa_azure.json b/prowler/compliance/azure/hipaa_azure.json index 332d081590..70f1e7e13f 100644 --- a/prowler/compliance/azure/hipaa_azure.json +++ b/prowler/compliance/azure/hipaa_azure.json @@ -438,7 +438,9 @@ "storage_geo_redundant_enabled", "keyvault_recoverable", "sqlserver_auditing_retention_90_days", - "postgresql_flexible_server_log_retention_days_greater_3" + "postgresql_flexible_server_log_retention_days_greater_3", + "cosmosdb_account_backup_policy_continuous", + "mysql_flexible_server_geo_redundant_backup_enabled" ] }, { diff --git a/prowler/compliance/azure/iso27001_2022_azure.json b/prowler/compliance/azure/iso27001_2022_azure.json index 0051795890..ee0e4ca89b 100644 --- a/prowler/compliance/azure/iso27001_2022_azure.json +++ b/prowler/compliance/azure/iso27001_2022_azure.json @@ -1266,7 +1266,10 @@ "Check_Summary": "Backup copies of information, software and systems should be maintained and regularly tested in accordance with the agreed topic-specific policy on backup." } ], - "Checks": [] + "Checks": [ + "cosmosdb_account_backup_policy_continuous", + "mysql_flexible_server_geo_redundant_backup_enabled" + ] }, { "Id": "A.8.14", @@ -1293,7 +1296,10 @@ "storage_ensure_private_endpoints_in_storage_accounts", "storage_secure_transfer_required_is_enabled", "vm_ensure_using_managed_disks", - "vm_trusted_launch_enabled" + "vm_trusted_launch_enabled", + "cosmosdb_account_automatic_failover_enabled", + "mysql_flexible_server_geo_redundant_backup_enabled", + "mysql_flexible_server_high_availability_enabled" ] }, { @@ -1337,6 +1343,7 @@ } ], "Checks": [ + "defender_ensure_defender_cspm_is_on", "monitor_alert_create_policy_assignment", "monitor_alert_create_update_nsg", "monitor_alert_create_update_public_ip_address_rule", @@ -1407,6 +1414,9 @@ "aks_network_policy_enabled", "containerregistry_not_publicly_accessible", "cosmosdb_account_firewall_use_selected_networks", + "cosmosdb_account_public_network_access_disabled", + "databricks_workspace_no_public_ip_enabled", + "databricks_workspace_public_network_access_disabled", "network_bastion_host_exists", "network_flow_log_captured_sent", "network_flow_log_more_than_90_days", @@ -1500,6 +1510,7 @@ ], "Checks": [ "app_minimum_tls_version_12", + "cosmosdb_account_minimum_tls_version", "monitor_storage_account_with_activity_logs_cmk_encrypted", "sqlserver_tde_encrypted_with_cmk", "sqlserver_tde_encryption_enabled", diff --git a/prowler/compliance/csa_ccm_4.0.json b/prowler/compliance/csa_ccm_4.0.json index e014c5f408..57f1a67758 100644 --- a/prowler/compliance/csa_ccm_4.0.json +++ b/prowler/compliance/csa_ccm_4.0.json @@ -1087,7 +1087,9 @@ "storage_blob_versioning_is_enabled", "storage_geo_redundant_enabled", "vm_scaleset_associated_with_load_balancer", - "vm_scaleset_not_empty" + "vm_scaleset_not_empty", + "cosmosdb_account_automatic_failover_enabled", + "cosmosdb_account_backup_policy_continuous" ], "gcp": [ "compute_instance_automatic_restart_enabled", diff --git a/prowler/compliance/dora_2022_2554.json b/prowler/compliance/dora_2022_2554.json new file mode 100644 index 0000000000..1bc4ed41da --- /dev/null +++ b/prowler/compliance/dora_2022_2554.json @@ -0,0 +1,842 @@ +{ + "framework": "DORA", + "name": "Digital Operational Resilience Act (Regulation (EU) 2022/2554)", + "version": "2022/2554", + "description": "The Digital Operational Resilience Act (DORA) is a European Union regulation (Regulation (EU) 2022/2554) that sets a uniform framework for the digital operational resilience of the EU financial sector. Mandatory since 17 January 2025, it applies to financial entities (banks, insurers, investment firms, payment institutions, etc.) and to ICT third-party service providers. DORA is structured around five pillars: ICT risk management, ICT-related incident reporting, digital operational resilience testing, ICT third-party risk management, and information sharing. This Prowler mapping covers the technical controls auditable from cloud configuration; the organisational, contractual and supervisory obligations defined in DORA must be addressed outside of Prowler.", + "icon": "dora", + "attributes_metadata": [ + { + "key": "Pillar", + "label": "Pillar", + "type": "str", + "required": true, + "enum": [ + "ICT Risk Management", + "ICT-Related Incident Reporting", + "Digital Operational Resilience Testing", + "ICT Third-Party Risk Management", + "Information Sharing" + ], + "output_formats": { + "csv": true, + "ocsf": true + } + }, + { + "key": "Article", + "label": "Article", + "type": "str", + "required": true, + "output_formats": { + "csv": true, + "ocsf": true + } + }, + { + "key": "ArticleTitle", + "label": "Article Title", + "type": "str", + "required": true, + "output_formats": { + "csv": true, + "ocsf": true + } + } + ], + "outputs": { + "table_config": { + "group_by": "Pillar" + }, + "pdf_config": { + "language": "en", + "primary_color": "#003399", + "secondary_color": "#0055A5", + "bg_color": "#F0F4FA", + "group_by_field": "Pillar", + "sections": [ + "ICT Risk Management", + "ICT-Related Incident Reporting", + "Digital Operational Resilience Testing", + "ICT Third-Party Risk Management", + "Information Sharing" + ], + "section_short_names": { + "ICT Risk Management": "ICT Risk Mgmt", + "ICT-Related Incident Reporting": "Incident Reporting", + "Digital Operational Resilience Testing": "Resilience Testing", + "ICT Third-Party Risk Management": "Third-Party Risk", + "Information Sharing": "Info Sharing" + }, + "charts": [ + { + "id": "pillar_compliance", + "type": "horizontal_bar", + "group_by": "Pillar", + "title": "Compliance Score by DORA Pillar", + "y_label": "Pillar", + "x_label": "Compliance %", + "value_source": "compliance_percent", + "color_mode": "by_value" + } + ], + "filter": { + "only_failed": true, + "include_manual": false + } + } + }, + "requirements": [ + { + "id": "DORA-Art5", + "name": "Governance and organisation", + "description": "Financial entities shall have a sound, comprehensive and well-documented ICT internal governance and control framework. Senior management is accountable for ICT risk and shall enforce strong identity, authentication and least-privilege policies for privileged identities, including the root account.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 5", + "ArticleTitle": "Governance and organisation" + }, + "checks": { + "aws": [ + "iam_avoid_root_usage", + "iam_no_root_access_key", + "iam_root_mfa_enabled", + "iam_root_hardware_mfa_enabled", + "iam_root_credentials_management_enabled", + "iam_password_policy_minimum_length_14", + "iam_password_policy_lowercase", + "iam_password_policy_uppercase", + "iam_password_policy_number", + "iam_password_policy_symbol", + "iam_password_policy_reuse_24", + "iam_password_policy_expires_passwords_within_90_days_or_less", + "iam_securityaudit_role_created", + "iam_support_role_created", + "organizations_account_part_of_organizations", + "iam_user_mfa_enabled_console_access", + "iam_user_hardware_mfa_enabled" + ], + "azure": [ + "entra_global_admin_in_less_than_five_users", + "entra_privileged_user_has_mfa", + "entra_non_privileged_user_has_mfa", + "entra_user_with_vm_access_has_mfa", + "entra_security_defaults_enabled", + "entra_conditional_access_policy_require_mfa_for_admin_portals", + "entra_conditional_access_policy_require_mfa_for_management_api", + "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_users_cannot_create_microsoft_365_groups", + "iam_subscription_roles_owner_custom_not_created", + "iam_role_user_access_admin_restricted", + "iam_custom_role_has_permissions_to_administer_resource_locks" + ] + } + }, + { + "id": "DORA-Art6", + "name": "ICT risk management framework", + "description": "Financial entities shall have an ICT risk management framework that is sound, comprehensive and well-documented, enabling them to address ICT risk quickly, efficiently and comprehensively and to ensure a high level of digital operational resilience. This includes continuous configuration recording, security findings aggregation and an enterprise-wide visibility plane.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 6", + "ArticleTitle": "ICT risk management framework" + }, + "checks": { + "aws": [ + "config_recorder_all_regions_enabled", + "config_recorder_using_aws_service_role", + "securityhub_enabled", + "accessanalyzer_enabled", + "accessanalyzer_enabled_without_findings", + "organizations_delegated_administrators", + "guardduty_centrally_managed", + "guardduty_delegated_admin_enabled_all_regions" + ], + "azure": [ + "defender_ensure_defender_for_server_is_on", + "defender_ensure_defender_for_containers_is_on", + "defender_ensure_defender_for_storage_is_on", + "defender_ensure_defender_for_app_services_is_on", + "defender_ensure_defender_for_azure_sql_databases_is_on", + "defender_ensure_defender_for_sql_servers_is_on", + "defender_ensure_defender_for_databases_is_on", + "defender_ensure_defender_for_os_relational_databases_is_on", + "defender_ensure_defender_for_keyvault_is_on", + "defender_ensure_defender_for_arm_is_on", + "defender_ensure_defender_for_dns_is_on", + "defender_ensure_defender_for_cosmosdb_is_on", + "defender_ensure_mcas_is_enabled", + "defender_ensure_wdatp_is_enabled", + "defender_auto_provisioning_log_analytics_agent_vms_on", + "policy_ensure_asc_enforcement_enabled" + ] + } + }, + { + "id": "DORA-Art7", + "name": "ICT systems, protocols and tools", + "description": "Financial entities shall use and maintain updated ICT systems, protocols and tools that are appropriate to the magnitude of operations supporting ICT functions, technologically resilient, and adequately equipped to securely process data. Cryptographic primitives, certificate hygiene and network segmentation are core to this requirement.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 7", + "ArticleTitle": "ICT systems, protocols and tools" + }, + "checks": { + "aws": [ + "acm_certificates_with_secure_key_algorithms", + "acm_certificates_transparency_logs_enabled", + "acm_certificates_expiration_check", + "ec2_ebs_default_encryption", + "kms_cmk_rotation_enabled", + "s3_bucket_secure_transport_policy", + "s3_bucket_default_encryption", + "s3_bucket_kms_encryption", + "vpc_subnet_separate_private_public", + "vpc_subnet_no_public_ip_by_default", + "elb_insecure_ssl_ciphers", + "elbv2_insecure_ssl_ciphers", + "elb_ssl_listeners", + "elbv2_ssl_listeners", + "cloudfront_distributions_using_deprecated_ssl_protocols", + "cloudfront_distributions_https_enabled", + "rds_instance_transport_encrypted" + ], + "azure": [ + "storage_ensure_minimum_tls_version_12", + "storage_secure_transfer_required_is_enabled", + "storage_smb_channel_encryption_with_secure_algorithm", + "storage_smb_protocol_version_is_latest", + "app_minimum_tls_version_12", + "app_ensure_http_is_redirected_to_https", + "app_ensure_using_http20", + "sqlserver_recommended_minimal_tls_version", + "mysql_flexible_server_minimum_tls_version_12", + "mysql_flexible_server_ssl_connection_enabled", + "postgresql_flexible_server_enforce_ssl_enabled", + "keyvault_key_rotation_enabled", + "storage_key_rotation_90_days", + "aks_network_policy_enabled" + ] + } + }, + { + "id": "DORA-Art8", + "name": "Identification", + "description": "Financial entities shall identify, classify and adequately document all ICT supported business functions, roles and responsibilities, the information assets and ICT assets supporting them, and their interdependencies. They shall on a continuous basis identify all sources of ICT risk, in particular the risk exposure to and from other financial entities.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 8", + "ArticleTitle": "Identification" + }, + "checks": { + "aws": [ + "accessanalyzer_enabled", + "accessanalyzer_enabled_without_findings", + "macie_is_enabled", + "macie_automated_sensitive_data_discovery_enabled", + "ec2_securitygroup_not_used", + "ec2_elastic_ip_unassigned", + "ec2_networkacl_unused", + "secretsmanager_secret_unused" + ], + "azure": [ + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "network_watcher_enabled", + "network_public_ip_shodan", + "vm_scaleset_not_empty" + ] + } + }, + { + "id": "DORA-Art9", + "name": "Protection and prevention", + "description": "Financial entities shall continuously monitor and control the security and functioning of ICT systems and tools and minimise the impact of ICT risk by deploying appropriate ICT security tools, policies and procedures. Encryption at rest and in transit, blocking of public exposure, network access controls, secret management and instance hardening are central to this article.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 9", + "ArticleTitle": "Protection and prevention" + }, + "checks": { + "aws": [ + "kms_key_not_publicly_accessible", + "ec2_ebs_volume_encryption", + "ec2_ebs_snapshots_encrypted", + "ec2_ebs_public_snapshot", + "ec2_ebs_snapshot_account_block_public_access", + "s3_account_level_public_access_blocks", + "s3_bucket_level_public_access_block", + "s3_bucket_public_access", + "s3_bucket_policy_public_write_access", + "s3_bucket_public_write_acl", + "s3_bucket_public_list_acl", + "s3_bucket_acl_prohibited", + "s3_access_point_public_access_block", + "ec2_securitygroup_default_restrict_traffic", + "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_high_risk_tcp_ports", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22", + "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_3389", + "rds_instance_storage_encrypted", + "rds_cluster_storage_encrypted", + "rds_instance_no_public_access", + "rds_snapshots_public_access", + "secretsmanager_not_publicly_accessible", + "secretsmanager_has_restrictive_resource_policy", + "secretsmanager_automatic_rotation_enabled", + "dynamodb_tables_kms_cmk_encryption_enabled", + "sns_topics_kms_encryption_at_rest_enabled", + "sns_topics_not_publicly_accessible", + "ec2_instance_imdsv2_enabled", + "ec2_instance_account_imdsv2_enabled", + "efs_encryption_at_rest_enabled", + "awslambda_function_not_publicly_accessible" + ], + "azure": [ + "storage_account_public_network_access_disabled", + "storage_blob_public_access_level_is_disabled", + "storage_default_network_access_rule_is_denied", + "storage_ensure_private_endpoints_in_storage_accounts", + "storage_ensure_encryption_with_customer_managed_keys", + "storage_infrastructure_encryption_is_enabled", + "storage_account_key_access_disabled", + "storage_default_to_entra_authorization_enabled", + "containerregistry_not_publicly_accessible", + "containerregistry_uses_private_link", + "cosmosdb_account_use_private_endpoints", + "cosmosdb_account_firewall_use_selected_networks", + "keyvault_private_endpoints", + "keyvault_access_only_through_private_endpoints", + "keyvault_rbac_enabled", + "app_function_not_publicly_accessible", + "aisearch_service_not_publicly_accessible", + "aks_clusters_public_access_disabled", + "aks_clusters_created_with_private_nodes", + "sqlserver_unrestricted_inbound_access", + "postgresql_flexible_server_allow_access_services_disabled", + "vm_ensure_attached_disks_encrypted_with_cmk", + "vm_ensure_unattached_disks_encrypted_with_cmk", + "vm_ensure_using_managed_disks", + "vm_trusted_launch_enabled", + "vm_linux_enforce_ssh_authentication", + "vm_jit_access_enabled", + "sqlserver_tde_encryption_enabled", + "sqlserver_tde_encrypted_with_cmk", + "databricks_workspace_cmk_encryption_enabled", + "network_ssh_internet_access_restricted", + "network_rdp_internet_access_restricted", + "network_http_internet_access_restricted", + "network_udp_internet_access_restricted", + "network_bastion_host_exists" + ] + } + }, + { + "id": "DORA-Art10", + "name": "Detection", + "description": "Financial entities shall have in place mechanisms to promptly detect anomalous activities, including ICT network performance issues and ICT-related incidents, and to identify potential single points of failure. Threat detection across compute, identity, storage and the API control plane is required for timely detection.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 10", + "ArticleTitle": "Detection" + }, + "checks": { + "aws": [ + "guardduty_is_enabled", + "guardduty_no_high_severity_findings", + "guardduty_ec2_malware_protection_enabled", + "guardduty_lambda_protection_enabled", + "guardduty_rds_protection_enabled", + "guardduty_s3_protection_enabled", + "guardduty_eks_audit_log_enabled", + "guardduty_eks_runtime_monitoring_enabled", + "securityhub_enabled", + "cloudtrail_threat_detection_enumeration", + "cloudtrail_threat_detection_llm_jacking", + "cloudtrail_threat_detection_privilege_escalation", + "cloudtrail_insights_exist", + "inspector2_is_enabled", + "inspector2_active_findings_exist", + "ec2_elastic_ip_shodan" + ], + "azure": [ + "defender_ensure_defender_for_server_is_on", + "defender_ensure_defender_for_containers_is_on", + "defender_ensure_defender_for_storage_is_on", + "defender_ensure_defender_for_keyvault_is_on", + "defender_ensure_defender_for_arm_is_on", + "defender_ensure_defender_for_dns_is_on", + "defender_ensure_defender_for_azure_sql_databases_is_on", + "defender_ensure_defender_for_sql_servers_is_on", + "defender_ensure_wdatp_is_enabled", + "defender_ensure_mcas_is_enabled", + "defender_container_images_scan_enabled", + "defender_container_images_resolved_vulnerabilities", + "sqlserver_microsoft_defender_enabled", + "apim_threat_detection_llm_jacking" + ] + } + }, + { + "id": "DORA-Art11", + "name": "Response and recovery", + "description": "Financial entities shall put in place a comprehensive ICT business continuity policy, including ICT response and recovery plans, that ensures the continuity of ICT-supported critical or important functions. Operational alarming, automated event routing and tested recovery actions are essential.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 11", + "ArticleTitle": "Response and recovery" + }, + "checks": { + "aws": [ + "cloudwatch_alarm_actions_enabled", + "cloudwatch_alarm_actions_alarm_state_configured", + "eventbridge_global_endpoint_event_replication_enabled", + "sns_subscription_not_using_http_endpoints", + "backup_plans_exist", + "backup_vaults_exist", + "rds_instance_critical_event_subscription", + "rds_cluster_critical_event_subscription" + ], + "azure": [ + "monitor_alert_service_health_exists", + "monitor_alert_create_update_security_solution", + "monitor_alert_delete_security_solution", + "defender_additional_email_configured_with_a_security_contact", + "defender_ensure_notify_alerts_severity_is_high", + "defender_ensure_notify_emails_to_owners", + "defender_attack_path_notifications_properly_configured", + "vm_backup_enabled", + "vm_sufficient_daily_backup_retention_period" + ] + } + }, + { + "id": "DORA-Art12", + "name": "Backup policies and procedures, restoration and recovery procedures and methods", + "description": "Financial entities shall develop and document backup policies and procedures specifying the scope of data subject to backup and the minimum frequency of the backup, as well as restoration and recovery procedures and methods. Backups must be encrypted, retained, and resources must be designed for recoverability across availability zones and regions.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 12", + "ArticleTitle": "Backup policies and procedures, restoration and recovery procedures and methods" + }, + "checks": { + "aws": [ + "backup_plans_exist", + "backup_vaults_exist", + "backup_vaults_encrypted", + "backup_recovery_point_encrypted", + "backup_reportplans_exist", + "rds_instance_backup_enabled", + "rds_cluster_protected_by_backup_plan", + "rds_instance_protected_by_backup_plan", + "rds_instance_multi_az", + "rds_cluster_multi_az", + "rds_cluster_backtrack_enabled", + "rds_instance_deletion_protection", + "rds_cluster_deletion_protection", + "rds_snapshots_encrypted", + "s3_bucket_object_versioning", + "s3_bucket_object_lock", + "s3_bucket_cross_region_replication", + "s3_bucket_no_mfa_delete", + "dynamodb_tables_pitr_enabled", + "dynamodb_table_deletion_protection_enabled", + "ec2_ebs_volume_protected_by_backup_plan", + "ec2_ebs_volume_snapshots_exists", + "autoscaling_group_multiple_az", + "elb_is_in_multiple_az", + "elbv2_is_in_multiple_az", + "cloudfront_distributions_multiple_origin_failover_configured", + "dynamodb_table_protected_by_backup_plan" + ], + "azure": [ + "vm_backup_enabled", + "vm_sufficient_daily_backup_retention_period", + "vm_ensure_using_managed_disks", + "storage_ensure_soft_delete_is_enabled", + "storage_ensure_file_shares_soft_delete_is_enabled", + "storage_blob_versioning_is_enabled", + "storage_geo_redundant_enabled", + "keyvault_recoverable" + ] + } + }, + { + "id": "DORA-Art13", + "name": "Learning and evolving", + "description": "Financial entities shall have in place capabilities and staff to gather information on vulnerabilities and cyber threats, perform post ICT-related incident reviews, and continuously feed lessons learnt back into the ICT risk assessment process. Findings aggregation and continuous insights drive this cycle.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 13", + "ArticleTitle": "Learning and evolving" + }, + "checks": { + "aws": [ + "securityhub_enabled", + "guardduty_no_high_severity_findings", + "inspector2_active_findings_exist", + "accessanalyzer_enabled_without_findings", + "cloudtrail_insights_exist" + ], + "azure": [ + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "defender_assessments_vm_endpoint_protection_installed", + "defender_container_images_resolved_vulnerabilities", + "defender_container_images_scan_enabled", + "defender_ensure_system_updates_are_applied", + "sqlserver_vulnerability_assessment_enabled", + "sqlserver_va_periodic_recurring_scans_enabled", + "sqlserver_va_scan_reports_configured" + ] + } + }, + { + "id": "DORA-Art14", + "name": "Communication", + "description": "As part of the ICT risk management framework, financial entities shall have in place crisis communication plans enabling a responsible disclosure of ICT-related incidents or major vulnerabilities to clients, counterparts and the public. Reliable, encrypted and access-controlled notification channels are required.", + "attributes": { + "Pillar": "ICT Risk Management", + "Article": "Article 14", + "ArticleTitle": "Communication" + }, + "checks": { + "aws": [ + "sns_topics_kms_encryption_at_rest_enabled", + "sns_topics_not_publicly_accessible", + "sns_subscription_not_using_http_endpoints", + "eventbridge_bus_exposed", + "eventbridge_bus_cross_account_access", + "eventbridge_schema_registry_cross_account_access", + "cloudwatch_alarm_actions_enabled", + "cloudwatch_alarm_actions_alarm_state_configured" + ], + "azure": [ + "defender_additional_email_configured_with_a_security_contact", + "defender_ensure_notify_emails_to_owners", + "defender_ensure_notify_alerts_severity_is_high", + "defender_attack_path_notifications_properly_configured", + "monitor_alert_service_health_exists" + ] + } + }, + { + "id": "DORA-Art17", + "name": "ICT-related incident management process", + "description": "Financial entities shall define, establish and implement an ICT-related incident management process to detect, manage and notify ICT-related incidents. Comprehensive trail logging, log integrity protection, retention and centralisation of ICT events are foundational requirements.", + "attributes": { + "Pillar": "ICT-Related Incident Reporting", + "Article": "Article 17", + "ArticleTitle": "ICT-related incident management process" + }, + "checks": { + "aws": [ + "cloudtrail_multi_region_enabled", + "cloudtrail_multi_region_enabled_logging_management_events", + "cloudtrail_kms_encryption_enabled", + "cloudtrail_log_file_validation_enabled", + "cloudtrail_cloudwatch_logging_enabled", + "cloudtrail_logs_s3_bucket_access_logging_enabled", + "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", + "cloudtrail_s3_dataevents_read_enabled", + "cloudtrail_s3_dataevents_write_enabled", + "cloudtrail_bucket_requires_mfa_delete", + "cloudtrail_bedrock_logging_enabled", + "cloudwatch_log_group_retention_policy_specific_days_enabled", + "cloudwatch_log_group_kms_encryption_enabled", + "cloudwatch_log_group_no_secrets_in_logs", + "cloudwatch_log_group_not_publicly_accessible", + "vpc_flow_logs_enabled", + "ec2_client_vpn_endpoint_connection_logging_enabled", + "route53_public_hosted_zones_cloudwatch_logging_enabled", + "elb_logging_enabled", + "elbv2_logging_enabled", + "cloudfront_distributions_logging_enabled", + "s3_bucket_server_access_logging_enabled" + ], + "azure": [ + "monitor_diagnostic_settings_exists", + "monitor_diagnostic_setting_with_appropriate_categories", + "monitor_storage_account_with_activity_logs_is_private", + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "network_flow_log_captured_sent", + "network_flow_log_more_than_90_days", + "keyvault_logging_enabled", + "sqlserver_auditing_enabled", + "sqlserver_auditing_retention_90_days", + "mysql_flexible_server_audit_log_enabled", + "mysql_flexible_server_audit_log_connection_activated", + "postgresql_flexible_server_log_checkpoints_on", + "postgresql_flexible_server_log_connections_on", + "postgresql_flexible_server_log_disconnections_on", + "postgresql_flexible_server_log_retention_days_greater_3", + "app_http_logs_enabled", + "app_function_application_insights_enabled", + "appinsights_ensure_is_configured" + ] + } + }, + { + "id": "DORA-Art18", + "name": "Classification of ICT-related incidents and cyber threats", + "description": "Financial entities shall classify ICT-related incidents and shall determine their impact based on criteria such as the number of clients affected, duration, geographical spread, data losses, and criticality of the services affected. Severity-aware threat detection across the estate underpins this classification.", + "attributes": { + "Pillar": "ICT-Related Incident Reporting", + "Article": "Article 18", + "ArticleTitle": "Classification of ICT-related incidents and cyber threats" + }, + "checks": { + "aws": [ + "guardduty_no_high_severity_findings", + "guardduty_centrally_managed", + "guardduty_delegated_admin_enabled_all_regions", + "securityhub_enabled", + "inspector2_active_findings_exist", + "accessanalyzer_enabled_without_findings", + "cloudtrail_threat_detection_enumeration", + "cloudtrail_threat_detection_llm_jacking", + "cloudtrail_threat_detection_privilege_escalation" + ], + "azure": [ + "defender_ensure_notify_alerts_severity_is_high", + "defender_attack_path_notifications_properly_configured", + "defender_ensure_defender_for_server_is_on", + "defender_ensure_wdatp_is_enabled", + "defender_ensure_mcas_is_enabled", + "sqlserver_microsoft_defender_enabled", + "apim_threat_detection_llm_jacking" + ] + } + }, + { + "id": "DORA-Art19", + "name": "Reporting of major ICT-related incidents and voluntary notification of significant cyber threats", + "description": "Financial entities shall report major ICT-related incidents to the relevant competent authority and may, on a voluntary basis, notify significant cyber threats. Detective metric filters, change-tracking alarms and reliable notification topics are needed to surface and route reportable events.", + "attributes": { + "Pillar": "ICT-Related Incident Reporting", + "Article": "Article 19", + "ArticleTitle": "Reporting of major ICT-related incidents and voluntary notification of significant cyber threats" + }, + "checks": { + "aws": [ + "cloudwatch_log_metric_filter_authentication_failures", + "cloudwatch_log_metric_filter_unauthorized_api_calls", + "cloudwatch_log_metric_filter_root_usage", + "cloudwatch_log_metric_filter_sign_in_without_mfa", + "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", + "cloudwatch_log_metric_filter_aws_organizations_changes", + "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_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", + "sns_subscription_not_using_http_endpoints" + ], + "azure": [ + "monitor_alert_create_policy_assignment", + "monitor_alert_delete_policy_assignment", + "monitor_alert_create_update_nsg", + "monitor_alert_delete_nsg", + "monitor_alert_create_update_security_solution", + "monitor_alert_delete_security_solution", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_sqlserver_fr", + "monitor_alert_create_update_public_ip_address_rule", + "monitor_alert_delete_public_ip_address_rule", + "monitor_alert_service_health_exists", + "defender_additional_email_configured_with_a_security_contact" + ] + } + }, + { + "id": "DORA-Art24", + "name": "General requirements for the performance of digital operational resilience testing", + "description": "Financial entities shall establish, maintain and review a sound and comprehensive digital operational resilience testing programme, as an integral part of the ICT risk management framework. Continuous vulnerability discovery, configuration assessment and instance manageability are foundational.", + "attributes": { + "Pillar": "Digital Operational Resilience Testing", + "Article": "Article 24", + "ArticleTitle": "General requirements for the performance of digital operational resilience testing" + }, + "checks": { + "aws": [ + "inspector2_is_enabled", + "inspector2_active_findings_exist", + "securityhub_enabled", + "ec2_instance_managed_by_ssm", + "ec2_instance_with_outdated_ami", + "ssm_managed_compliant_patching" + ], + "azure": [ + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "defender_ensure_system_updates_are_applied", + "defender_container_images_scan_enabled", + "defender_assessments_vm_endpoint_protection_installed", + "sqlserver_vulnerability_assessment_enabled", + "sqlserver_va_periodic_recurring_scans_enabled", + "vm_ensure_using_approved_images", + "vm_desired_sku_size" + ] + } + }, + { + "id": "DORA-Art25", + "name": "Testing of ICT tools and systems", + "description": "Financial entities shall ensure that tests are undertaken on ICT tools and systems, on critical ICT systems supporting all critical or important functions, at least yearly. Vulnerability assessments, deprecated component detection and certificate hygiene must be tracked.", + "attributes": { + "Pillar": "Digital Operational Resilience Testing", + "Article": "Article 25", + "ArticleTitle": "Testing of ICT tools and systems" + }, + "checks": { + "aws": [ + "inspector2_is_enabled", + "inspector2_active_findings_exist", + "guardduty_is_enabled", + "guardduty_no_high_severity_findings", + "config_recorder_all_regions_enabled", + "ec2_instance_with_outdated_ami", + "ec2_instance_managed_by_ssm", + "ec2_instance_paravirtual_type", + "rds_instance_deprecated_engine_version", + "acm_certificates_expiration_check", + "rds_instance_certificate_expiration", + "iam_no_expired_server_certificates_stored", + "ssm_managed_compliant_patching" + ], + "azure": [ + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "defender_container_images_resolved_vulnerabilities", + "sqlserver_vulnerability_assessment_enabled", + "sqlserver_va_periodic_recurring_scans_enabled", + "sqlserver_va_scan_reports_configured", + "sqlserver_va_emails_notifications_admins_enabled", + "keyvault_key_expiration_set_in_non_rbac", + "keyvault_rbac_key_expiration_set", + "keyvault_non_rbac_secret_expiration_set", + "keyvault_rbac_secret_expiration_set", + "app_ensure_java_version_is_latest", + "app_ensure_php_version_is_latest", + "app_ensure_python_version_is_latest", + "app_function_latest_runtime_version", + "storage_smb_protocol_version_is_latest" + ] + } + }, + { + "id": "DORA-Art28", + "name": "General principles (ICT third-party risk)", + "description": "Financial entities shall manage ICT third-party risk as an integral component of ICT risk within their ICT risk management framework. Cross-account access, trust boundaries, organization-level controls and dependency visibility are critical to monitor third-party exposure on AWS.", + "attributes": { + "Pillar": "ICT Third-Party Risk Management", + "Article": "Article 28", + "ArticleTitle": "General principles (ICT third-party risk)" + }, + "checks": { + "aws": [ + "iam_role_cross_service_confused_deputy_prevention", + "iam_role_cross_account_readonlyaccess_policy", + "iam_no_custom_policy_permissive_role_assumption", + "accessanalyzer_enabled", + "accessanalyzer_enabled_without_findings", + "s3_bucket_cross_account_access", + "dynamodb_table_cross_account_access", + "eventbridge_bus_cross_account_access", + "eventbridge_schema_registry_cross_account_access", + "cloudwatch_cross_account_sharing_disabled", + "organizations_delegated_administrators", + "organizations_account_part_of_organizations", + "organizations_scp_check_deny_regions", + "vpc_endpoint_connections_trust_boundaries", + "vpc_endpoint_services_allowed_principals_trust_boundaries", + "vpc_peering_routing_tables_with_least_privilege", + "awslambda_function_using_cross_account_layers" + ], + "azure": [ + "entra_policy_guest_users_access_restrictions", + "entra_policy_guest_invite_only_for_admin_roles", + "entra_policy_restricts_user_consent_for_apps", + "entra_policy_user_consent_for_verified_apps", + "storage_cross_tenant_replication_disabled", + "containerregistry_uses_private_link", + "cosmosdb_account_use_private_endpoints", + "keyvault_access_only_through_private_endpoints", + "aks_clusters_created_with_private_nodes" + ] + } + }, + { + "id": "DORA-Art30", + "name": "Key contractual provisions", + "description": "Contractual arrangements with ICT third-party service providers shall be set out in writing and include, at minimum, agreed service levels and clear allocation of rights and obligations. Privilege boundaries, least-privilege policies and absence of administrative wildcards are the technical guardrails that enforce these contractual constraints inside AWS.", + "attributes": { + "Pillar": "ICT Third-Party Risk Management", + "Article": "Article 30", + "ArticleTitle": "Key contractual provisions" + }, + "checks": { + "aws": [ + "iam_aws_attached_policy_no_administrative_privileges", + "iam_customer_attached_policy_no_administrative_privileges", + "iam_customer_unattached_policy_no_administrative_privileges", + "iam_inline_policy_no_administrative_privileges", + "iam_inline_policy_allows_privilege_escalation", + "iam_policy_allows_privilege_escalation", + "iam_inline_policy_no_full_access_to_cloudtrail", + "iam_inline_policy_no_full_access_to_kms", + "iam_policy_no_full_access_to_cloudtrail", + "iam_policy_no_full_access_to_kms", + "iam_role_administratoraccess_policy", + "iam_user_administrator_access_policy", + "iam_group_administrator_access_policy", + "iam_administrator_access_with_mfa", + "iam_policy_attached_only_to_group_or_roles", + "accessanalyzer_enabled" + ], + "azure": [ + "iam_subscription_roles_owner_custom_not_created", + "iam_role_user_access_admin_restricted", + "iam_custom_role_has_permissions_to_administer_resource_locks", + "entra_global_admin_in_less_than_five_users", + "app_function_identity_without_admin_privileges", + "entra_policy_default_users_cannot_create_security_groups", + "entra_policy_ensure_default_user_cannot_create_apps" + ] + } + }, + { + "id": "DORA-Art45", + "name": "Information-sharing arrangements on cyber threat information and intelligence", + "description": "Financial entities may exchange amongst themselves cyber threat information and intelligence, including indicators of compromise, tactics, techniques and procedures, cyber security alerts and configuration tools. Centralised threat detection, sensitive data discovery and trail-based intelligence enable participation in such information-sharing arrangements.", + "attributes": { + "Pillar": "Information Sharing", + "Article": "Article 45", + "ArticleTitle": "Information-sharing arrangements on cyber threat information and intelligence" + }, + "checks": { + "aws": [ + "guardduty_is_enabled", + "guardduty_centrally_managed", + "securityhub_enabled", + "macie_is_enabled", + "macie_automated_sensitive_data_discovery_enabled", + "cloudtrail_threat_detection_enumeration", + "cloudtrail_threat_detection_llm_jacking", + "cloudtrail_threat_detection_privilege_escalation", + "accessanalyzer_enabled_without_findings" + ], + "azure": [ + "defender_ensure_mcas_is_enabled", + "defender_ensure_wdatp_is_enabled", + "defender_ensure_defender_for_server_is_on", + "defender_attack_path_notifications_properly_configured", + "sqlserver_microsoft_defender_enabled", + "apim_threat_detection_llm_jacking" + ] + } + } + ] +} diff --git a/prowler/compliance/gcp/ccc_gcp.json b/prowler/compliance/gcp/ccc_gcp.json index df6b15bc5c..67a75841ef 100644 --- a/prowler/compliance/gcp/ccc_gcp.json +++ b/prowler/compliance/gcp/ccc_gcp.json @@ -889,7 +889,7 @@ } ], "Checks": [ - "kms_key_rotation_enabled" + "kms_key_rotation_max_90_days" ] }, { diff --git a/prowler/compliance/gcp/cis_2.0_gcp.json b/prowler/compliance/gcp/cis_2.0_gcp.json index d391bcc271..dd9eb62a96 100644 --- a/prowler/compliance/gcp/cis_2.0_gcp.json +++ b/prowler/compliance/gcp/cis_2.0_gcp.json @@ -150,7 +150,7 @@ "Id": "1.10", "Description": "Google Cloud Key Management Service stores cryptographic keys in a hierarchical structure designed for useful and elegant access control management. The format for the rotation schedule depends on the client library that is used. For the gcloud command-line tool, the next rotation time must be in `ISO` or `RFC3339` format, and the rotation period must be in the form `INTEGERUNIT`, where units can be one of seconds (s), minutes (m), hours (h) or days (d).", "Checks": [ - "kms_key_rotation_enabled" + "kms_key_rotation_max_90_days" ], "Attributes": [ { diff --git a/prowler/compliance/gcp/cis_3.0_gcp.json b/prowler/compliance/gcp/cis_3.0_gcp.json index b8d796c35a..958a2a9bc3 100644 --- a/prowler/compliance/gcp/cis_3.0_gcp.json +++ b/prowler/compliance/gcp/cis_3.0_gcp.json @@ -201,7 +201,7 @@ "Id": "1.10", "Description": "Ensure KMS Encryption Keys Are Rotated Within a Period of 90 Days", "Checks": [ - "kms_key_rotation_enabled" + "kms_key_rotation_max_90_days" ], "Attributes": [ { diff --git a/prowler/compliance/gcp/cis_4.0_gcp.json b/prowler/compliance/gcp/cis_4.0_gcp.json index 5cd97a87da..d7a3c5d7a2 100644 --- a/prowler/compliance/gcp/cis_4.0_gcp.json +++ b/prowler/compliance/gcp/cis_4.0_gcp.json @@ -201,7 +201,7 @@ "Id": "1.10", "Description": "Ensure KMS Encryption Keys Are Rotated Within a Period of 90 Days", "Checks": [ - "kms_key_rotation_enabled" + "kms_key_rotation_max_90_days" ], "Attributes": [ { diff --git a/prowler/compliance/gcp/csa_ccm_4.0_gcp.json b/prowler/compliance/gcp/csa_ccm_4.0_gcp.json deleted file mode 100644 index 6623fe5eca..0000000000 --- a/prowler/compliance/gcp/csa_ccm_4.0_gcp.json +++ /dev/null @@ -1,7386 +0,0 @@ -{ - "Framework": "CSA-CCM", - "Name": "CSA Cloud Controls Matrix (CCM) v4.0.13", - "Version": "4.0", - "Provider": "GCP", - "Description": "The Cloud Security Alliance (CSA) Cloud Controls Matrix (CCM) is a cybersecurity control framework for cloud computing, composed of 197 control objectives structured in 17 domains covering all key aspects of cloud technology. The CCM can be used as a tool for the systematic assessment of a cloud implementation, and provides guidance on which security controls should be implemented by which actor within the cloud supply chain.", - "Requirements": [ - { - "Id": "A&A-02", - "Description": "Conduct independent audit and assurance assessments according to relevant standards at least annually.", - "Name": "Independent Assessments", - "Attributes": [ - { - "Section": "Audit & Assurance", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC4.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AAC-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.5.2", - "5.2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "AS1.1", - "AS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.2.1", - "27002: 18.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.35", - "27001: A.5.36" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-2", - "CA-2(1)", - "CA-2(2)", - "CA-7", - "CA-7(1)" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-01" - ] - } - ] - } - ], - "Checks": [ - "iam_audit_logs_enabled" - ] - }, - { - "Id": "A&A-04", - "Description": "Verify compliance with all relevant standards, regulations, legal/contractual, and statutory requirements applicable to the audit.", - "Name": "Requirements Compliance", - "Attributes": [ - { - "Section": "Audit & Assurance", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC3.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-01", - "GRM-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "7.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "AS1.1", - "AS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.3.2", - "27001: A.18.2.2", - "27002: 18.2.2", - "27001: A.18.2.3", - "27002: 18.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.3.2", - "27001: A.5.31", - "27001: A.5.32", - "27001: A.5.33", - "27001: A.5.34", - "27001: A.5.36" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-1" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-3", - "DE.DP-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-01" - ] - } - ] - } - ], - "Checks": [ - "iam_audit_logs_enabled", - "iam_cloud_asset_inventory_enabled" - ] - }, - { - "Id": "AIS-04", - "Description": "Define and implement a SDLC process for application design, development, deployment, and operation in accordance with security requirements defined by the organization.", - "Name": "Secure Application Design and Development", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AIS-01", - "AIS-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.4", - "5.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.1.1", - "27002: 14.1.1", - "27017: 14.1.1", - "27001: A.14.1.2", - "27002: 14.1.2", - "27017: 14.1.2", - "27001: A.14.2.1", - "27002: 14.2.1", - "27017: 14.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.8", - "27001: A.8.25", - "27001: A.8.26", - "27001: A.8.28" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-2", - "PL-8", - "PL-8(1)", - "SA-3", - "SA-3(1)", - "SA-4", - "SA-4(2)", - "SA-4(3)", - "SA-4(8)", - "SA-4(9)", - "SA-5", - "SA-8", - "SA-8(1)-(7)", - "SA-8(9)-(13)", - "SA-8(15)-(20)", - "SA-8(22)", - "SA-8(24)-(28)", - "SA-8(30)-(33)", - "SA-17", - "SA-17(1)-(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-6", - "PR.DS-7", - "PR.IP-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "PR.IR-01", - "PR.PS-01", - "PR.PS-02", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.1", - "6.2.3", - "6.5.2" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "AIS-05", - "Description": "Implement a testing strategy, including criteria for acceptance of new information systems, upgrades and new versions, which provides application security assurance and maintains compliance while enabling organizational speed of delivery goals. Automate when applicable and possible.", - "Name": "Automated Application Security Testing", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AIS-01", - "AIS-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.12", - "16.13" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD2.3", - "SD2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.2.8", - "27001: A.14.2.9", - "27001: A.12.1.2", - "27002: 12.1.2", - "27001: A.14.1.1", - "27002: 14.1.1", - "27001: A.14.2.2", - "27002: 14.2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.25", - "27001: A.8.29", - "27001: A.8.32", - "27002: 8.25 (e)", - "27002: 8.32 (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SA-11", - "SA-11(1)-(9)", - "SI-6", - "SI-6(2)", - "SI-6(3)", - "SI-10", - "SI-10(1)-(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.PT-3", - "PR.IP-12", - "DE.CM-8" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "ID.RA-01", - "PR.PS-01", - "PR.PS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A.3.2.2", - "A.3.2.2.1", - "6.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.4", - "6.4.1", - "6.4.2", - "6.5.1" - ] - } - ] - } - ], - "Checks": [ - "gcr_container_scanning_enabled", - "artifacts_container_analysis_enabled" - ] - }, - { - "Id": "AIS-07", - "Description": "Define and implement a process to remediate application security vulnerabilities, automating remediation when possible.", - "Name": "Application Vulnerability Remediation", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.1", - "CC7.4", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.2", - "16.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27001: A.12.6.1", - "27002: 12.6.1", - "27017: 12.6.1", - "27018: 12.6.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.26", - "27001: A.8.8", - "27002: 5.26 (j)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SI-2", - "SI-2(2)-(6)", - "SA-11", - "SA-11(2)", - "SA-15", - "SA-15(1)-(3)", - "SA-15(5)-(8)", - "SA-15(10)-(12)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.IP-12", - "DE.CM-8", - "RS.AN-5", - "RS.MI-3", - "PR.DS-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "ID.RA-01", - "ID.RA-06", - "ID.RA-08", - "PR.PS-02", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.2", - "6.5", - "6.5.1-10" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "11.3.1", - "11.3.1.1" - ] - } - ] - } - ], - "Checks": [ - "gcr_container_scanning_enabled", - "artifacts_container_analysis_enabled" - ] - }, - { - "Id": "BCR-08", - "Description": "Periodically backup data stored in the cloud. Ensure the confidentiality, integrity and availability of the backup, and verify data restoration from backup for resiliency.", - "Name": "Backup", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "A1.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "11.1", - "11.2", - "11.3", - "11.4", - "11.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8", - "5.2.9" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.3", - "27017: 12.3", - "27018: 12.3.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.13", - "27001: A.5.23", - "27001: A.5.30", - "27002: 8.13", - "27002: 5.23 2nd (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-4", - "CP-4(4)", - "CP-6", - "CP-6(1)-(3)", - "CP-9", - "CP-9(1)", - "CP-9(2)", - "CP-10", - "CP-10(2)", - "CP-10(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-4", - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-11", - "RC.RP-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "9.5.1", - "12.10.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1", - "10.3.3" - ] - } - ] - } - ], - "Checks": [ - "cloudsql_instance_automated_backups", - "cloudstorage_bucket_versioning_enabled", - "cloudstorage_bucket_soft_delete_enabled" - ] - }, - { - "Id": "BCR-09", - "Description": "Establish, document, approve, communicate, apply, evaluate and maintain a disaster response plan to recover from natural and man-made disasters. Update the plan at least annually or upon significant changes.", - "Name": "Disaster Response Plan", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "CC3.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8", - "5.2.9", - "1.6.1", - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "BC1.4", - "BC2.1", - "BC2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.29", - "27001: A.5.30", - "27002: 5.29", - "27002: 5.30" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2(1)", - "CP-2(2)", - "CP-2(3)", - "CP-2(5)", - "CP-2(6)", - "CP-2(7)", - "CP-2(8)", - "PE-13", - "PE-13(1)", - "PE-13(2)", - "PE-13(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-9", - "PR.IP-10", - "RC.IM-1", - "RC.IM-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-04" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "BCR-11", - "Description": "Supplement business-critical equipment with redundant equipment independently located at a reasonable minimum distance in accordance with applicable industry standards.", - "Name": "Equipment Redundancy", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "No", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "CC3.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-06" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "BC1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.20", - "27001: A.7.11", - "27001: A.8.14", - "27002: 5.20 (t)", - "27002: 8.14 (c)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2", - "CP-2(2)", - "CP-4(3)", - "CP-6", - "CP-6(1)", - "CP-7", - "CP-8", - "CP-8(1)-(3)", - "CP-9", - "CP-9(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.BE-4", - "ID.BE-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.OC-04", - "GV.OC-05", - "PR.IR-03" - ] - } - ] - } - ], - "Checks": [ - "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" - ] - }, - { - "Id": "CCC-04", - "Description": "Restrict the unauthorized addition, removal, update, and management of organization assets.", - "Name": "Unauthorized Change Protection", - "Attributes": [ - { - "Section": "Change Control and Configuration Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "CCC-04" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.1", - "1.3.4", - "5.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.4", - "SM2.6" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.1.4", - "27002: 12.1.4", - "27001: A.12.4.2", - "27002: 12.4.2", - "27001: A.14.2.2", - "27017: 14.2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.3", - "27001: A.8.4", - "27001: A.8.15", - "27001: A.8.31", - "27001: A.8.32" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-7", - "CA-7(4)", - "CM-3", - "CM-3(1)", - "CM-3(5)", - "CM-3(7)", - "CM-3(8)", - "CM-5", - "CM-5(1)", - "CM-5(4)", - "CM-5(5)", - "CM-6", - "CM-6(1)", - "CM-6(2)", - "CM-7", - "CM-7(1)", - "CM-7(4)", - "CM-7(5)", - "CM-7(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-1", - "ID.AM-2", - "ID.AM-4", - "PR.MA-1", - "PR.MA-2", - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-01", - "ID.AM-02", - "ID.AM-04", - "ID.AM-08", - "PR.PS-02", - "PR.PS-03", - "PR.PS-05", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.5.1", - "6.5.2" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_bucket_log_retention_policy_lock", - "iam_audit_logs_enabled", - "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", - "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled" - ] - }, - { - "Id": "CCC-07", - "Description": "Implement detection measures with proactive notification in case of changes deviating from the established baseline.", - "Name": "Detection of Baseline Deviation", - "Attributes": [ - { - "Section": "Change Control and Configuration Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-01" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.5.1", - "1.5.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.2.2", - "27001: A.14.2.4", - "27001: A.12.4.1", - "27002: 12.4.1 (g)", - "27001: A.5.1.1", - "27017: 5.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.9", - "27001: A.8.15", - "27002: 8.9" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-6", - "CM-6(2)", - "SI-2", - "SI-2(2)-(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.MA-1", - "PR.IP-1", - "DE.DP-4", - "PR.IP-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-01", - "DE.CM-09", - "DE.AE-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4.5.3", - "6.4.5.4", - "11.5", - "11.5.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "11.5.2", - "11.6.1" - ] - } - ] - } - ], - "Checks": [ - "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" - ] - }, - { - "Id": "CEK-03", - "Description": "Provide cryptographic protection to data at-rest and in-transit, using cryptographic libraries certified to approved standards.", - "Name": "Data Encryption", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-03", - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.6", - "3.1", - "3.11", - "11.3", - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.1", - "27001: A.18.1.2", - "27001: A.18.1.3", - "27001: A.18.1.4", - "27001: A.18.1.5", - "27001: A.10.1", - "27002: 10.1", - "27001: A.13.2.1", - "27002: 13.2.1", - "27001: A.18", - "27002: 18", - "27001: A.14.1.2", - "27002: 14.1.2", - "27001: A.14.1.3", - "27002 14.1.3 c)", - "27001 - A.10.1.1", - "27017 - 10.1.1", - "27001 - A.10.1.2", - "27017 - 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.8.24", - "27002: 8.24 Other Information (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-19", - "AC-19(5)", - "SC-8", - "SC-8(1)", - "SC-8(3)", - "SC-8(4)", - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-28", - "SC-28(1)-(3)", - "SI-4", - "SI-4(10)", - "SI-7", - "SI-7(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "Requirement 3", - "2.2.3", - "2.3", - "3.4", - "3.5.3", - "4.1", - "8.2.1", - "PCI Glossary - Strong Cryptography" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.7", - "3.5.1", - "4.2.1", - "4.2.1.2", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "compute_instance_encryption_with_csek_enabled", - "compute_instance_confidential_computing_enabled", - "bigquery_dataset_cmk_encryption", - "bigquery_table_cmk_encryption", - "cloudsql_instance_ssl_connections", - "dataproc_encrypted_with_cmks_disabled" - ] - }, - { - "Id": "CEK-04", - "Description": "Use encryption algorithms that are appropriate for data protection, considering the classification of data, associated risks, and usability of the encryption technology.", - "Name": "Encryption Algorithm", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.2", - "27001: 6.1.3", - "27001: A.8.2", - "27002: 8.2", - "27001: A.8.3", - "27001: A.10.1.1", - "27002: 10.1.1 (b)", - "27001: A.10.1.2", - "27002: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.2", - "27001: 6.1.3", - "27001: A.8.24", - "27001: A.5.12", - "27001: A.5.13", - "27002: 8.24 General (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2", - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02", - "ID.AM-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A2", - "Requirement 3", - "2.3", - "2.2.3", - "3.4", - "3.5.3", - "4.1", - "8.2.1", - "PCI Glossary - Strong Cryptography" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.7", - "3.5.1", - "4.2.1", - "4.2.1.2", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "dns_rsasha1_in_use_to_key_sign_in_dnssec", - "dns_rsasha1_in_use_to_zone_sign_in_dnssec" - ] - }, - { - "Id": "CEK-08", - "Description": "CSPs must provide the capability for CSCs to manage their own data encryption keys.", - "Name": "CSC Key Management Capability", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2", - "SC2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1", - "27017: 10.1", - "27001: A.10.1.1", - "27017: 10.1.1", - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.23", - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-9", - "CP-9(8)", - "SA-9", - "SA-9(6)", - "SC-12", - "SC-12(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.SC-3", - "ID.AM-6", - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.SC-05" - ] - } - ] - } - ], - "Checks": [ - "bigquery_dataset_cmk_encryption", - "bigquery_table_cmk_encryption", - "compute_instance_encryption_with_csek_enabled", - "dataproc_encrypted_with_cmks_disabled", - "kms_key_not_publicly_accessible", - "kms_key_rotation_enabled" - ] - }, - { - "Id": "CEK-10", - "Description": "Generate Cryptographic keys using industry accepted cryptographic libraries specifying the algorithm strength and the random number generator used.", - "Name": "Key Generation", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2", - "TS2.3", - "SY1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27002: 10.1.1 (e)", - "27017: 10.1.1", - "27001: A.10.1.2", - "27002: 10.1.2", - "27002: 10.1.2 (a)", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24", - "27002: 8.24 (d), Key management (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.2.3", - "3.6.1", - "PCI Glossary - Cryptographic Key Generation" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1", - "3.6.1.1", - "3.7.1" - ] - } - ] - } - ], - "Checks": [ - "bigquery_dataset_cmk_encryption", - "bigquery_table_cmk_encryption", - "dataproc_encrypted_with_cmks_disabled" - ] - }, - { - "Id": "CEK-12", - "Description": "Rotate cryptographic keys in accordance with the calculated cryptoperiod, which includes provisions for considering the risk of information disclosure and legal and regulatory requirements.", - "Name": "Key Rotation", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27017: 10.1.1", - "27001: A.10.1.2", - "27002: 10.1.2 e)", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.31", - "27001: A.8.24", - "27002: 5.31 Cryptography", - "27002: 8.24 Key management (e,m)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "ID.GV-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05", - "GV.OC-03" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.7.4", - "3.7.5" - ] - } - ] - } - ], - "Checks": [ - "kms_key_rotation_enabled", - "iam_sa_user_managed_key_rotate_90_days", - "apikeys_key_rotated_in_90_days" - ] - }, - { - "Id": "CEK-14", - "Description": "Define, implement and evaluate processes, procedures and technical measures to destroy keys stored outside a secure environment and revoke keys stored in Hardware Security Modules (HSMs) when they are no longer needed, which include provisions for legal and regulatory requirements.", - "Name": "Key Destruction", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27017: 10.1.1", - "27017: 10.1.2", - "27001: A.10.1.2", - "27002: 10.1.2 (j)", - "27001: A.18.1.3", - "27002: 18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.31", - "27001: A.8.24", - "27002: 5.31 Cryptography", - "27002: 8.24 Key management (j,m)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.IP-6", - "ID.GV-3", - "PR.DS-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05", - "ID.AM-08", - "GV.OC-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.6.4", - "3.6.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.7.4", - "3.7.5" - ] - } - ] - } - ], - "Checks": [ - "iam_role_kms_enforce_separation_of_duties" - ] - }, - { - "Id": "DCS-06", - "Description": "Catalogue and track all relevant physical and logical assets located at all of the CSP's sites within a secured system.", - "Name": "Assets Cataloguing and Tracking", - "Attributes": [ - { - "Section": "Datacenter Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DCS - 01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "1.1", - "2.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SM2.6" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.1.1", - "27002: 8.1.1", - "27017: 8.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.9" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-8", - "CM-8(1)", - "CM-8(2)", - "CM-8(4)", - "CM-8(7)", - "CM-8(8)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-1", - "ID.AM-2", - "ID.AM-4", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-01", - "ID.AM-02", - "ID.AM-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.4", - "9.7.1", - "9.9.1", - "9.9.1.a", - "9.9.1.b", - "9.9.1.c", - "12.3.3", - "12.3.4" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1.1", - "6.3.2", - "9.4.2", - "9.4.3", - "12.5.1" - ] - } - ] - } - ], - "Checks": [ - "iam_cloud_asset_inventory_enabled" - ] - }, - { - "Id": "DSP-02", - "Description": "Apply industry accepted methods for the secure disposal of data from storage media such that data is not recoverable by any forensic means.", - "Name": "Secure Disposal", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3", - "CC6.4", - "CC6.5", - "CC6.7", - "P4.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DSI-07" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.3.3", - "7.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.3.2", - "27002: 8.3.2", - "27001: A.11.2.7", - "27002: 11.2.7" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.7.10", - "27001: A.7.14", - "27001: A.8.10", - "27002: 7.10 (Secure reuse or disposal)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-22", - "SI-12", - "SI-12(3)", - "SI-18", - "SI-18(1)", - "SI-18(4)", - "SI-18(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.SC-10", - "PR.PS-02", - "PR.PS-03", - "ID.AM-08" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.1", - "9.8", - "9.8.1", - "9.8.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1", - "3.7.5", - "9.4.7" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_bucket_lifecycle_management_enabled" - ] - }, - { - "Id": "DSP-03", - "Description": "Create and maintain a data inventory, at least for any sensitive data and personal data.", - "Name": "Data Inventory", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1", - "1.3.2", - "1.3.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.1.1", - "27002: 8.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.9", - "27001: A.8.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-12", - "CM-12(1)", - "PM-5", - "PM-5(1)", - "SI-12", - "SI-12(1)", - "SI-19", - "SI-19(1)", - "SI-19(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1", - "9.4.5" - ] - } - ] - } - ], - "Checks": [ - "iam_cloud_asset_inventory_enabled", - "iam_audit_logs_enabled" - ] - }, - { - "Id": "DSP-04", - "Description": "Classify data according to its type and sensitivity level.", - "Name": "Data Classification", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "C1.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DSI-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.7" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1", - "1.3.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.2.1", - "27002: 8.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-16", - "AC-16(9)", - "PM-22", - "PM-23", - "PT-2", - "PT-2(1)", - "SI-18", - "SI-18(2)", - "SI-19", - "SI-19(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-05", - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "9.6.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "9.4.2", - "9.4.3" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "DSP-07", - "Description": "Develop systems, products, and business practices based upon a principle of security by design and industry best practices.", - "Name": "Data Protection by Design and Default", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "PI1.2", - "PI1.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.3.1", - "5.3.2", - "5.3.3", - "5.3.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD2.2", - "IM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.1.1", - "27002:14.1.1", - "27001: A.14.2.5", - "27002:14.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.27", - "27001: A.8.28", - "27001: A.8.29", - "27002: 5.8 (Information security requirements a-i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-17", - "PM-24", - "PM-25", - "PT-2", - "PT-2(2)", - "SA-3", - "SA-4", - "SA-5", - "SA-8", - "SA-8(9)", - "SA-8(13)", - "SA-8(18)", - "SA-8(20)", - "SA-8(22)", - "SA-8(23)", - "SA-8(33)", - "SA-15", - "SA-15(12)", - "SC-3", - "SC-3(3)", - "SC-7", - "SC-7(24)", - "SC-8", - "SC-8(1)-(4)", - "SC-28", - "SC-28(1)", - "SI-12", - "SI-12(1)-(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.PT-3", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.1" - ] - } - ] - } - ], - "Checks": [ - "bigquery_dataset_public_access", - "cloudsql_instance_public_access", - "cloudsql_instance_public_ip", - "cloudstorage_bucket_public_access", - "compute_image_not_publicly_shared", - "compute_instance_public_ip", - "kms_key_not_publicly_accessible" - ] - }, - { - "Id": "DSP-10", - "Description": "Define, implement and evaluate processes, procedures and technical measures that ensure any transfer of personal or sensitive data is protected from unauthorized access and only processed within scope as permitted by the respective laws and regulations.", - "Name": "Sensitive Data Transfer", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-02", - "EKM-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.1", - "3.12", - "3.13" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "9.5.1", - "9.5.2", - "9.5.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.4", - "IM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.13.2.1", - "27002: 13.2.1", - "27001: A.8.3.3", - "27002: 8.3.3", - "27001: A.13.2.3", - "27002: 13.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.7.10" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-4", - "AC-4(23)-(25)", - "CA-3", - "CA-3(6)", - "CA-6", - "CA-6(1)", - "CA-6(2)", - "SC-4", - "SC-4(2)", - "SC-7", - "SC-7(10)", - "SC-7(24)", - "SC-8", - "SC-8(1)-(5)", - "SC-16", - "SC-16(1)-(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2", - "PR.DS-5", - "PR.PT-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02", - "PR.IR-01", - "ID.AM-03", - "GV.OC-03", - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "4.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "4.1.1", - "4.2.1", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "cloudsql_instance_ssl_connections" - ] - }, - { - "Id": "DSP-16", - "Description": "Data retention, archiving and deletion is managed in accordance with business requirements, applicable laws and regulations.", - "Name": "Data Retention and Deletion", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "C1.1", - "C1.2", - "CC3.1", - "P4.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-02", - "BCR-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.4", - "3.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.3.1", - "7.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.33", - "27001: A.8.10", - "27002: 5.33 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SI-12", - "SI-12(1)-(3)", - "SI-18", - "SI-18(1)", - "SI-18(4)", - "SI-18(5)", - "SI-19", - "SI-19(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-3", - "PR.IP-6", - "ID.GV-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "GV.OC-03", - "GV.SC-10", - "PR.DS-11" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_bucket_lifecycle_management_enabled", - "cloudstorage_bucket_sufficient_retention_period" - ] - }, - { - "Id": "DSP-17", - "Description": "Define and implement, processes, procedures and technical measures to protect sensitive data throughout it's lifecycle.", - "Name": "Sensitive Data Protection", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSC-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.1", - "CC6.1", - "CC6.3", - "CC6.7", - "CC8.1", - "C1.1", - "P2.0", - "P3.0", - "P4.0", - "P5.0", - "P6.0" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.1", - "3.1", - "3.14" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.3.3", - "9.1.1", - "9.2.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3", - "27002: 18.1.3", - "27001:A.18.1.4", - "27002:18.1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.11", - "27001: A.8.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-2", - "PM-22", - "PM-24", - "PT-7", - "PT-7(1)", - "PT-7(2)", - "PT-8", - "SC-8", - "SC-8(1)-(5)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2", - "PR.DS-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02", - "PR.DS-10" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.0 (including all subsections)", - "4.0 (including all subsections)" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.1.1", - "4.1.1" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_bucket_public_access", - "bigquery_dataset_public_access", - "cloudsql_instance_public_access", - "cloudsql_instance_public_ip", - "compute_instance_public_ip", - "compute_image_not_publicly_shared", - "kms_key_not_publicly_accessible" - ] - }, - { - "Id": "GRC-05", - "Description": "Develop and implement an Information Security Program, which includes programs for all the relevant domains of the CCM.", - "Name": "Information Security Program", - "Attributes": [ - { - "Section": "Governance, Risk and Compliance", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "14.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SG2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 4.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-1", - "PM-3", - "PM-14", - "PL-2", - "PM-18", - "PM-31" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.4.1", - "A.3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.4.1", - "A3.1.1" - ] - } - ] - } - ], - "Checks": [ - "iam_account_access_approval_enabled", - "iam_audit_logs_enabled", - "iam_organization_essential_contacts_configured" - ] - }, - { - "Id": "IAM-02", - "Description": "Establish, document, approve, communicate, implement, apply, evaluate and maintain strong password policies and procedures. Review and update the policies and procedures at least annually.", - "Name": "Strong Password Policy and Procedures", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-12", - "GRM-06", - "GRM-09" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.1.1", - "1.5.1", - "4.1.2", - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1", - "SA1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5", - "27002: 5", - "27001: A.9.4.3", - "27002: 9.4.3", - "27017: 9.4.3", - "27018: 9.4.3", - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27001: A.7.2.2", - "27002: 7.2.2", - "27001: A.9.2.6", - "27002: 9.2.6", - "27001: A.9.2.3", - "27002: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5.1", - "27001: A.5.4", - "27001: A.5.17", - "27001: A.6.3", - "27001: A.8.5", - "27001: A.5.37" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(3)", - "AC-2(11)", - "AC-3", - "AC-3(3)", - "AC-12", - "AC-12(1)", - "IA-2", - "IA-2(10)", - "IA-5", - "IA-5(1)", - "IA-5(18)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "PR.AC-1", - "PR.AC-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.PO-01", - "GV.PO-02", - "ID.IM-03", - "PR.AA-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.4", - "12.1", - "12.1.1", - "12.11" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.1.1", - "8.3.8" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "IAM-03", - "Description": "Manage, store, and review the information of system identities, and level of access.", - "Name": "Identity Inventory", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-04", - "IAM-08", - "IAM-10" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1", - "5.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.2 (c)", - "27001: A.8.1.1", - "27002: 8.1.1", - "27001: A.9.4.1", - "27002: 9.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.2 (c)", - "27001: A.5.15", - "27001: A.5.16", - "27001: A.5.18", - "27001: A.7.4", - "27001: A.8.15", - "27001: A.8.2", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-10", - "AU-10(1)", - "AU-10(2)", - "AU-16", - "AU-16(1)", - "IA-4", - "IA-4(8)", - "IA-4(9)", - "IA-5", - "IA-5(5)", - "IA-8", - "IA-8(4)", - "PM-5(1)", - "SA-8", - "SA-8(22)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-04", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.4.a" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.5", - "7.2.5.1" - ] - } - ] - } - ], - "Checks": [ - "iam_sa_user_managed_key_unused", - "iam_service_account_unused" - ] - }, - { - "Id": "IAM-04", - "Description": "Employ the separation of duties principle when implementing information system access.", - "Name": "Separation of Duties", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC1.3", - "CC5.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.2.2", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.6.1.2", - "27002: 6.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.18", - "27001: A.5.3", - "27001: A.8.2" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(3)", - "AC-2(11)", - "AC-6", - "AC-6(1)-(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4", - "6.4.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.5.3", - "6.5.4", - "7.2.1", - "7.2.2" - ] - } - ] - } - ], - "Checks": [ - "iam_role_sa_enforce_separation_of_duties", - "iam_role_kms_enforce_separation_of_duties", - "iam_no_service_roles_at_project_level" - ] - }, - { - "Id": "IAM-05", - "Description": "Employ the least privilege principle when implementing information system access.", - "Name": "Least Privilege", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-06", - "IVS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.1.1", - "27002: 9.1.1", - "27001: A.9.1.2", - "27002: 9.1.2", - "27001: A.9.2.3", - "27002: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.8.2", - "27002: 5.15 (Other information 2nd (a))" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(4)", - "IA-12", - "IA-12(2)", - "IA-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "7.1", - "7.1.1", - "7.1.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "7.2.2", - "7.2.5", - "7.2.6" - ] - } - ] - } - ], - "Checks": [ - "apikeys_api_restrictions_configured", - "compute_instance_default_service_account_in_use", - "compute_instance_default_service_account_in_use_with_full_api_access", - "gke_cluster_no_default_service_account", - "iam_no_service_roles_at_project_level", - "iam_sa_no_administrative_privileges" - ] - }, - { - "Id": "IAM-07", - "Description": "De-provision or respectively modify access of movers / leavers or system identity changes in a timely manner in order to effectively adopt and communicate identity and access management policies.", - "Name": "User Access Changes and Revocation", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.3", - "6.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.18" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(1)", - "AC-2(2)", - "AC-2(6)", - "AC-2(8)", - "AC-3", - "AC-3(8)", - "AC-6", - "AC-6(7)", - "AU-10", - "AU-10(4)", - "AU-16", - "AU-16(1)", - "CM-7", - "CM-7(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4", - "PR.IP-11" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.RR-04", - "GV.SC-10", - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1.2", - "8.1.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.2.5", - "8.2.6" - ] - } - ] - } - ], - "Checks": [ - "iam_sa_user_managed_key_unused", - "iam_service_account_unused" - ] - }, - { - "Id": "IAM-08", - "Description": "Review and revalidate user access for least privilege and separation of duties with a frequency that is commensurate with organizational risk tolerance.", - "Name": "User Access Review", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-10" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.5", - "27001: A.9.2.6", - "27001: A.9.4.1", - "27017: 9.4.1", - "27001: A.6.1.2", - "27001: A 9.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.3", - "27001: A.5.18", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(4)", - "AC-6(8)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.5.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.5.1", - "7.2.5", - "7.2.4" - ] - } - ] - } - ], - "Checks": [ - "iam_sa_user_managed_key_unused", - "iam_service_account_unused", - "iam_sa_user_managed_key_rotate_90_days" - ] - }, - { - "Id": "IAM-09", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the segregation of privileged access roles such that administrative access to data, encryption and key management capabilities and logging capabilities are distinct and separated.", - "Name": "Segregation of Privileged Access Roles", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.1", - "CC6.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.3", - "27002: 9.2.3", - "27017: 9.2.3", - "27018: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.2", - "27001: A.8.18", - "27002: 8.2 (j)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-3(7)", - "AC-6(4)", - "AC-6(8)", - "IA-5", - "IA-5(6)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.3", - "3.5.2", - "7.1.2", - "7.1.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1", - "3.7.6", - "6.5.3", - "6.5.4", - "7.2.1", - "7.2.2", - "10.3.1" - ] - } - ] - } - ], - "Checks": [ - "iam_role_kms_enforce_separation_of_duties", - "iam_role_sa_enforce_separation_of_duties", - "iam_sa_no_administrative_privileges" - ] - }, - { - "Id": "IAM-10", - "Description": "Define and implement an access process to ensure privileged access roles and rights are granted for a time limited period, and implement procedures to prevent the culmination of segregated privileged access.", - "Name": "Management of Privileged Access Roles", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1", - "6.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.3", - "27002: 9.2.3", - "27017: 9.2.3", - "27018: 9.2.3", - "27001: A.9.4.4", - "27002: 9.4.4", - "27017: 9.4.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.2", - "27001: A.8.18", - "27002: 8.2 (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(7)", - "AC-3", - "AC-3(4)", - "AC-3(11)", - "AC-3(13)", - "AC-3(14)", - "AC-6", - "AC-6(4)", - "AC-6(5)", - "AC-6(8)", - "AC-12", - "AC-12(3)", - "AC-17", - "AC-17(4)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "7.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "7.2.2" - ] - } - ] - } - ], - "Checks": [ - "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" - ] - }, - { - "Id": "IAM-12", - "Description": "Define, implement and evaluate processes, procedures and technical measures to ensure the logging infrastructure is read-only for all with write access, including privileged access roles, and that the ability to disable it is controlled through a procedure that ensures the segregation of duties and break glass procedures.", - "Name": "Safeguard Logs Integrity", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.3" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1", - "27018: 12.4.1", - "27001: A.12.4.2", - "27002: 12.4.2", - "27017: 12.4.2", - "27018: 12.4.2", - "27001: A.12.4.3", - "27002: 12.4.3", - "27017: 12.4.3", - "27018: 12.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15", - "27001: A.8.18", - "27002: 8.15 Protection of Logs" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(11)", - "AC-2(12)", - "IA-8", - "IA-8(4)", - "SA-8", - "SA-8(22)", - "SC-34", - "SC-34(1)", - "SC-34(2)", - "SC-36", - "SI-4", - "SI-4(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_bucket_log_retention_policy_lock", - "cloudstorage_bucket_logging_enabled", - "logging_sink_created" - ] - }, - { - "Id": "IAM-13", - "Description": "Define, implement and evaluate processes, procedures and technical measures that ensure users are identifiable through unique IDs or which can associate individuals to the usage of user IDs.", - "Name": "Uniquely Identifiable Users", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.1", - "27002: 9.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.16" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-3", - "AC-3(14)", - "AC-24", - "AC-24(2)", - "AU-10", - "AU-10(1)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(12)", - "IA-4", - "IA-4(1)", - "SA-8", - "SA-8(22)", - "SC-23", - "SC-23(3)", - "SC-40(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1", - "8.2", - "8.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.2.1", - "8.2.2", - "8.2.4" - ] - } - ] - } - ], - "Checks": [ - "compute_project_os_login_enabled" - ] - }, - { - "Id": "IAM-14", - "Description": "Define, implement and evaluate processes, procedures and technical measures for authenticating access to systems, application and data assets, including multifactor authentication for at least privileged user and sensitive data access. Adopt digital certificates or alternatives which achieve an equivalent level of security for system identities.", - "Name": "Strong Authentication", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.3", - "6.5", - "12.5", - "12.7" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3", - "SA1.4", - "SA1.8" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.1.2", - "27002: 9.1.2", - "27017: 9.1.2", - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27001: A.9.4.2", - "27002: 9.4.2", - "27017: 9.4.2", - "27018: 9.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.17", - "27001: A.8.5", - "27001: A.8.24", - "27002: 8.5", - "27002: 8.24 other information (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(5)", - "AC-7", - "AC-7(4)", - "AU-10", - "AU-10(2)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(8)", - "IA-2(12)", - "IA-3", - "IA-3(1)", - "IA-5", - "IA-5(2)", - "IA-5(7)", - "IA-5(9)", - "IA-5(10)", - "IA-5(12)", - "IA-5(14)-(16)", - "IA-8", - "IA-8(1)", - "IA-8(6)", - "SC-23", - "SC-23(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6", - "PR.AC-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1.2", - "8.1.3", - "8.1.6", - "8.2", - "8.3", - "8.3.2", - "12.3.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "8.3.1", - "8.3.2", - "8.4.1", - "8.4.2", - "8.4.3" - ] - } - ] - } - ], - "Checks": [ - "compute_project_os_login_2fa_enabled", - "compute_project_os_login_enabled" - ] - }, - { - "Id": "IAM-15", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the secure management of passwords.", - "Name": "Passwords Management", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27018: 9.2.4", - "27001: A.9.3.1", - "27002: 9.3.1", - "27017: 9.3.1", - "27018: 9.3.1", - "27001: A.9.4.3", - "27002: 9.4.3", - "27017: 9.4.3", - "27018: 9.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.17" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IA-4", - "IA-4(8)", - "IA-5", - "IA-5(1)", - "IA-5(8)", - "IA-5(18)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.2", - "8.2.1-6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.2", - "2.3.1", - "8.3.5", - "8.3.6", - "8.3.7", - "8.3.8", - "8.3.9", - "8.3.10", - "8.3.10.1", - "8.6.2" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "IAM-16", - "Description": "Define, implement and evaluate processes, procedures and technical measures to verify access to data and system functions is authorized.", - "Name": "Authorization Mechanisms", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3", - "SA1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.5", - "27002: 9.2.5", - "27017: 9.2.5", - "27018: 9.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.18" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-3", - "AC-3(5)", - "AC-4", - "AC-4(17)", - "AC-4(21)", - "AC-4(22)", - "AC-6", - "AC-6(8)", - "AC-6(9)", - "AC-12", - "AC-12(1)", - "AC-20", - "AC-20(1)", - "AU-10", - "AU-10(1)", - "AU-10(2)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(12)", - "IA-3", - "IA-3(1)", - "IA-5(1)", - "IA-5(2)", - "IA-5(5)", - "IA-5(8)", - "IA-5(10)", - "IA-5(12)", - "IA-8", - "IA-8(1)", - "IA-8(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4", - "PR.AC-6", - "PR.AC-7", - "PR.PT-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-03", - "PR.AA-04", - "PR.AA-05", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.3", - "7.1.4" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.4", - "7.2.3", - "7.2.5.1" - ] - } - ] - } - ], - "Checks": [ - "apikeys_api_restrictions_configured", - "cloudstorage_bucket_uniform_bucket_level_access", - "compute_instance_default_service_account_in_use_with_full_api_access", - "iam_sa_no_administrative_privileges" - ] - }, - { - "Id": "IPY-03", - "Description": "Implement cryptographically secure and standardized network protocols for the management, import and export of data.", - "Name": "Secure Interoperability and Portability Management", - "Attributes": [ - { - "Section": "Interoperability & Portability", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IPY-04" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY1.1", - "SY1.2", - "NC1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1", - "27001: A.15.1.1", - "27002: 15.1.1", - "27017: 15.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.19", - "27001: A.5.23", - "27001: A.5.31", - "27001: A.5.32", - "27001: A.5.33", - "27001: A.5.34" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PT-2", - "PT-2(2)", - "SA-4", - "SC-16", - "SC-16(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.2.1", - "1.2.5", - "1.2.6", - "2.2.4", - "2.2.5", - "2.2.7", - "4.2.1" - ] - } - ] - } - ], - "Checks": [ - "cloudsql_instance_ssl_connections" - ] - }, - { - "Id": "IVS-02", - "Description": "Plan and monitor the availability, quality, and adequate capacity of resources in order to deliver the required system performance as determined by the business.", - "Name": "Capacity and Resource Planning", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "No", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-04" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.3", - "27001: 6.1", - "27001: 9.1", - "27001: A.12.1.3", - "27002: 12.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.3 (b)", - "27001: 6.1", - "27001: 9.1", - "27001: A.8.6", - "27001: A.8.14" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2", - "CP-2(2)", - "SC-5", - "SC-5(2)", - "SC-4", - "SI-4" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-4", - "ID.BE-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.IR-04", - "GV.OC-04" - ] - } - ] - } - ], - "Checks": [ - "compute_instance_group_autohealing_enabled", - "compute_instance_group_load_balancer_attached", - "compute_instance_group_multiple_zones" - ] - }, - { - "Id": "IVS-03", - "Description": "Monitor, encrypt and restrict communications between environments to only authenticated and authorized connections, as justified by the business. Review these configurations at least annually, and support them by a documented justification of all allowed services, protocols, ports, and compensating controls.", - "Name": "Network Security", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-06" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.8", - "3.1", - "12.2", - "13.6", - "13.9" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "NC1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.13.1.1", - "27002: 13.1.1", - "27001: A.13.1.2", - "27002: 13.1.2", - "27001: A.13.1.3", - "27002: 13.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.5.15", - "27001: A.5.37", - "27001: A.8.5", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.21", - "27001: A.8.22", - "27001: A.8.24", - "27002: A.5.15 2nd c)", - "27002: 8.20", - "27002: 8.21", - "27002: 8.22", - "27002: 8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-1", - "SC-4", - "SC-7", - "SC-7(4)", - "SC-7(5)", - "SC-7(8)", - "SC-7(9)", - "SC-7(11)", - "SC-8", - "SC-8(1)", - "SC-11", - "SC-12", - "SC-16", - "SC-23", - "SC-29", - "SC-29(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-5", - "PR.AC-7", - "PR.PT-4", - "DE.CM-1", - "DE.CM-7", - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.IR-01", - "PR.AA-03", - "PR.AA-05", - "DE.CM-01", - "PR.DS-02", - "ID.AM-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "1.1.6", - "1.2", - "1.2.3", - "2.2", - "4.1.1", - "10.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.2.5", - "1.2.6", - "1.2.7", - "1.4.2", - "2.2.4", - "2.2.5", - "2.2.7", - "4.2.1", - "10.1.1" - ] - } - ] - } - ], - "Checks": [ - "compute_firewall_rdp_access_from_the_internet_allowed", - "compute_firewall_ssh_access_from_the_internet_allowed", - "compute_instance_ip_forwarding_is_enabled", - "compute_network_default_in_use", - "compute_network_dns_logging_enabled", - "compute_network_not_legacy", - "compute_subnet_flow_logs_enabled" - ] - }, - { - "Id": "IVS-04", - "Description": "Harden host and guest OS, hypervisor or infrastructure control plane according to their respective best practices, and supported by technical controls, as part of a security baseline.", - "Name": "OS Hardening and Base Controls", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.8", - "CC7.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-07", - "IVS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "4.1", - "4.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3", - "5.2.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY1.1", - "SY1.3", - "SY1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.14.2.2", - "27002: 14.2.2", - "27001: A.14.2.3", - "27001 A.14.2.4", - "27018: 12.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.5.37", - "27001: A.8.5", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.22", - "27001: A.8.24", - "27002: 8.20", - "27002: 8.22", - "27002: 8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-6", - "CM-6(1)", - "SC-29", - "SC-29(1)", - "SC-2", - "SC-7", - "SC-7(12)", - "SC-30", - "SC-34", - "SC-35", - "SC-39", - "SC-44" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-1", - "PR.PT-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.1" - ] - } - ] - } - ], - "Checks": [ - "compute_instance_shielded_vm_enabled", - "compute_project_os_login_enabled", - "compute_instance_serial_ports_in_use", - "compute_instance_block_project_wide_ssh_keys_disabled" - ] - }, - { - "Id": "IVS-06", - "Description": "Design, develop, deploy and configure applications and infrastructures such that CSP and CSC (tenant) user access and intra-tenant access is appropriately segmented and segregated, monitored and restricted from other tenants.", - "Name": "Segmentation and Segregation", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-09" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.3.4", - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SC2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.1", - "27001: A.13.1.3", - "27002: 13.1.3", - "27017: 13.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.1", - "27001: A.5.15", - "27001: A.5.20", - "27001: A.8.3", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.22", - "27002: 5.15 (b)", - "27002: 8.3 (b)", - "27002: 8.16 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-3", - "SC-7", - "SC-7(20)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.AC-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.IR-01", - "PR.PS-01", - "PR.PS-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.6", - "8.3.1", - "10.8", - "11.3", - "A3.2.1", - "A3.3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "A1.1.1", - "A1.1.2", - "A1.1.3" - ] - } - ] - } - ], - "Checks": [ - "cloudsql_instance_private_ip_assignment", - "cloudstorage_uses_vpc_service_controls", - "compute_instance_public_ip", - "compute_network_default_in_use", - "compute_network_not_legacy" - ] - }, - { - "Id": "IVS-07", - "Description": "Use secure and encrypted communication channels when migrating servers, services, applications, or data to cloud environments. Such channels must include only up-to-date and approved protocols.", - "Name": "Migration to Cloud Environments", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-10" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.4", - "IM1.4", - "NC1.4", - "SC2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.13.1.1", - "27002: 13.1.1", - "27017: 13.1.1", - "27018: 13.1.1", - "27001: A.13.1.2", - "27002: 13.1.2", - "27017: 13.1.2", - "27018: 13.1.2", - "27001: A.13.1.3", - "27002: 13.1.3", - "27017: 13.1.3", - "27018: 13.1.3", - "27001: A.13.2.1", - "27002: 13.2.1", - "27017: 13.2.1", - "27018: 13.2.1", - "27001: A.13.2.2", - "27002: 13.2.2", - "27017: 13.2.2", - "27018: 13.2.2", - "27001: A.13.2.3", - "27002: 13.2.3", - "27017: 13.2.3", - "27018: 13.2.3", - "27001: A.13.2.4", - "27002: 13.2.4", - "27017: 13.2.4", - "27018: 13.2.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.8.20", - "27001: A.8.24", - "27002: 8.20 (e)", - "27002: 8.24 Guidance (b,f), other information (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-17", - "AC-20", - "SC-7", - "SC-7(28)", - "SC-8", - "SC-8(1)", - "SC-12", - "SC-23", - "SC-29", - "SI-7", - "SI-7(1)-(3)", - "SI-7(5)-(10)", - "SI-7(12)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2", - "PR.PT-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "4.2.1" - ] - } - ] - } - ], - "Checks": [ - "cloudsql_instance_ssl_connections" - ] - }, - { - "Id": "IVS-09", - "Description": "Define, implement and evaluate processes, procedures and defense-in-depth techniques for protection, detection, and timely response to network-based attacks.", - "Name": "Network Defense", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.6", - "CC6.8", - "CC7.1", - "CC7.2", - "CC7.5" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-13" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "13.3", - "13.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.3", - "5.2.4", - "5.2.5", - "5.2.7", - "5.3.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "NC1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1", - "27001: 6.2", - "27001: A.14.1.2", - "27002: 14.1.2", - "27017: 14.1.2", - "27001: A.11.1.4", - "27002: 11.1.4", - "27017: 11.1.4", - "27018: 16.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1", - "27001: 6.2", - "27001: A.5.24", - "27001: A.5.26", - "27001: A.8.8", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.21", - "27001: A.8.22", - "27001: A.8.26", - "27002: 8.8 (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-8", - "PL-8(1)", - "SC-5", - "SC-5(1)", - "SC-5(3)", - "SC-7", - "SC-7(13)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.DP-1", - "DE.CM-1", - "DE.CM-7", - "PR.AC-5", - "RS.MI-2", - "PR.DS-2", - "RS.RP-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-03", - "DE.CM-01", - "PR.IR-01", - "RS.MA-01", - "RS.MI-01", - "RS.MI-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.6", - "1.1", - "1.2", - "1.3", - "1.5", - "12.10.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.1.1", - "1.3.1", - "1.3.2", - "1.3.3", - "1.4.1", - "1.4.2", - "1.4.3", - "1.4.4", - "1.4.5", - "1.5.1", - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "compute_firewall_rdp_access_from_the_internet_allowed", - "compute_firewall_ssh_access_from_the_internet_allowed", - "compute_loadbalancer_logging_enabled", - "compute_public_address_shodan", - "dns_dnssec_disabled" - ] - }, - { - "Id": "LOG-02", - "Description": "Define, implement and evaluate processes, procedures and technical measures to ensure the security and retention of audit logs.", - "Name": "Audit Logs Protection", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.1", - "8.9", - "8.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "3.1.3", - "5.1.2", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3", - "27002: 18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.28", - "27001: A.5.33", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-4", - "AU-11" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.IP-4", - "PR.IP-6", - "PR.PT-1", - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.DS-01", - "PR.DS-02", - "ID.AM-08", - "PR.DS-11", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5", - "10.7" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4", - "10.5.1" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_bucket_log_retention_policy_lock", - "cloudstorage_bucket_logging_enabled", - "logging_sink_created" - ] - }, - { - "Id": "LOG-03", - "Description": "Identify and monitor security-related events within applications and the underlying infrastructure. Define and implement a system to generate alerts to responsible stakeholders based on such events and corresponding metrics.", - "Name": "Security Monitoring and Alerting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-03", - "SEF-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4", - "5.2.7", - "1.6.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2", - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.28", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-5", - "AU-5(2)", - "AU-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.AE-2", - "DE.AE-3", - "DE.AE-5", - "DE.CM-1", - "DE.CM-2", - "DE.CM-3", - "DE.CM-4", - "DE.CM-5", - "DE.CM-6", - "DE.CM-7", - "DE.DP-1", - "DE.DP-4", - "DE.AE-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.AE-02", - "DE.AE-03", - "DE.AE-04", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1", - "10.2.2", - "10.4.1.1", - "10.4.2.1", - "10.4.3" - ] - } - ] - } - ], - "Checks": [ - "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" - ] - }, - { - "Id": "LOG-04", - "Description": "Restrict audit logs access to authorized personnel and maintain records that provide unique access accountability.", - "Name": "Audit Logs Access and Accountability", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.14" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "3.1.1", - "4.1.2", - "4.1.3", - "4.2.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.2", - "27001: A.12.4.1", - "27002: 12.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.33", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(4)", - "AU-9(6)", - "AU-10" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.1", - "10.2.1", - "10.2.3", - "10.5.1", - "10.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1.3", - "10.3.1" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_bucket_public_access", - "cloudstorage_bucket_uniform_bucket_level_access", - "iam_audit_logs_enabled", - "kms_key_not_publicly_accessible" - ] - }, - { - "Id": "LOG-05", - "Description": "Monitor security audit logs to detect activity outside of typical or expected patterns. Establish and follow a defined process to review and take appropriate and timely actions on detected anomalies.", - "Name": "Audit Logs Monitoring and Response", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.8", - "8.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.1", - "1.6.2", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.3", - "27002: 12.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15", - "27001: A.8.16" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-6", - "AU-6(1)", - "AU-6(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-3", - "PR.PT-1", - "RS.AN-1", - "RS.CO-1.", - "DE.AE-1", - "DE.AE-5", - "DE.DP-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-03", - "PR.PS-04", - "DE.AE-02", - "DE.AE-03", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.6", - "10.6.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.4.1.1", - "10.4.2.1" - ] - } - ] - } - ], - "Checks": [ - "logging_log_metric_filter_and_alert_for_project_ownership_changes_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_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": "LOG-07", - "Description": "Establish, document and implement which information meta/data system events should be logged. Review and update the scope at least annually or whenever there is a change in the threat environment.", - "Name": "Logging Scope", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5.3", - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5.3", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-1", - "AU-14", - "AU-16" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.SC-3", - "ID.SC-4", - "PR.PT-1", - "ID.GV-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1", - "10.2.2" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_audit_logs_enabled", - "cloudstorage_bucket_logging_enabled", - "compute_network_dns_logging_enabled", - "compute_subnet_flow_logs_enabled", - "iam_audit_logs_enabled" - ] - }, - { - "Id": "LOG-08", - "Description": "Generate audit records containing relevant security information.", - "Name": "Log Records", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-3", - "AU-3(1)", - "AU-3(3)", - "AU-6", - "AU-6(8)", - "AU-12", - "AU-12(1)", - "AU-12(2)", - "AU-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.PT-1", - "DE.AE-3", - "DE.CM-1", - "DE.CM-2", - "DE.CM-3", - "DE.CM-6", - "DE.CM-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.2" - ] - } - ] - } - ], - "Checks": [ - "iam_audit_logs_enabled", - "compute_subnet_flow_logs_enabled", - "compute_loadbalancer_logging_enabled", - "compute_network_dns_logging_enabled", - "cloudstorage_audit_logs_enabled", - "cloudstorage_bucket_logging_enabled", - "logging_sink_created", - "cloudsql_instance_postgres_log_connections_flag", - "cloudsql_instance_postgres_log_disconnections_flag", - "cloudsql_instance_postgres_log_statement_flag", - "cloudsql_instance_postgres_enable_pgaudit_flag" - ] - }, - { - "Id": "LOG-09", - "Description": "The information system protects audit records from unauthorized access, modification, and deletion.", - "Name": "Log Protection", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-04", - "IVS-01" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.2", - "27002: 12.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(2)", - "AU-9(3)", - "AU-9(4)", - "AU-12(3)", - "AU-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.IP-4", - "PR.IP-6", - "PR.PT-1", - "PR.DS-1", - "PR.DS-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.DS-01", - "PR.DS-02", - "PR.DS-11" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5", - "10.5.1", - "10.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_bucket_log_retention_policy_lock", - "cloudstorage_bucket_uniform_bucket_level_access" - ] - }, - { - "Id": "LOG-10", - "Description": "Establish and maintain a monitoring and internal reporting capability over the operations of cryptographic, encryption and key management policies, processes, procedures, and controls.", - "Name": "Encryption Monitoring and Reporting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-02", - "EKM-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1", - "27002: 10.1", - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-1", - "AU-9", - "AU-9(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "PR.PT-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.1.1", - "10.2.1", - "10.4.1" - ] - } - ] - } - ], - "Checks": [ - "kms_key_rotation_enabled" - ] - }, - { - "Id": "LOG-11", - "Description": "Log and monitor key lifecycle management events to enable auditing and reporting on usage of cryptographic keys.", - "Name": "Transaction/Activity Logging", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.PT-1", - "DE.AE-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-09" - ] - } - ] - } - ], - "Checks": [ - "cloudstorage_audit_logs_enabled", - "iam_audit_logs_enabled", - "logging_sink_created" - ] - }, - { - "Id": "LOG-13", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the reporting of anomalies and failures of the monitoring system and provide immediate notification to the accountable party.", - "Name": "Failures and Anomalies Reporting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.3", - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.1", - "27002: 16.1.1", - "27001: A.16.1.2", - "27017: 16.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.24", - "27001: A.6.8", - "27002: 6.8 (g)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-5", - "AU-5(2)", - "AU-6", - "AU-6(3)", - "AU-6(4)", - "AU-6(5)", - "AU-16" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-3", - "DE.DP-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.AE-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.4.3", - "10.7.1", - "10.7.2", - "10.7.3" - ] - } - ] - } - ], - "Checks": [ - "logging_log_metric_filter_and_alert_for_audit_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" - ] - }, - { - "Id": "SEF-03", - "Description": "'Establish, document, approve, communicate, apply, evaluate and maintain a security incident response plan, which includes but is not limited to: relevant internal departments, impacted CSCs, and other business critical relationships (such as supply-chain) that may be impacted.'", - "Name": "Incident Response Plans", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2", - "CC7.3", - "CC7.4" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "17.2", - "17.4" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27017: CLD.12.1.5", - "27018: 16.1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: A.5.26", - "27002: 5.26 (e,f)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IR-1", - "IR-2", - "IR-2(1)-(3)", - "IR-3", - "IR-3(1)-(3)", - "IR-4", - "IR-4(1)-(15)", - "IR-5", - "IR-5(1)", - "IR-6", - "IR-6(1)-(3)", - "IR-7", - "IR-7(1)", - "IR-7(2)", - "IR-8", - "IR-8(1)", - "IR-9", - "IR-9(1)-(4)", - "PM-12" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "RS.CO-1", - "RS.CO-4", - "ID.AM-6", - "ID.GV-2", - "ID.SC-5", - "PR.IP-9", - "PR.IP10" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AT-01", - "PR.AT-02", - "RS.MA-01", - "GV.SC-08", - "ID.IM-02", - "ID.IM-04", - "RC.RP-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.1", - "12.10.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1", - "12.10.5" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "SEF-06", - "Description": "Define, implement and evaluate processes, procedures and technical measures supporting business processes to triage security-related events.", - "Name": "Event Triage Processes", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.4", - "27002: 16.1.4", - "27017: 16.1.4", - "27018: 16.1.4", - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27018: 16.1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.25" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-7", - "CA-7(3)", - "CA-7(4)", - "CA-7(5)", - "CA-7(6)", - "IR-4", - "IR-4(1)", - "IR-4(3)", - "IR-4(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.AE-2", - "DE.AE-4", - "RS.RP-1", - "RS.AN-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "RS.MA-02", - "RS.MA-03", - "RS.AN-03", - "DE.AE-02", - "DE.AE-04", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "RS.MI-02", - "RC.RP-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "iam_audit_logs_enabled", - "logging_sink_created" - ] - }, - { - "Id": "SEF-08", - "Description": "Maintain points of contact for applicable regulation authorities, national and local law enforcement, and other legal jurisdictional authorities.", - "Name": "Points of Contact Maintenance", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "17.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 4.2", - "27001: A.6.1.3", - "27002: 6.1.3", - "27017: 6.1.3", - "27018: 6.1.3", - "27001: A.16.1.1", - "27002: 16.1.1", - "27001: A.18.1.1", - "27002: 18.1.1", - "27017: 18.1.1", - "27018: 18.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.5", - "27001: A.5.24", - "27002: 5.24 Incident management procedure (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IR-4", - "IR-4(8)", - "IR-6", - "IR-6(3)", - "IR-7", - "IR-7(2)", - "PM-21", - "PM-23", - "PM-26" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-2", - "RS.CO-3", - "RS.CO-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.RR-02", - "RS.CO-02", - "RS.CO-03" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "iam_organization_essential_contacts_configured" - ] - }, - { - "Id": "TVM-02", - "Description": "Establish, document, approve, communicate, apply, evaluate and maintain policies and procedures to protect against malware on managed assets. Review and update the policies and procedures at least annually.", - "Name": "Malware Protection Policy and Procedures", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC6.8" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-01", - "GRM-06", - "GRM-09" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "9.7", - "10.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.1.1", - "1.5.1", - "5.2.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS1.2", - "TS1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5", - "27002: 5", - "27001: A.12.2.1", - "27001: A.6.2.1", - "27002: 6.2.1 (h)", - "27001: A.6.2.2", - "27002: 6.2.2 (j)", - "27001: A.7.2.2", - "27002: 7.2.2 (d)", - "27001: A.10.1.1", - "27002: 10.1.1 (g)", - "27001: A.13.2.1", - "27002: 13.2.1 (b)", - "27001: A.15.1.2", - "27017: 15.1.2", - "27001: A.12.2.1", - "27002: 12.2.1 (a),(d)", - "27017: CLD.9.5.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5.1", - "27001: A.5.4", - "27001: A.5.7", - "27001: A.5.37", - "27001: A.8.7", - "27002: 5.7 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-3", - "RA-3(3)", - "RA-5", - "RA-5(3)", - "RA-5(5)", - "SI-3", - "SI-3(4)", - "SI-3(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "DE.CM-4", - "DE.CM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.PO-01", - "GV.PO-02", - "ID.IM-03", - "DE.CM-01", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.4", - "12.1", - "12.1.1", - "12.3.1", - "12.5.1", - "12.11" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.1.1", - "12.1.2", - "5.1.1", - "5.3.2.1" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "TVM-03", - "Description": "Define, implement and evaluate processes, procedures and technical measures to enable both scheduled and emergency responses to vulnerability identifications, based on the identified risk.", - "Name": "Vulnerability Remediation Schedule", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC7.1", - "CC7.4" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "7.2", - "7.7", - "17.9" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1", - "TM2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.12.2.1", - "27001: A.12.6.1", - "27002: 12.6.1(c)(d)(j)", - "27018: 12.6.1(k)(i)" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.8.7", - "27001: A.8.8", - "27001: A.8.32", - "27002: 8.7", - "27002: 8.8", - "27002: 8.32" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-31", - "RA-3", - "RA-3(1)", - "RA-5", - "RA-5(2)-(4)", - "RA-5(6)", - "SI-3", - "SI-3(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "RS.AN-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01", - "ID.RA-06", - "ID.RA-08", - "PR.PS-02", - "PR.PS-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "6.1.a", - "6.1.b" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.1.1", - "6.3.1", - "6.3.2", - "6.3.3", - "12.10.1" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "TVM-04", - "Description": "Define, implement and evaluate processes, procedures and technical measures to update detection tools, threat signatures, and indicators of compromise on a weekly, or more frequent basis.", - "Name": "Detection Updates", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "No mapping" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "10.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS1.3", - "TS1.4", - "TM1.3", - "TM1.4", - "IM1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.5.1.1", - "27002: 5.1.1 (h)", - "27001: A.12.6.1", - "27002: 12.6.1 (b),(c)" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.5.1", - "27001: A.8.8", - "27001: A.8.15", - "27001: A.8.16", - "27002: 5.1", - "27002: 5.37", - "27002: 8.8", - "27002: 8.15 (d)", - "27002: 8.16 (d,e)", - "27002: 8.31 2nd (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-7", - "CM-7(4)", - "RA-3", - "RA-3(3)", - "RA-5(2)", - "SA-10", - "SA-10(5)", - "SA-11", - "SA-11(2)", - "SI-2", - "SI-2(4)", - "SI-3", - "SI-3(4)", - "SI-4", - "SI-4(9)", - "SI-4(24)", - "SI-8", - "SI-8(2)", - "SI-8(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-02", - "ID.RA-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.2", - "5.2a", - "5.2b", - "5.2c" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "5.3.1" - ] - } - ] - } - ], - "Checks": [ - "artifacts_container_analysis_enabled", - "gcr_container_scanning_enabled" - ] - }, - { - "Id": "TVM-05", - "Description": "Define, implement and evaluate processes, procedures and technical measures to identify updates for applications which use third party or open source libraries according to the organization's vulnerability management policy.", - "Name": "External Library Vulnerabilities", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC3.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "No mapping" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1", - "SD2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.12.6.2", - "27002: 12.6.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A 5.6", - "27001: A.8.19", - "27001: A.8.8", - "27001: A.8.28", - "27001: A.8.31", - "27002: 5.6 (c)", - "27001: 8.19", - "27001: 8.8", - "27001: 8.28", - "27001: 8.31" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-5", - "RA-5(3)", - "SA-11", - "SA-11(2)", - "SA-11(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01", - "ID.RA-03", - "PR.PS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "6.2", - "6.3.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "6.3.2", - "6.3.3" - ] - } - ] - } - ], - "Checks": [ - "artifacts_container_analysis_enabled", - "gcr_container_scanning_enabled" - ] - }, - { - "Id": "TVM-07", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the detection of vulnerabilities on organizationally managed assets at least monthly.", - "Name": "Vulnerability Identification", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "7.1", - "7.5", - "7.6" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.5", - "5.2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.6", - "27001: A.12.6.1", - "27002: 12.6.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.8", - "27002: 8.8" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-5", - "RA-5(4)", - "RA-5(5)", - "SA-11", - "SA-11(5)", - "SA-15(5)", - "SC-7", - "SC-7(10)", - "SI-3(8)", - "SI-3(10)", - "SI-7", - "SI-7(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.RA-1", - "DE.CM-8", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "11.2", - "11.2.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "6.3.2", - "6.3.3", - "11.3.2", - "11.3.2.1" - ] - } - ] - } - ], - "Checks": [ - "artifacts_container_analysis_enabled", - "compute_public_address_shodan", - "gcr_container_scanning_enabled" - ] - }, - { - "Id": "UEM-08", - "Description": "Protect information from unauthorized disclosure on managed endpoint devices with storage encryption.", - "Name": "Storage Encryption", - "Attributes": [ - { - "Section": "Universal Endpoint Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "MOS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.6" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "3.1.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "PA1.2", - "PA1.3", - "PA1.5", - "PA2.2", - "PM1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.11.2.7", - "27002: 11.2.7", - "27001: A.18.1.1", - "27017: 18.1.1", - "27001: A.12.3.1", - "27017: 12.3.1", - "27018: A.11.4", - "27018: A.11.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.1", - "27002: 8.1 (h)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-19(5)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.4", - "3.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.5.1", - "3.6" - ] - } - ] - } - ], - "Checks": [ - "bigquery_dataset_cmk_encryption", - "bigquery_table_cmk_encryption", - "compute_instance_confidential_computing_enabled", - "compute_instance_encryption_with_csek_enabled", - "dataproc_encrypted_with_cmks_disabled" - ] - }, - { - "Id": "UEM-11", - "Description": "Configure managed endpoints with Data Loss Prevention (DLP) technologies and rules in accordance with a risk assessment.", - "Name": "Data Loss Prevention", - "Attributes": [ - { - "Section": "Universal Endpoint Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.13" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.5", - "PA2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.3", - "27002: 12.3", - "27001: A.8.3.1", - "27002: 8.3.1", - "27001: A.12.2", - "27002: 12.2", - "27001: A.18.1.3", - "27002: 18.1.3", - "27001: A.6.1.1", - "27017: 6.1.1", - "27018: 12.3.1", - "27018: 10.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.12", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-7", - "SC-7(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02", - "PR.DS-10", - "PR.PS-01", - "ID.AM-08", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A3.2.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "A3.2.6" - ] - } - ] - } - ], - "Checks": [] - } - ] -} diff --git a/prowler/compliance/gcp/prowler_threatscore_gcp.json b/prowler/compliance/gcp/prowler_threatscore_gcp.json index 46fc776fbe..923a1acfc2 100644 --- a/prowler/compliance/gcp/prowler_threatscore_gcp.json +++ b/prowler/compliance/gcp/prowler_threatscore_gcp.json @@ -117,7 +117,7 @@ "Id": "1.2.4", "Description": "Ensure KMS Encryption Keys Are Rotated Within a Period of 90 Days", "Checks": [ - "kms_key_rotation_enabled" + "kms_key_rotation_max_90_days" ], "Attributes": [ { diff --git a/prowler/lib/outputs/compliance/csa/__init__.py b/prowler/compliance/okta/__init__.py similarity index 100% rename from prowler/lib/outputs/compliance/csa/__init__.py rename to prowler/compliance/okta/__init__.py diff --git a/prowler/compliance/okta/okta_idaas_stig_v1r2_okta.json b/prowler/compliance/okta/okta_idaas_stig_v1r2_okta.json new file mode 100644 index 0000000000..bac3d9132e --- /dev/null +++ b/prowler/compliance/okta/okta_idaas_stig_v1r2_okta.json @@ -0,0 +1,638 @@ +{ + "Framework": "Okta-IDaaS-STIG", + "Name": "DISA Okta Identity as a Service (IDaaS) STIG V1R2", + "Version": "1R2", + "Provider": "Okta", + "Description": "Defense Information Systems Agency (DISA) Security Technical Implementation Guide (STIG) for Okta Identity as a Service (IDaaS), Version 1 Release 2 (Benchmark Date: 05 Jan 2026).", + "Requirements": [ + { + "Id": "OKTA-APP-000020", + "Name": "Okta must log out a session after a 15-minute period of inactivity.", + "Description": "A session timeout lock is a temporary action taken when a user stops work and moves away from the immediate physical vicinity of the information system but does not log out because of the temporary nature of the absence. Rather than relying on the user to manually lock their application session prior to vacating the vicinity, applications must be able to identify when a user's application session has idled and take action to initiate the session lock. The session lock is implemented at the point where session activity can be determined and/or controlled. This is typically at the operating system level and results in a system lock. However, it may be at the application level where the application interface window is secured instead. Satisfies: SRG-APP-000003, SRG-APP-000190", + "Checks": [ + "signon_global_session_idle_timeout_15min" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273186r1098825_rule", + "StigID": "OKTA-APP-000020", + "CCI": [ + "CCI-000057", + "CCI-001133" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Global Session Policy. 2. In the Default Policy, verify a rule is configured at Priority 1 that is not named \"Default Rule\". 3. Click the edit icon next to the Priority 1 rule. 4. Verify the \"Maximum Okta global session idle time\" is set to 15 minutes. If \"Maximum Okta global session idle time\" is not set to 15 minutes, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Global Session Policy. 2. Select the Default Policy. 3. In the Rules table, make these updates: - Click \"Add rule\". - Set \"Maximum Okta global session idle time\" to 15 minutes." + } + ] + }, + { + "Id": "OKTA-APP-000025", + "Name": "The Okta Admin Console must log out a session after a 15-minute period of inactivity.", + "Description": "A session timeout lock is a temporary action taken when a user stops work and moves away from the immediate physical vicinity of the information system but does not log out because of the temporary nature of the absence. Rather than relying on the user to manually lock their application session prior to vacating the vicinity, applications must be able to identify when a user's application session has idled and take action to initiate the session lock. The session lock is implemented at the point where session activity can be determined and/or controlled. This is typically at the operating system level and results in a system lock. However, it may be at the application level where the application interface window is secured instead.", + "Checks": [ + "application_admin_console_session_idle_timeout_15min" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273187r1098828_rule", + "StigID": "OKTA-APP-000025", + "CCI": [ + "CCI-000057" + ], + "CheckText": "From the Admin Console: 1. Select Applications >> Applications >> Okta Admin Console. 2. In the Sign On tab, under \"Okta Admin Console session\", verify the \"Maximum app session idle time\" is set to 15 minutes. If the \"Maximum app session idle time\" is not set to 15 minutes, this is a finding.", + "FixText": "From the Admin Console: 1. Select Applications >> Applications >> Okta Admin Console. 2. In the Sign On tab, under \"Okta Admin Console session\", set the \"Maximum app session idle time\" to 15 minutes." + } + ] + }, + { + "Id": "OKTA-APP-000090", + "Name": "Okta must automatically disable accounts after a 35-day period of account inactivity.", + "Description": "Attackers that are able to exploit an inactive account can potentially obtain and maintain undetected access to an application. Owners of inactive accounts will not notice if unauthorized access to their user account has been obtained. Applications must track periods of user inactivity and disable accounts after 35 days of inactivity. Such a process greatly reduces the risk that accounts will be hijacked, leading to a data compromise. To address access requirements, many application developers choose to integrate their applications with enterprise-level authentication/access mechanisms that meet or exceed access control policy requirements. Such integration allows the application developer to off-load those access control functions and focus on core application features and functionality. This policy does not apply to emergency accounts or infrequently used accounts. Infrequently used accounts are local login administrator accounts used by system administrators when network or normal login/access is not available. Emergency accounts are administrator accounts created in response to crisis situations. Satisfies: SRG-APP-000025, SRG-APP-000163, SRG-APP-000700", + "Checks": [ + "user_inactivity_automation_35d_enabled" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273188r1098831_rule", + "StigID": "OKTA-APP-000090", + "CCI": [ + "CCI-000017", + "CCI-000795", + "CCI-003627" + ], + "CheckText": "If Okta Services rely on external directory services for user sourcing, this is not applicable, and the connected directory services must perform this function. Go to Workflows >> Automations and verify that an Automation has been created to disable accounts after 35 days of inactivity. If the Okta configuration does not automatically disable accounts after a 35-day period of account inactivity, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Workflow >> Automations and select \"Add Automation\". 2. Create a name for the Automation (e.g., \"User Inactivity\"). 3. Click \"Add Condition\" and select \"User Inactivity in Okta\". 4. In the duration field, enter 35 days and click \"Save\". 5 Click the edit button next to \"Select Schedule\". 6. Configure the \"Schedule\" field for \"Run Daily\" and set the \"Time\" field to an organizationally defined time to run this automation. Click \"Save\". 7. Click the edit button next to \"Select group membership\". 8. In the \"Applies to\" field, select the group \"Everyone\" by typing it into the field. Click \"Save\". 9. Click \"Add Action\" and select \"Change User lifecycle state in Okta\". 10. In the \"Change user state to\" field, select \"Suspended\" and click \"Save\". 11. Click the \"Inactive\" button near the top of the section screen and select \"Activate\"." + } + ] + }, + { + "Id": "OKTA-APP-000170", + "Name": "Okta must enforce the limit of three consecutive invalid login attempts by a user during a 15-minute time period.", + "Description": "By limiting the number of failed login attempts, the risk of unauthorized system access via user password guessing, otherwise known as brute forcing, is reduced. Limits are imposed by locking the account. Satisfies: SRG-APP-000065, SRG-APP-000345", + "Checks": [ + "authenticator_password_lockout_threshold_3" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273189r1098834_rule", + "StigID": "OKTA-APP-000170", + "CCI": [ + "CCI-000044", + "CCI-002238" + ], + "CheckText": "If Okta Services rely on external directory services for user sourcing, this check is not applicable, and the connected directory services must perform this function. From the Admin Console: 1. Go to Security >> Authenticators. 2. Click the \"Actions\" button next to \"Password\" and select \"Edit\". 3. For each Password Policy, verify the \"Lock Out\" section has the following values: - \"Lock out after 3 unsuccessful attempts\" is checked. - The value is set to \"3\". If Okta Services are not configured to automatically lock user accounts after three consecutive invalid login attempts, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Authenticators. 2. Click the \"Actions\" button next to \"Password\" and select \"Edit\". 3. For each Password Policy, ensure the \"Lock Out\" section has the following values: - \"Lock out after 3 unsuccessful attempts\" is checked. - The value is set to \"3\"." + } + ] + }, + { + "Id": "OKTA-APP-000180", + "Name": "The Okta Dashboard application must be configured to allow authentication only via non-phishable authenticators.", + "Description": "Requiring the use of non-phishable authenticators protects against brute force/password dictionary attacks. This provides a better level of security while removing the need to lock out accounts after three attempts in 15 minutes.", + "Checks": [ + "application_dashboard_phishing_resistant_authentication" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273190r1099763_rule", + "StigID": "OKTA-APP-000180", + "CCI": [ + "CCI-000044" + ], + "CheckText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Dashboard\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"Possession factor constraints are\" section, verify the \"Phishing resistant\" box is checked. This will ensure that only phishing-resistant factors are used to access the Okta Dashboard. If in the \"Possession factor constraints are\" section the \"Phishing resistant\" box is not checked, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Dashboard\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"Possession factor constraints are\" section, ensure the \"Phishing resistant\" box is checked." + } + ] + }, + { + "Id": "OKTA-APP-000190", + "Name": "The Okta Admin Console application must be configured to allow authentication only via non-phishable authenticators.", + "Description": "Requiring the use of non-phishable authenticators protects against brute force/password dictionary attacks. This provides a better level of security while removing the need to lock out accounts after three attempts in 15 minutes.", + "Checks": [ + "application_admin_console_phishing_resistant_authentication" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273191r1099764_rule", + "StigID": "OKTA-APP-000190", + "CCI": [ + "CCI-000044" + ], + "CheckText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Admin Console\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"Possession factor constraints are\" section, verify the \"Phishing resistant\" box is checked. This will ensure that only phishing-resistant factors are used to access the Okta Dashboard. If in the \"Possession factor constraints are\" section the \"Phishing resistant\" box is not checked, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Admin Console\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"Possession factor constraints are\" section, ensure the \"Phishing resistant\" box is checked." + } + ] + }, + { + "Id": "OKTA-APP-000200", + "Name": "Okta must display the Standard Mandatory DOD Notice and Consent Banner before granting access to the application.", + "Description": "Display of the DOD-approved use notification before granting access to the application ensures that privacy and security notification verbiage used is consistent with applicable federal laws, Executive Orders, directives, policies, regulations, standards, and guidance. System use notifications are required only for access via login interfaces with human users and are not required when such human interfaces do not exist. The banner must be formatted in accordance with DTM-08-060. Use the following verbiage for applications that can accommodate banners of 1300 characters: \"You are accessing a U.S. Government (USG) Information System (IS) that is provided for USG-authorized use only. By using this IS (which includes any device attached to this IS), you consent to the following conditions: -The USG routinely intercepts and monitors communications on this IS for purposes including, but not limited to, penetration testing, COMSEC monitoring, network operations and defense, personnel misconduct (PM), law enforcement (LE), and counterintelligence (CI) investigations. -At any time, the USG may inspect and seize data stored on this IS. -Communications using, or data stored on, this IS are not private, are subject to routine monitoring, interception, and search, and may be disclosed or used for any USG-authorized purpose. -This IS includes security measures (e.g., authentication and access controls) to protect USG interests--not for your personal benefit or privacy. -Notwithstanding the above, using this IS does not constitute consent to PM, LE or CI investigative searching or monitoring of the content of privileged communications, or work product, related to personal representation or services by attorneys, psychotherapists, or clergy, and their assistants. Such communications and work product are private and confidential. See User Agreement for details.\" Use the following verbiage for operating systems that have severe limitations on the number of characters that can be displayed in the banner: \"I've read & consent to terms in IS user agreem't.\" Satisfies: SRG-APP-000068, SRG-APP-000069, SRG-APP-000070", + "Checks": [ + "signon_dod_warning_banner_configured" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273192r1098843_rule", + "StigID": "OKTA-APP-000200", + "CCI": [ + "CCI-000048", + "CCI-000050", + "CCI-001384", + "CCI-001385", + "CCI-001386", + "CCI-001387", + "CCI-001388" + ], + "CheckText": "Attempt to log in to the Okta tenant and verify the DOD-approved warning banner is in place. If the required warning banner is not present and complete, this is a finding.", + "FixText": "Follow the supplemental instructions in the \"Okta DOD Warning Banner Configuration Guide\" provided with this STIG package." + } + ] + }, + { + "Id": "OKTA-APP-000560", + "Name": "The Okta Admin Console application must be configured to use multifactor authentication.", + "Description": "Without the use of multifactor authentication, the ease of access to privileged functions is greatly increased. Multifactor authentication requires using two or more factors to achieve authentication. Factors include: (i) something a user knows (e.g., password/PIN); (ii) something a user has (e.g., cryptographic identification device, token); or (iii) something a user is (e.g., biometric). A privileged account is defined as an information system account with authorizations of a privileged user. Network access is defined as access to an information system by a user (or a process acting on behalf of a user) communicating through a network (e.g., local area network, wide area network, or the internet). Satisfies: SRG-APP-000149, SRG-APP-000154", + "Checks": [ + "application_admin_console_mfa_required" + ], + "Attributes": [ + { + "Section": "CAT I (High)", + "Severity": "high", + "RuleID": "SV-273193r1098846_rule", + "StigID": "OKTA-APP-000560", + "CCI": [ + "CCI-000765", + "CCI-004046" + ], + "CheckText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Admin Console\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"User must authenticate with\" field, verify that either \"Password/IdP + Another factor\" or \"Any 2 factor types\" is selected. If either of these settings is incorrect, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Admin Console\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"User must authenticate with\" field, select either \"Password/IdP + Another factor\" or \"Any 2 factor types\"." + } + ] + }, + { + "Id": "OKTA-APP-000570", + "Name": "The Okta Dashboard application must be configured to use multifactor authentication.", + "Description": "To ensure accountability and prevent unauthenticated access, nonprivileged users must use multifactor authentication to prevent potential misuse and compromise of the system. Multifactor authentication uses two or more factors to achieve authentication. Factors include: (i) Something you know (e.g., password/PIN); (ii) Something you have (e.g., cryptographic identification device, token); or (iii) Something you are (e.g., biometric). A nonprivileged account is any information system account with authorizations of a nonprivileged user. Network access is any access to an application by a user (or process acting on behalf of a user) where the access is obtained through a network connection. Applications integrating with the DOD Active Directory and using the DOD CAC are examples of compliant multifactor authentication solutions. Satisfies: SRG-APP-000150, SRG-APP-000155", + "Checks": [ + "application_dashboard_mfa_required" + ], + "Attributes": [ + { + "Section": "CAT I (High)", + "Severity": "high", + "RuleID": "SV-273194r1098849_rule", + "StigID": "OKTA-APP-000570", + "CCI": [ + "CCI-000766", + "CCI-004046" + ], + "CheckText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Dashboard\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"User must authenticate with\" field, verify that either \"Password/IdP + Another factor\" or \"Any 2 factor types\" is selected. If either of these settings is incorrect, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Dashboard\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"User must authenticate with\" field, select either \"Password/IdP + Another factor\" or \"Any 2 factor types\"." + } + ] + }, + { + "Id": "OKTA-APP-000650", + "Name": "Okta must enforce a minimum 15-character password length.", + "Description": "Password complexity, or strength, is a measure of the effectiveness of a password in resisting attempts at guessing and brute-force attacks. Password length is one factor of several that helps to determine strength and how long it takes to crack a password. The shorter the password, the lower the number of possible combinations that need to be tested before the password is compromised. Use of more characters in a password helps to exponentially increase the time and/or resources required to compromise the password.", + "Checks": [ + "authenticator_password_minimum_length_15" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273195r1098852_rule", + "StigID": "OKTA-APP-000650", + "CCI": [ + "CCI-004066" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify the \"Minimum Length\" field is set to at least \"15\" characters. If any policy is not set to at least \"15\", this is a finding.", + "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set the \"Minimum Length\" field to at least \"15\" characters." + } + ] + }, + { + "Id": "OKTA-APP-000670", + "Name": "Okta must enforce password complexity by requiring that at least one uppercase character be used.", + "Description": "Use of a complex password helps to increase the time and resources required to compromise the password. Password complexity, or strength, is a measure of the effectiveness of a password in resisting attempts at guessing and brute-force attacks. Password complexity is one factor of several that determine how long it takes to crack a password. The more complex the password is, the greater the number of possible combinations that need to be tested before the password is compromised.", + "Checks": [ + "authenticator_password_complexity_uppercase" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273196r1098855_rule", + "StigID": "OKTA-APP-000670", + "CCI": [ + "CCI-004066" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Upper case letter\" is checked. For each policy, if \"Upper case letter\" is not checked, this is a finding.", + "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Upper case letter\" to checked." + } + ] + }, + { + "Id": "OKTA-APP-000680", + "Name": "Okta must enforce password complexity by requiring that at least one lowercase character be used.", + "Description": "Use of a complex password helps to increase the time and resources required to compromise the password. Password complexity, or strength, is a measure of the effectiveness of a password in resisting attempts at guessing and brute-force attacks. Password complexity is one factor of several that determine how long it takes to crack a password. The more complex the password, the greater the number of possible combinations that need to be tested before the password is compromised.", + "Checks": [ + "authenticator_password_complexity_lowercase" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273197r1098858_rule", + "StigID": "OKTA-APP-000680", + "CCI": [ + "CCI-004066" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Lower case letter\" is checked. For each policy, if \"Lower case letter\" is not checked, this is a finding.", + "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Lower case letter\" to checked." + } + ] + }, + { + "Id": "OKTA-APP-000690", + "Name": "Okta must enforce password complexity by requiring that at least one numeric character be used.", + "Description": "Use of a complex password helps to increase the time and resources required to compromise the password. Password complexity, or strength, is a measure of the effectiveness of a password in resisting attempts at guessing and brute-force attacks. Password complexity is one factor of several that determine how long it takes to crack a password. The more complex the password, the greater the number of possible combinations that need to be tested before the password is compromised.", + "Checks": [ + "authenticator_password_complexity_number" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273198r1098861_rule", + "StigID": "OKTA-APP-000690", + "CCI": [ + "CCI-004066" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Number (0-9)\" is checked. For each policy, if \"Number (0-9)\" is not checked, this is a finding.", + "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Number (0-9)\" to checked." + } + ] + }, + { + "Id": "OKTA-APP-000700", + "Name": "Okta must enforce password complexity by requiring that at least one special character be used.", + "Description": "Use of a complex password helps to increase the time and resources required to compromise the password. Password complexity, or strength, is a measure of the effectiveness of a password in resisting attempts at guessing and brute-force attacks. Password complexity is one factor in determining how long it takes to crack a password. The more complex the password, the greater the number of possible combinations that need to be tested before the password is compromised. Special characters are not alphanumeric. Examples include: ~ ! @ # $ % ^ *.", + "Checks": [ + "authenticator_password_complexity_symbol" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273199r1098864_rule", + "StigID": "OKTA-APP-000700", + "CCI": [ + "CCI-004066" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Symbol (e.g., !@#$%^&*)\" is checked. For each policy, if \"Symbol (e.g., !@#$%^&*)\" is not checked, this is a finding.", + "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Symbol (e.g., !@#$%^&*)\" to checked." + } + ] + }, + { + "Id": "OKTA-APP-000740", + "Name": "Okta must enforce 24 hours/one day as the minimum password lifetime.", + "Description": "Enforcing a minimum password lifetime helps prevent repeated password changes to defeat the password reuse or history enforcement requirement. Restricting this setting limits the user's ability to change their password. Passwords must be changed at specific policy-based intervals; however, if the application allows the user to immediately and continually change their password, it could be changed repeatedly in a short period of time to defeat the organization's policy regarding password reuse. Satisfies: SRG-APP-000173, SRG-APP-000870", + "Checks": [ + "authenticator_password_minimum_age_24h" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273200r1098867_rule", + "StigID": "OKTA-APP-000740", + "CCI": [ + "CCI-004066" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Minimum password age is XX hours\" is set to at least \"24\". For each policy, if \"Minimum password age is XX hours\" is not set to at least \"24\", this is a finding.", + "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Minimum password age is XX hours\" to at least \"24\"." + } + ] + }, + { + "Id": "OKTA-APP-000745", + "Name": "Okta must enforce a 60-day maximum password lifetime restriction.", + "Description": "Any password, no matter how complex, can eventually be cracked. Therefore, passwords must be changed at specific intervals. One method of minimizing this risk is to use complex passwords and periodically change them. If the application does not limit the lifetime of passwords and force users to change their passwords, there is the risk that the system and/or application passwords could be compromised. This requirement does not include emergency administration accounts, which are meant for access to the application in case of failure. These accounts are not required to have maximum password lifetime restrictions.", + "Checks": [ + "authenticator_password_maximum_age_60d" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273201r1098870_rule", + "StigID": "OKTA-APP-000745", + "CCI": [ + "CCI-004066" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Password expires after XX days\" is set to \"60\". For each policy, if \"Password expires after XX days\" is not set to \"60\", this is a finding.", + "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Password expires after XX days\" to \"60\"." + } + ] + }, + { + "Id": "OKTA-APP-001430", + "Name": "Okta must off-load audit records onto a central log server.", + "Description": "Information stored in one location is vulnerable to accidental or incidental deletion or alteration. Off-loading is a common process in information systems with limited audit storage capacity. Satisfies: SRG-APP-000358, SRG-APP-000080, SRG-APP-000125", + "Checks": [ + "systemlog_streaming_enabled" + ], + "Attributes": [ + { + "Section": "CAT I (High)", + "Severity": "high", + "RuleID": "SV-273202r1099766_rule", + "StigID": "OKTA-APP-001430", + "CCI": [ + "CCI-001851", + "CCI-000166", + "CCI-001348" + ], + "CheckText": "From the Admin Console: 1. Go to Reports >> Log Streaming. 2. Verify that a Log Stream connection is configured and active. Alternately, interview the information system security manager (ISSM) and verify that an external Security Information and Event Management (SIEM) system is pulling Okta logs via an Application Programming Interface (API). If either of these is not configured, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Reports >> Log Streaming. 2. Select either \"AWS EventBridge\" or \"Splunk Cloud\" and click \"Next\". 3. Complete the necessary fields and click \"Save\". If Log Streaming is not an option because the SIEM required is not an option, customers can use the Okta Log API to export system logs in real time." + } + ] + }, + { + "Id": "OKTA-APP-001665", + "Name": "Okta must be configured to limit the global session lifetime to 18 hours.", + "Description": "Without reauthentication, users may access resources or perform tasks for which they do not have authorization. When applications provide the capability to change security roles or escalate the functional capability of the application, it is critical the user reauthenticate. In addition to the reauthentication requirements associated with session locks, organizations may require reauthentication of individuals and/or devices in other situations, including (but not limited to) the following circumstances. (i) When authenticators change; (ii) When roles change; (iii) When security categories of information systems change; (iv) When the execution of privileged functions occurs; (v) After a fixed period of time; or (vi) Periodically. Within the DOD, the minimum circumstances requiring reauthentication are privilege escalation and role changes.", + "Checks": [ + "signon_global_session_lifetime_18h" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273203r1099958_rule", + "StigID": "OKTA-APP-001665", + "CCI": [ + "CCI-002038" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Global Session Policy. 2. In the Default Policy, verify a rule is configured at Priority 1 that is not named \"Default Rule\". 3. Click the \"Edit\" icon next to the Priority 1 rule. 4. Verify \"Maximum Okta global session lifetime\" is set to 18 hours. If the above is not set, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Global Session Policy. 2. Select the Default Policy. 3. In the Rules table, make these updates: - Click \"Add rule\". - Set \"Maximum Okta global session lifetime\" to 18 hours." + } + ] + }, + { + "Id": "OKTA-APP-001670", + "Name": "Okta must be configured to accept Personal Identity Verification (PIV) credentials.", + "Description": "The use of PIV credentials facilitates standardization and reduces the risk of unauthorized access. DOD has mandated the use of the common access card (CAC) to support identity management and personal authentication for systems covered under HSPD 12, as well as a primary component of layered protection for national security systems. Satisfies: SRG-APP-000391, SRG-APP-000402, SRG-APP-000403", + "Checks": [ + "authenticator_smart_card_active" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273204r1098879_rule", + "StigID": "OKTA-APP-001670", + "CCI": [ + "CCI-001953", + "CCI-002009", + "CCI-002010" + ], + "CheckText": "From the Admin Console: 1. Go to Security >> Authenticators. 2. Verify that \"Smart Card Authenticator\" is listed and has \"Status\" listed as \"Active\". If \"Smart Card Authenticator\" is not listed or is not listed as \"Active\", this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Authenticators. 2. In the \"Setup\" tab, click \"Add authenticator\". 3. Select the configured Smart Card Identity Provider and finish configuration." + } + ] + }, + { + "Id": "OKTA-APP-001700", + "Name": "The Okta Verify application must be configured to connect only to FIPS-compliant devices.", + "Description": "Without device-to-device authentication, communications with malicious devices may be established. Bidirectional authentication provides stronger safeguards to validate the identity of other devices for connections that are of greater risk. Currently, DOD requires the use of AES for bidirectional authentication because it is the only FIPS-validated AES cipher block algorithm. For distributed architectures (e.g., service-oriented architectures), the decisions regarding the validation of authentication claims may be made by services separate from the services acting on those decisions. In such situations, it is necessary to provide authentication decisions (as opposed to the actual authenticators) to the services that need to act on those decisions. A local connection is any connection with a device communicating without the use of a network. A network connection is any connection with a device that communicates through a network (e.g., local area or wide area network; the internet). A remote connection is any connection with a device communicating through an external network (e.g., the internet). Because of the challenges of applying this requirement on a large scale, organizations are encouraged to apply the requirement only to those limited number (and type) of devices that truly need to support this capability.", + "Checks": [ + "authenticator_okta_verify_fips_compliant" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273205r1098882_rule", + "StigID": "OKTA-APP-001700", + "CCI": [ + "CCI-001967" + ], + "CheckText": "From the Admin Console: 1. Go to Security >> Authenticators. 2. From the \"Setup\" tab, select \"Edit Okta Verify\". 3. Review the \"FIPS Compliance\" field. If FIPS-compliant authentication is not enabled, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Authenticators. 2. From the \"Setup\" tab, select \"Edit Okta Verify\". 3. In the \"FIPS Compliance\" field, choose whether users enrolling in Okta Verify can use FIPS-compliant devices only or any device. 4. Click \"Save\" after making any changes." + } + ] + }, + { + "Id": "OKTA-APP-001710", + "Name": "Okta must be configured to disable persistent global session cookies.", + "Description": "If cached authentication information is out of date, the validity of the authentication information may be questionable. Satisfies: SRG-APP-000400, SRG-APP-000157", + "Checks": [ + "signon_global_session_cookies_not_persistent" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273206r1098885_rule", + "StigID": "OKTA-APP-001710", + "CCI": [ + "CCI-002007", + "CCI-001942" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Global Session Policy. 2. In the Default Policy, verify a rule is configured at Priority 1 that is not named \"Default Rule\". 3. Click the \"Edit\" icon next to the Priority 1 rule. 4. Verify \"Okta global session cookies persist across browser sessions\" is set to \"Disabled\". If the above it not set, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Global Session Policy. 2. Select the Default Policy. 3. In the \"Rules\" table, make these updates: - Click \"Add rule\". - Set \"Okta global session cookies persist across browser sessions\" to Disable." + } + ] + }, + { + "Id": "OKTA-APP-001920", + "Name": "Okta must be configured to use only DOD-approved certificate authorities.", + "Description": "Untrusted Certificate Authorities (CA) can issue certificates, but they may be issued by organizations or individuals that seek to compromise DOD systems or by organizations with insufficient security controls. If the CA used for verifying the certificate is not DOD approved, trust of this CA has not been established. The DOD will accept only PKI certificates obtained from a DOD-approved internal or external CA. Reliance on CAs for the establishment of secure sessions includes, for example, the use of Transport Layer Security (TLS) certificates. This requirement focuses on communications protection for the application session rather than for the network packet. This requirement applies to applications that use communications sessions. This includes, but is not limited to, web-based applications and Service-Oriented Architectures (SOA). Satisfies: SRG-APP-000427, SRG-APP-000910", + "Checks": [ + "idp_smart_card_dod_approved_ca" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273207r1098888_rule", + "StigID": "OKTA-APP-001920", + "CCI": [ + "CCI-002470", + "CCI-004909" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Identity Providers (IdPs). 2. Review the list of IdPs with \"Type\" as \"Smart Card\". If the IdP is not listed as \"Active\", this is a finding. 3. Select Actions >> Configure. 4. Under \"Certificate chain\", verify the certificate is from a DOD-approved CA. If the certificate is not from a DOD-approved CA, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Identity Providers. 2. Click \"Add identity provider.\" 3. Click \"Smart Card IdP\". Click \"Next\". 4. Enter the name of the identity provider. 5. Build a certificate chain: - Click \"Browse\" to open a file explorer. Select the certificate file to add and click \"Open\". - To add another certificate, click \"Add Another\" and repeat step 1. - Click \"Build certificate chain\". On success, the chain and its certificates are shown. If the build failed, correct any issues and try again. - Click \"Reset certificate chain\" if replacing the current chain with a new one. 6. In \"IdP username\", select the \"idpuser.subjectAltNameUpn\" attribute. This is the attribute that stores the Electronic Data Interchange Personnel Identifier (EDIPI) on the CAC. 7. In the \"Match Against\" field, select the Okta Profile Attribute in which the EDIPI is to be stored." + } + ] + }, + { + "Id": "OKTA-APP-002980", + "Name": "Okta must validate passwords against a list of commonly used, expected, or compromised passwords.", + "Description": "Password-based authentication applies to passwords regardless of whether they are used in single-factor or multifactor authentication. Long passwords or passphrases are preferable over shorter passwords. Enforced composition rules provide marginal security benefits while decreasing usability. However, organizations may choose to establish certain rules for password generation (e.g., minimum character length for long passwords) under certain circumstances and can enforce this requirement in IA-5(1)(h). Account recovery can occur, for example, in situations when a password is forgotten. Cryptographically protected passwords include salted one-way cryptographic hashes of passwords. The list of commonly used, compromised, or expected passwords includes passwords obtained from previous breach corpuses, dictionary words, and repetitive or sequential characters. The list includes context-specific words, such as the name of the service, username, and derivatives thereof.", + "Checks": [ + "authenticator_password_common_password_check" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273208r1099769_rule", + "StigID": "OKTA-APP-002980", + "CCI": [ + "CCI-004058" + ], + "CheckText": "From the Admin Console: 1. Navigate to Security >> Authenticators. 2. Click the \"Actions\" button next to the Password authenticator and select \"Edit\". 3. Under the \"Password Settings\" section, verify the \"Common Password Check\" box is checked. If \"Common Password Check\" is not selected, this is a finding.", + "FixText": "From the Admin Console: 1. Navigate to Security >> Authenticators. 2. Click the \"Actions\" button next to the Password authenticator and select \"Edit\". 3. Under the \"Password Settings\" section, check the \"Common Password Check\" box." + } + ] + }, + { + "Id": "OKTA-APP-003010", + "Name": "Okta must prohibit password reuse for a minimum of five generations.", + "Description": "Password-based authentication applies to passwords regardless of whether they are used in single-factor or multifactor authentication. Long passwords or passphrases are preferable over shorter passwords. Enforced composition rules provide marginal security benefits while decreasing usability. However, organizations may choose to establish certain rules for password generation (e.g., minimum character length for long passwords) under certain circumstances and can enforce this requirement in IA-5(1)(h). Account recovery can occur, for example, in situations when a password is forgotten. Cryptographically protected passwords include salted one-way cryptographic hashes of passwords. The list of commonly used, compromised, or expected passwords includes passwords obtained from previous breach corpuses, dictionary words, and repetitive or sequential characters. The list includes context-specific words, such as the name of the service, username, and derivatives thereof.", + "Checks": [ + "authenticator_password_history_5" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273209r1098894_rule", + "StigID": "OKTA-APP-003010", + "CCI": [ + "CCI-004061" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password row\" and select \"Edit\". 3. For each listed policy, verify \"Enforce password history for last XX passwords\" is set to \"5\". If any policy is not set to at least \"5\", this is a finding.", + "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Enforce password history for last XX passwords\" to \"5\"." + } + ] + }, + { + "Id": "OKTA-APP-003240", + "Name": "Okta API tokens must be configured with Network Zones to restrict authorization from known networks.", + "Description": "An access token is a piece of data that represents the authorization granted to a user or NPE to access specific systems or information resources. Access tokens enable controlled access to services and resources. Properly managing the lifecycle of access tokens, including their issuance, validation, and revocation, is crucial to maintaining confidentiality of data and systems. Restricting token validity to a specific audience, e.g., an application or security domain, and restricting token validity lifetimes are important practices. Access tokens are revoked or invalidated if they are compromised, lost, or are no longer needed to mitigate the risks associated with stolen or misused tokens. API tokens have the potential to be replicated or stolen (just like a password). Because of this, it is important to only allow API tokens to authenticate from known IP ranges as this limits an adversary's ability to use a token to gain access.", + "Checks": [ + "apitoken_restricted_to_network_zone" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-279689r1155066_rule", + "StigID": "OKTA-APP-003240", + "CCI": [ + "CCI-005165", + "CCI-000366" + ], + "CheckText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"API\" item. 2. Click the \"Tokens\" tab. 3. For each token listed, click the token name link. 4. In the \"Security\" section, verify the \"Token can be used from\" setting is mapped to a known network zone for the application calling the API. If a network zone for each API access token is not defined, this is a finding.", + "FixText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"API\" item. 2. Click the \"Tokens\" tab. 3. For each token listed, click the token name link. 4. In the \"Security\" section, click \"Edit\". 5. Set the \"Token can be used from\" setting to the known network zone for the application calling the API. 6. Click \"Save\"." + } + ] + }, + { + "Id": "OKTA-APP-003241", + "Name": "Okta API tokens must be created under new dedicated user accounts.", + "Description": "An access token is a piece of data that represents the authorization granted to a user or NPE to access specific systems or information resources. Access tokens enable controlled access to services and resources. Properly managing the lifecycle of access tokens, including their issuance, validation, and revocation, is crucial to maintaining confidentiality of data and systems. Restricting token validity to a specific audience, e.g., an application or security domain, and restricting token validity lifetimes are important practices. Access tokens are revoked or invalidated if they are compromised, lost, or are no longer needed to mitigate the risks associated with stolen or misused tokens. When API tokens are created, they inherit the permissions of the user that created them. Therefore, API tokens should only be created from dedicated accounts and permissions must be constrained to least privilege for that dedicated user account and token. No API tokens should be created using a Super Admin account.", + "Checks": [ + "apitoken_not_super_admin" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-279690r1155069_rule", + "StigID": "OKTA-APP-003241", + "CCI": [ + "CCI-005165", + "CCI-000366" + ], + "CheckText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"API\" item. 2. Click the \"Tokens\" tab. 3. For each token listed, verify that the Role listed is not \"Super Admin\", and that the account has been specifically created for that token. 4. Click the account name to be token to the user profile for that user. 5. Verify the user only has an administrator role (standard or customer) applied that is correctly scoped as required and documented in the Okta Access Control policy. If the token is using a Super Administrator account, or one that is not properly scoped per the Access Control policy, this is a finding. Note: If a Super Admin token is required for system operation, then this permanent finding.", + "FixText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"API\" item. 2. Click the \"Tokens\" tab. 3. For each token listed that has \"Super Admin\" or an improperly scoped Admin account, delete the token and create a new one with the appropriately scoped permissions. 4. Verify the application performing the API calls with the new token has been updated." + } + ] + }, + { + "Id": "OKTA-APP-003242", + "Name": "The Okta Global Session policy must be configured to allow or deny IP based access in accordance with the Access Control policy for Okta.", + "Description": "To mitigate the risk of unauthorized access to sensitive information by entities that have been issued certificates by DOD-approved PKIs, all DOD systems (e.g., networks, web servers, and web portals) must be properly configured to incorporate access control methods that do not rely solely on the possession of a certificate for access. Successful authentication must not automatically give an entity access to an asset or security boundary. Authorization procedures and controls must be implemented to ensure each authenticated entity also has a validated and current authorization. Authorization is the process of determining whether an entity, once authenticated, is permitted to access a specific asset. Information systems use access control policies and enforcement mechanisms to implement this requirement. Access Control policies include identity-based policies, role-based policies, and attribute-based policies. Access enforcement mechanisms include access control lists, access control matrices, and cryptography. These policies and mechanisms must be employed by the application to control access between users (or processes acting on behalf of users) and objects (e.g., devices, files, records, processes, programs, and domains) in the information system. The Okta Global Session Policy is applied at the organization level and before any application-specific authentication policies are processed. The Okta authorization package should contain an access control policy that defines IP ranges from which to either allow or deny access. This list (either as an explicit allow or explicit deny) can be implemented in the Global Session Policy.", + "Checks": [ + "signon_global_session_policy_network_zone_enforced" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-279691r1155072_rule", + "StigID": "OKTA-APP-003242", + "CCI": [ + "CCI-000213" + ], + "CheckText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"Global Session Policy\" item. 2. In the \"Policy Settings\" section, verify the \"IF User's IP is\" setting is correctly set to either allow or deny based on the organization defined policy. If the Okta Global Session Policy is not configured to restrict access to specific IP ranges, this is a finding.", + "FixText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"Global Session Policy\" item. 2. In the Policy Settings section, configure the \"IF User's IP is\" setting to correctly set the appropriate network to either allow or deny based on the Access Control Policy." + } + ] + }, + { + "Id": "OKTA-APP-003243", + "Name": "Okta must be configured with Network Zones defined to block anonymized proxies according to organizationally defined policy.", + "Description": "A mechanism to detect and prevent unauthorized communication flow must be configured or provided as part of the system design. If information flow is not enforced based on approved authorizations, the system may become compromised. Information flow control regulates where information is allowed to travel within a system and between interconnected systems. The flow of all application information must be monitored and controlled so it does not introduce any unacceptable risk to the systems or data. Application-specific examples of enforcement occurs in systems that employ rule sets or establish configuration settings that restrict information system services, or provide a message filtering capability based on message content (e.g., implementing key word searches or using document characteristics). Applications providing information flow control must be able to enforce approved authorizations for controlling the flow of information between interconnected systems in accordance with applicable policy. Working with the organizational CSSP, the ISSM should obtain a list of known anonymizer proxies that exist on the commercial internet. If this is not available from the CSSP, then the Okta-provided \"Enhanced dynamic zone blocklist\" should be activated.", + "Checks": [ + "network_zone_block_anonymized_proxies" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-279692r1155075_rule", + "StigID": "OKTA-APP-003243", + "CCI": [ + "CCI-001414" + ], + "CheckText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"Networks' item. 2. If the CSSP has provided a list of anonymizers to block, verify the \"IP Block list\" is configured with them. a. Click the pencil icon next to IP Block list. b. Verify the \"Gateway IPs\" section contains all of the IP ranges in the provided list. 3. If the CSSP is not able to provide a list, then implement the Okta managed list. a. Verify the \"Enhanced dynamic zone blocklist\" is set to \"Active\". If Network Zones are not configured to block anonymous proxies, this is a finding.", + "FixText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"Networks\" item. 2. If the CSSP has provided a list of anonymizers to block, add the IP ranges to the \"IP Block list\". a. Click the pencil icon next to IP Block list. b. Add the IP ranges to the \"Gateway IPs\" section and click \"Save\". 3. If the CSSP is not able to provide a list, then implement the Okta managed list. a. Set the \"Enhanced dynamic zone blocklist\" to \"Active\"." + } + ] + }, + { + "Id": "OKTA-APP-003244", + "Name": "For each application integrated with Okta, network zones must be defined in its authentication policy.", + "Description": "A mechanism to detect and prevent unauthorized communication flow must be configured or provided as part of the system design. If information flow is not enforced based on approved authorizations, the system may become compromised. Information flow control regulates where information is allowed to travel within a system and between interconnected systems. The flow of all application information must be monitored and controlled so it does not introduce any unacceptable risk to the systems or data. Application-specific examples of enforcement occurs in systems that employ rule sets or establish configuration settings that restrict information system services, or provide a message filtering capability based on message content (e.g., implementing key word searches or using document characteristics). Applications providing information flow control must be able to enforce approved authorizations for controlling the flow of information between interconnected systems in accordance with applicable policy. Each application in Okta should have a well defined access control policy that takes into account the end user network. This should be documented in the Access Control policy for each application. As an example, access to an application may be restricted to a specific location by policy. In this case, a network defining that specific location should be created.", + "Checks": [ + "application_authentication_policy_network_zone_enforced" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-279693r1155078_rule", + "StigID": "OKTA-APP-003244", + "CCI": [ + "CCI-001414" + ], + "CheckText": "For each application integrated into Okta: 1. From the Admin console, open the \"Security\" menu, and then select \"Networks\". 2. Verify the list of networks includes all necessary allow or block lists. If any application is not configured with network zones, this is a finding.", + "FixText": "For each application, starting at the admin console: 1. Open the \"Applications\" group from the Menu, and then click the \"Applications\" menu item. 2. Click the application name. 3. Click the \"Sign On\" tab. 4. Scroll to the \"User Authentication\" section, and then click \"Edit\". 5. Select the appropriate Authentication policy from the pull down, and then click \"Save\". 6. Click \"View Policy Details\". 7. For each nondefault rule: a. Select \"Edit\" from the Actions menu. b. In the \"IF\" section, verify the \"User is\" setting has the appropriate allow or deny range has been selected based on the Access Control policy for the application. c. Scroll down to the bottom and click \"Save\". 8. For the Catch-All rule: a. Select \"Edit\" from the Actions menu. b. Scroll down to the \"Then\" section. c. For the \"Access is\" setting, select \"Denied\", and then click \"Save\"." + } + ] + } + ] +} diff --git a/prowler/compliance/oraclecloud/cis_3.1_oraclecloud.json b/prowler/compliance/oraclecloud/cis_3.1_oraclecloud.json index ceca68d124..8140dca6cc 100644 --- a/prowler/compliance/oraclecloud/cis_3.1_oraclecloud.json +++ b/prowler/compliance/oraclecloud/cis_3.1_oraclecloud.json @@ -302,7 +302,9 @@ { "Id": "1.15", "Description": "Ensure storage service-level admins cannot delete resources they manage", - "Checks": [], + "Checks": [ + "identity_storage_service_level_admins_scoped" + ], "Attributes": [ { "Section": "1. Identity and Access Management", diff --git a/prowler/compliance/oraclecloud/csa_ccm_4.0_oraclecloud.json b/prowler/compliance/oraclecloud/csa_ccm_4.0_oraclecloud.json deleted file mode 100644 index 300e32788d..0000000000 --- a/prowler/compliance/oraclecloud/csa_ccm_4.0_oraclecloud.json +++ /dev/null @@ -1,7307 +0,0 @@ -{ - "Framework": "CSA-CCM", - "Name": "CSA Cloud Controls Matrix (CCM) v4.0.13", - "Version": "4.0", - "Provider": "OracleCloud", - "Description": "The Cloud Security Alliance (CSA) Cloud Controls Matrix (CCM) is a cybersecurity control framework for cloud computing, composed of 197 control objectives structured in 17 domains covering all key aspects of cloud technology. The CCM can be used as a tool for the systematic assessment of a cloud implementation, and provides guidance on which security controls should be implemented by which actor within the cloud supply chain.", - "Requirements": [ - { - "Id": "A&A-02", - "Description": "Conduct independent audit and assurance assessments according to relevant standards at least annually.", - "Name": "Independent Assessments", - "Attributes": [ - { - "Section": "Audit & Assurance", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC4.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AAC-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.5.2", - "5.2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "AS1.1", - "AS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.2.1", - "27002: 18.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.35", - "27001: A.5.36" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-2", - "CA-2(1)", - "CA-2(2)", - "CA-7", - "CA-7(1)" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-01" - ] - } - ] - } - ], - "Checks": [ - "cloudguard_enabled" - ] - }, - { - "Id": "A&A-04", - "Description": "Verify compliance with all relevant standards, regulations, legal/contractual, and statutory requirements applicable to the audit.", - "Name": "Requirements Compliance", - "Attributes": [ - { - "Section": "Audit & Assurance", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC3.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-01", - "GRM-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "7.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "AS1.1", - "AS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.3.2", - "27001: A.18.2.2", - "27002: 18.2.2", - "27001: A.18.2.3", - "27002: 18.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.3.2", - "27001: A.5.31", - "27001: A.5.32", - "27001: A.5.33", - "27001: A.5.34", - "27001: A.5.36" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-1" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-3", - "DE.DP-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-01" - ] - } - ] - } - ], - "Checks": [ - "cloudguard_enabled" - ] - }, - { - "Id": "AIS-04", - "Description": "Define and implement a SDLC process for application design, development, deployment, and operation in accordance with security requirements defined by the organization.", - "Name": "Secure Application Design and Development", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AIS-01", - "AIS-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.4", - "5.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.1.1", - "27002: 14.1.1", - "27017: 14.1.1", - "27001: A.14.1.2", - "27002: 14.1.2", - "27017: 14.1.2", - "27001: A.14.2.1", - "27002: 14.2.1", - "27017: 14.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.8", - "27001: A.8.25", - "27001: A.8.26", - "27001: A.8.28" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-2", - "PL-8", - "PL-8(1)", - "SA-3", - "SA-3(1)", - "SA-4", - "SA-4(2)", - "SA-4(3)", - "SA-4(8)", - "SA-4(9)", - "SA-5", - "SA-8", - "SA-8(1)-(7)", - "SA-8(9)-(13)", - "SA-8(15)-(20)", - "SA-8(22)", - "SA-8(24)-(28)", - "SA-8(30)-(33)", - "SA-17", - "SA-17(1)-(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-6", - "PR.DS-7", - "PR.IP-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "PR.IR-01", - "PR.PS-01", - "PR.PS-02", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.1", - "6.2.3", - "6.5.2" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "AIS-05", - "Description": "Implement a testing strategy, including criteria for acceptance of new information systems, upgrades and new versions, which provides application security assurance and maintains compliance while enabling organizational speed of delivery goals. Automate when applicable and possible.", - "Name": "Automated Application Security Testing", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "AIS-01", - "AIS-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.12", - "16.13" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD2.3", - "SD2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.2.8", - "27001: A.14.2.9", - "27001: A.12.1.2", - "27002: 12.1.2", - "27001: A.14.1.1", - "27002: 14.1.1", - "27001: A.14.2.2", - "27002: 14.2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.25", - "27001: A.8.29", - "27001: A.8.32", - "27002: 8.25 (e)", - "27002: 8.32 (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SA-11", - "SA-11(1)-(9)", - "SI-6", - "SI-6(2)", - "SI-6(3)", - "SI-10", - "SI-10(1)-(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.PT-3", - "PR.IP-12", - "DE.CM-8" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "ID.RA-01", - "PR.PS-01", - "PR.PS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A.3.2.2", - "A.3.2.2.1", - "6.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.4", - "6.4.1", - "6.4.2", - "6.5.1" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "AIS-07", - "Description": "Define and implement a process to remediate application security vulnerabilities, automating remediation when possible.", - "Name": "Application Vulnerability Remediation", - "Attributes": [ - { - "Section": "Application & Interface Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.1", - "CC7.4", - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.2", - "16.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27001: A.12.6.1", - "27002: 12.6.1", - "27017: 12.6.1", - "27018: 12.6.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.26", - "27001: A.8.8", - "27002: 5.26 (j)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SI-2", - "SI-2(2)-(6)", - "SA-11", - "SA-11(2)", - "SA-15", - "SA-15(1)-(3)", - "SA-15(5)-(8)", - "SA-15(10)-(12)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.IP-12", - "DE.CM-8", - "RS.AN-5", - "RS.MI-3", - "PR.DS-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "ID.RA-01", - "ID.RA-06", - "ID.RA-08", - "PR.PS-02", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.2", - "6.5", - "6.5.1-10" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "11.3.1", - "11.3.1.1" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "BCR-08", - "Description": "Periodically backup data stored in the cloud. Ensure the confidentiality, integrity and availability of the backup, and verify data restoration from backup for resiliency.", - "Name": "Backup", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "A1.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "11.1", - "11.2", - "11.3", - "11.4", - "11.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8", - "5.2.9" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.3", - "27017: 12.3", - "27018: 12.3.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.13", - "27001: A.5.23", - "27001: A.5.30", - "27002: 8.13", - "27002: 5.23 2nd (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-4", - "CP-4(4)", - "CP-6", - "CP-6(1)-(3)", - "CP-9", - "CP-9(1)", - "CP-9(2)", - "CP-10", - "CP-10(2)", - "CP-10(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-4", - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-11", - "RC.RP-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "9.5.1", - "12.10.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1", - "10.3.3" - ] - } - ] - } - ], - "Checks": [ - "objectstorage_bucket_versioning_enabled" - ] - }, - { - "Id": "BCR-09", - "Description": "Establish, document, approve, communicate, apply, evaluate and maintain a disaster response plan to recover from natural and man-made disasters. Update the plan at least annually or upon significant changes.", - "Name": "Disaster Response Plan", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "CC3.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8", - "5.2.9", - "1.6.1", - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "BC1.4", - "BC2.1", - "BC2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.29", - "27001: A.5.30", - "27002: 5.29", - "27002: 5.30" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2(1)", - "CP-2(2)", - "CP-2(3)", - "CP-2(5)", - "CP-2(6)", - "CP-2(7)", - "CP-2(8)", - "PE-13", - "PE-13(1)", - "PE-13(2)", - "PE-13(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-9", - "PR.IP-10", - "RC.IM-1", - "RC.IM-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.IM-04" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "BCR-11", - "Description": "Supplement business-critical equipment with redundant equipment independently located at a reasonable minimum distance in accordance with applicable industry standards.", - "Name": "Equipment Redundancy", - "Attributes": [ - { - "Section": "Business Continuity Management and Operational Resilience", - "CCMLite": "No", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.2", - "CC3.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-06" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.8" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "BC1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.20", - "27001: A.7.11", - "27001: A.8.14", - "27002: 5.20 (t)", - "27002: 8.14 (c)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2", - "CP-2(2)", - "CP-4(3)", - "CP-6", - "CP-6(1)", - "CP-7", - "CP-8", - "CP-8(1)-(3)", - "CP-9", - "CP-9(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.BE-4", - "ID.BE-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.OC-04", - "GV.OC-05", - "PR.IR-03" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "CCC-04", - "Description": "Restrict the unauthorized addition, removal, update, and management of organization assets.", - "Name": "Unauthorized Change Protection", - "Attributes": [ - { - "Section": "Change Control and Configuration Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "CCC-04" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.1", - "1.3.4", - "5.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.4", - "SM2.6" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.1.4", - "27002: 12.1.4", - "27001: A.12.4.2", - "27002: 12.4.2", - "27001: A.14.2.2", - "27017: 14.2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.3", - "27001: A.8.4", - "27001: A.8.15", - "27001: A.8.31", - "27001: A.8.32" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-7", - "CA-7(4)", - "CM-3", - "CM-3(1)", - "CM-3(5)", - "CM-3(7)", - "CM-3(8)", - "CM-5", - "CM-5(1)", - "CM-5(4)", - "CM-5(5)", - "CM-6", - "CM-6(1)", - "CM-6(2)", - "CM-7", - "CM-7(1)", - "CM-7(4)", - "CM-7(5)", - "CM-7(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-1", - "ID.AM-2", - "ID.AM-4", - "PR.MA-1", - "PR.MA-2", - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-01", - "ID.AM-02", - "ID.AM-04", - "ID.AM-08", - "PR.PS-02", - "PR.PS-03", - "PR.PS-05", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.5.1", - "6.5.2" - ] - } - ] - } - ], - "Checks": [ - "events_rule_iam_group_changes", - "events_rule_iam_policy_changes", - "events_rule_user_changes", - "events_rule_vcn_changes" - ] - }, - { - "Id": "CCC-07", - "Description": "Implement detection measures with proactive notification in case of changes deviating from the established baseline.", - "Name": "Detection of Baseline Deviation", - "Attributes": [ - { - "Section": "Change Control and Configuration Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC8.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-01" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.5.1", - "1.5.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.2.2", - "27001: A.14.2.4", - "27001: A.12.4.1", - "27002: 12.4.1 (g)", - "27001: A.5.1.1", - "27017: 5.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.9", - "27001: A.8.15", - "27002: 8.9" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-6", - "CM-6(2)", - "SI-2", - "SI-2(2)-(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.MA-1", - "PR.IP-1", - "DE.DP-4", - "PR.IP-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-01", - "DE.CM-09", - "DE.AE-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4.5.3", - "6.4.5.4", - "11.5", - "11.5.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "11.5.2", - "11.6.1" - ] - } - ] - } - ], - "Checks": [ - "cloudguard_enabled", - "events_rule_cloudguard_problems", - "events_rule_network_gateway_changes", - "events_rule_network_security_group_changes", - "events_rule_route_table_changes", - "events_rule_security_list_changes", - "events_rule_vcn_changes" - ] - }, - { - "Id": "CEK-03", - "Description": "Provide cryptographic protection to data at-rest and in-transit, using cryptographic libraries certified to approved standards.", - "Name": "Data Encryption", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-03", - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.6", - "3.1", - "3.11", - "11.3", - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.1", - "27001: A.18.1.2", - "27001: A.18.1.3", - "27001: A.18.1.4", - "27001: A.18.1.5", - "27001: A.10.1", - "27002: 10.1", - "27001: A.13.2.1", - "27002: 13.2.1", - "27001: A.18", - "27002: 18", - "27001: A.14.1.2", - "27002: 14.1.2", - "27001: A.14.1.3", - "27002 14.1.3 c)", - "27001 - A.10.1.1", - "27017 - 10.1.1", - "27001 - A.10.1.2", - "27017 - 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.8.24", - "27002: 8.24 Other Information (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-19", - "AC-19(5)", - "SC-8", - "SC-8(1)", - "SC-8(3)", - "SC-8(4)", - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-28", - "SC-28(1)-(3)", - "SI-4", - "SI-4(10)", - "SI-7", - "SI-7(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "Requirement 3", - "2.2.3", - "2.3", - "3.4", - "3.5.3", - "4.1", - "8.2.1", - "PCI Glossary - Strong Cryptography" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.7", - "3.5.1", - "4.2.1", - "4.2.1.2", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "blockstorage_block_volume_encrypted_with_cmk", - "blockstorage_boot_volume_encrypted_with_cmk", - "compute_instance_in_transit_encryption_enabled", - "filestorage_file_system_encrypted_with_cmk", - "objectstorage_bucket_encrypted_with_cmk" - ] - }, - { - "Id": "CEK-04", - "Description": "Use encryption algorithms that are appropriate for data protection, considering the classification of data, associated risks, and usability of the encryption technology.", - "Name": "Encryption Algorithm", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.2", - "27001: 6.1.3", - "27001: A.8.2", - "27002: 8.2", - "27001: A.8.3", - "27001: A.10.1.1", - "27002: 10.1.1 (b)", - "27001: A.10.1.2", - "27002: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.2", - "27001: 6.1.3", - "27001: A.8.24", - "27001: A.5.12", - "27001: A.5.13", - "27002: 8.24 General (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2", - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02", - "ID.AM-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A2", - "Requirement 3", - "2.3", - "2.2.3", - "3.4", - "3.5.3", - "4.1", - "8.2.1", - "PCI Glossary - Strong Cryptography" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.7", - "3.5.1", - "4.2.1", - "4.2.1.2", - "4.2.2" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "CEK-08", - "Description": "CSPs must provide the capability for CSCs to manage their own data encryption keys.", - "Name": "CSC Key Management Capability", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2", - "SC2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1", - "27017: 10.1", - "27001: A.10.1.1", - "27017: 10.1.1", - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.23", - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-9", - "CP-9(8)", - "SA-9", - "SA-9(6)", - "SC-12", - "SC-12(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.SC-3", - "ID.AM-6", - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.SC-05" - ] - } - ] - } - ], - "Checks": [ - "blockstorage_block_volume_encrypted_with_cmk", - "blockstorage_boot_volume_encrypted_with_cmk", - "filestorage_file_system_encrypted_with_cmk", - "objectstorage_bucket_encrypted_with_cmk" - ] - }, - { - "Id": "CEK-10", - "Description": "Generate Cryptographic keys using industry accepted cryptographic libraries specifying the algorithm strength and the random number generator used.", - "Name": "Key Generation", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2", - "TS2.3", - "SY1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27002: 10.1.1 (e)", - "27017: 10.1.1", - "27001: A.10.1.2", - "27002: 10.1.2", - "27002: 10.1.2 (a)", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24", - "27002: 8.24 (d), Key management (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.2.3", - "3.6.1", - "PCI Glossary - Cryptographic Key Generation" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1", - "3.6.1.1", - "3.7.1" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "CEK-12", - "Description": "Rotate cryptographic keys in accordance with the calculated cryptoperiod, which includes provisions for considering the risk of information disclosure and legal and regulatory requirements.", - "Name": "Key Rotation", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27017: 10.1.1", - "27001: A.10.1.2", - "27002: 10.1.2 e)", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.31", - "27001: A.8.24", - "27002: 5.31 Cryptography", - "27002: 8.24 Key management (e,m)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)", - "SC-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "ID.GV-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05", - "GV.OC-03" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.7.4", - "3.7.5" - ] - } - ] - } - ], - "Checks": [ - "kms_key_rotation_enabled", - "identity_user_api_keys_rotated_90_days", - "identity_user_auth_tokens_rotated_90_days", - "identity_user_customer_secret_keys_rotated_90_days", - "identity_user_db_passwords_rotated_90_days" - ] - }, - { - "Id": "CEK-14", - "Description": "Define, implement and evaluate processes, procedures and technical measures to destroy keys stored outside a secure environment and revoke keys stored in Hardware Security Modules (HSMs) when they are no longer needed, which include provisions for legal and regulatory requirements.", - "Name": "Key Destruction", - "Attributes": [ - { - "Section": "Cryptography, Encryption & Key Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.1", - "27017: 10.1.1", - "27017: 10.1.2", - "27001: A.10.1.2", - "27002: 10.1.2 (j)", - "27001: A.18.1.3", - "27002: 18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.31", - "27001: A.8.24", - "27002: 5.31 Cryptography", - "27002: 8.24 Key management (j,m)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-12", - "SC-12(2)", - "SC-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.IP-6", - "ID.GV-3", - "PR.DS-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05", - "ID.AM-08", - "GV.OC-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.6.4", - "3.6.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.7.4", - "3.7.5" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "DCS-06", - "Description": "Catalogue and track all relevant physical and logical assets located at all of the CSP's sites within a secured system.", - "Name": "Assets Cataloguing and Tracking", - "Attributes": [ - { - "Section": "Datacenter Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DCS - 01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "1.1", - "2.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SM2.6" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.1.1", - "27002: 8.1.1", - "27017: 8.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.9" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-8", - "CM-8(1)", - "CM-8(2)", - "CM-8(4)", - "CM-8(7)", - "CM-8(8)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-1", - "ID.AM-2", - "ID.AM-4", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-01", - "ID.AM-02", - "ID.AM-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.4", - "9.7.1", - "9.9.1", - "9.9.1.a", - "9.9.1.b", - "9.9.1.c", - "12.3.3", - "12.3.4" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1.1", - "6.3.2", - "9.4.2", - "9.4.3", - "12.5.1" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "DSP-02", - "Description": "Apply industry accepted methods for the secure disposal of data from storage media such that data is not recoverable by any forensic means.", - "Name": "Secure Disposal", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3", - "CC6.4", - "CC6.5", - "CC6.7", - "P4.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DSI-07" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.3.3", - "7.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.3.2", - "27002: 8.3.2", - "27001: A.11.2.7", - "27002: 11.2.7" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.7.10", - "27001: A.7.14", - "27001: A.8.10", - "27002: 7.10 (Secure reuse or disposal)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-22", - "SI-12", - "SI-12(3)", - "SI-18", - "SI-18(1)", - "SI-18(4)", - "SI-18(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.SC-10", - "PR.PS-02", - "PR.PS-03", - "ID.AM-08" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.1", - "9.8", - "9.8.1", - "9.8.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1", - "3.7.5", - "9.4.7" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "DSP-03", - "Description": "Create and maintain a data inventory, at least for any sensitive data and personal data.", - "Name": "Data Inventory", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1", - "1.3.2", - "1.3.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.1.1", - "27002: 8.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.9", - "27001: A.8.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-12", - "CM-12(1)", - "PM-5", - "PM-5(1)", - "SI-12", - "SI-12(1)", - "SI-19", - "SI-19(1)", - "SI-19(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1", - "9.4.5" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "DSP-04", - "Description": "Classify data according to its type and sensitivity level.", - "Name": "Data Classification", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "C1.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "DSI-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.7" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.3.1", - "1.3.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.8.2.1", - "27002: 8.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-16", - "AC-16(9)", - "PM-22", - "PM-23", - "PT-2", - "PT-2(1)", - "SI-18", - "SI-18(2)", - "SI-19", - "SI-19(6)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.AM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-05", - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "9.6.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "9.4.2", - "9.4.3" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "DSP-07", - "Description": "Develop systems, products, and business practices based upon a principle of security by design and industry best practices.", - "Name": "Data Protection by Design and Default", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "PI1.2", - "PI1.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "16.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.3.1", - "5.3.2", - "5.3.3", - "5.3.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SD2.2", - "IM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.14.1.1", - "27002:14.1.1", - "27001: A.14.2.5", - "27002:14.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.27", - "27001: A.8.28", - "27001: A.8.29", - "27002: 5.8 (Information security requirements a-i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-17", - "PM-24", - "PM-25", - "PT-2", - "PT-2(2)", - "SA-3", - "SA-4", - "SA-5", - "SA-8", - "SA-8(9)", - "SA-8(13)", - "SA-8(18)", - "SA-8(20)", - "SA-8(22)", - "SA-8(23)", - "SA-8(33)", - "SA-15", - "SA-15(12)", - "SC-3", - "SC-3(3)", - "SC-7", - "SC-7(24)", - "SC-8", - "SC-8(1)-(4)", - "SC-28", - "SC-28(1)", - "SI-12", - "SI-12(1)-(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-2", - "PR.PT-3", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "PR.PS-06" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.2.1" - ] - } - ] - } - ], - "Checks": [ - "objectstorage_bucket_not_publicly_accessible", - "database_autonomous_database_access_restricted", - "analytics_instance_access_restricted", - "integration_instance_access_restricted" - ] - }, - { - "Id": "DSP-10", - "Description": "Define, implement and evaluate processes, procedures and technical measures that ensure any transfer of personal or sensitive data is protected from unauthorized access and only processed within scope as permitted by the respective laws and regulations.", - "Name": "Sensitive Data Transfer", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-02", - "EKM-03" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.1", - "3.12", - "3.13" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "9.5.1", - "9.5.2", - "9.5.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.4", - "IM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.13.2.1", - "27002: 13.2.1", - "27001: A.8.3.3", - "27002: 8.3.3", - "27001: A.13.2.3", - "27002: 13.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.7.10" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-4", - "AC-4(23)-(25)", - "CA-3", - "CA-3(6)", - "CA-6", - "CA-6(1)", - "CA-6(2)", - "SC-4", - "SC-4(2)", - "SC-7", - "SC-7(10)", - "SC-7(24)", - "SC-8", - "SC-8(1)-(5)", - "SC-16", - "SC-16(1)-(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2", - "PR.DS-5", - "PR.PT-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02", - "PR.IR-01", - "ID.AM-03", - "GV.OC-03", - "ID.AM-07" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "4.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "4.1.1", - "4.2.1", - "4.2.2" - ] - } - ] - } - ], - "Checks": [ - "compute_instance_in_transit_encryption_enabled" - ] - }, - { - "Id": "DSP-16", - "Description": "Data retention, archiving and deletion is managed in accordance with business requirements, applicable laws and regulations.", - "Name": "Data Retention and Deletion", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "C1.1", - "C1.2", - "CC3.1", - "P4.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-02", - "BCR-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.4", - "3.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.3.1", - "7.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.33", - "27001: A.8.10", - "27002: 5.33 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SI-12", - "SI-12(1)-(3)", - "SI-18", - "SI-18(1)", - "SI-18(4)", - "SI-18(5)", - "SI-19", - "SI-19(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-3", - "PR.IP-6", - "ID.GV-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-08", - "GV.OC-03", - "GV.SC-10", - "PR.DS-11" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.2.1" - ] - } - ] - } - ], - "Checks": [ - "audit_log_retention_period_365_days" - ] - }, - { - "Id": "DSP-17", - "Description": "Define and implement, processes, procedures and technical measures to protect sensitive data throughout it's lifecycle.", - "Name": "Sensitive Data Protection", - "Attributes": [ - { - "Section": "Data Security and Privacy Lifecycle Management", - "CCMLite": "Yes", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSC-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.1", - "CC6.1", - "CC6.3", - "CC6.7", - "CC8.1", - "C1.1", - "P2.0", - "P3.0", - "P4.0", - "P5.0", - "P6.0" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.1", - "3.1", - "3.14" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.3.3", - "9.1.1", - "9.2.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.1", - "IM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3", - "27002: 18.1.3", - "27001:A.18.1.4", - "27002:18.1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.11", - "27001: A.8.12" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-2", - "PM-22", - "PM-24", - "PT-7", - "PT-7(1)", - "PT-7(2)", - "PT-8", - "SC-8", - "SC-8(1)-(5)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1", - "PR.DS-2", - "PR.DS-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01", - "PR.DS-02", - "PR.DS-10" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.0 (including all subsections)", - "4.0 (including all subsections)" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.1.1", - "4.1.1" - ] - } - ] - } - ], - "Checks": [ - "objectstorage_bucket_not_publicly_accessible", - "objectstorage_bucket_encrypted_with_cmk", - "database_autonomous_database_access_restricted", - "blockstorage_block_volume_encrypted_with_cmk", - "blockstorage_boot_volume_encrypted_with_cmk" - ] - }, - { - "Id": "GRC-05", - "Description": "Develop and implement an Information Security Program, which includes programs for all the relevant domains of the CCM.", - "Name": "Information Security Program", - "Attributes": [ - { - "Section": "Governance, Risk and Compliance", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-04" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "14.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SG2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 4.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-1", - "PM-3", - "PM-14", - "PL-2", - "PM-18", - "PM-31" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.4.1", - "A.3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.4.1", - "A3.1.1" - ] - } - ] - } - ], - "Checks": [ - "cloudguard_enabled" - ] - }, - { - "Id": "IAM-02", - "Description": "Establish, document, approve, communicate, implement, apply, evaluate and maintain strong password policies and procedures. Review and update the policies and procedures at least annually.", - "Name": "Strong Password Policy and Procedures", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-12", - "GRM-06", - "GRM-09" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.1.1", - "1.5.1", - "4.1.2", - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1", - "SA1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5", - "27002: 5", - "27001: A.9.4.3", - "27002: 9.4.3", - "27017: 9.4.3", - "27018: 9.4.3", - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27001: A.7.2.2", - "27002: 7.2.2", - "27001: A.9.2.6", - "27002: 9.2.6", - "27001: A.9.2.3", - "27002: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5.1", - "27001: A.5.4", - "27001: A.5.17", - "27001: A.6.3", - "27001: A.8.5", - "27001: A.5.37" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(3)", - "AC-2(11)", - "AC-3", - "AC-3(3)", - "AC-12", - "AC-12(1)", - "IA-2", - "IA-2(10)", - "IA-5", - "IA-5(1)", - "IA-5(18)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "PR.AC-1", - "PR.AC-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.PO-01", - "GV.PO-02", - "ID.IM-03", - "PR.AA-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.4", - "12.1", - "12.1.1", - "12.11" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.1.1", - "8.3.8" - ] - } - ] - } - ], - "Checks": [ - "identity_password_policy_minimum_length_14", - "identity_password_policy_expires_within_365_days", - "identity_password_policy_prevents_reuse" - ] - }, - { - "Id": "IAM-03", - "Description": "Manage, store, and review the information of system identities, and level of access.", - "Name": "Identity Inventory", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-04", - "IAM-08", - "IAM-10" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1", - "5.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.2 (c)", - "27001: A.8.1.1", - "27002: 8.1.1", - "27001: A.9.4.1", - "27002: 9.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.2 (c)", - "27001: A.5.15", - "27001: A.5.16", - "27001: A.5.18", - "27001: A.7.4", - "27001: A.8.15", - "27001: A.8.2", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-10", - "AU-10(1)", - "AU-10(2)", - "AU-16", - "AU-16(1)", - "IA-4", - "IA-4(8)", - "IA-4(9)", - "IA-5", - "IA-5(5)", - "IA-8", - "IA-8(4)", - "PM-5(1)", - "SA-8", - "SA-8(22)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-04", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.4.a" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.5", - "7.2.5.1" - ] - } - ] - } - ], - "Checks": [ - "identity_user_api_keys_rotated_90_days", - "identity_user_auth_tokens_rotated_90_days", - "identity_user_customer_secret_keys_rotated_90_days", - "identity_user_valid_email_address" - ] - }, - { - "Id": "IAM-04", - "Description": "Employ the separation of duties principle when implementing information system access.", - "Name": "Separation of Duties", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC1.3", - "CC5.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.2.2", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.6.1.2", - "27002: 6.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.18", - "27001: A.5.3", - "27001: A.8.2" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(3)", - "AC-2(11)", - "AC-6", - "AC-6(1)-(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.4", - "6.4.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.5.3", - "6.5.4", - "7.2.1", - "7.2.2" - ] - } - ] - } - ], - "Checks": [ - "identity_service_level_admins_exist", - "identity_iam_admins_cannot_update_tenancy_admins", - "identity_tenancy_admin_permissions_limited" - ] - }, - { - "Id": "IAM-05", - "Description": "Employ the least privilege principle when implementing information system access.", - "Name": "Least Privilege", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-06", - "IVS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.1.1", - "27002: 9.1.1", - "27001: A.9.1.2", - "27002: 9.1.2", - "27001: A.9.2.3", - "27002: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.8.2", - "27002: 5.15 (Other information 2nd (a))" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(4)", - "IA-12", - "IA-12(2)", - "IA-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "7.1", - "7.1.1", - "7.1.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "7.2.2", - "7.2.5", - "7.2.6" - ] - } - ] - } - ], - "Checks": [ - "identity_tenancy_admin_permissions_limited", - "identity_service_level_admins_exist", - "identity_no_resources_in_root_compartment", - "identity_non_root_compartment_exists" - ] - }, - { - "Id": "IAM-07", - "Description": "De-provision or respectively modify access of movers / leavers or system identity changes in a timely manner in order to effectively adopt and communicate identity and access management policies.", - "Name": "User Access Changes and Revocation", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.3", - "6.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.18" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(1)", - "AC-2(2)", - "AC-2(6)", - "AC-2(8)", - "AC-3", - "AC-3(8)", - "AC-6", - "AC-6(7)", - "AU-10", - "AU-10(4)", - "AU-16", - "AU-16(1)", - "CM-7", - "CM-7(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4", - "PR.IP-11" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.RR-04", - "GV.SC-10", - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1.2", - "8.1.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.2.5", - "8.2.6" - ] - } - ] - } - ], - "Checks": [ - "identity_user_api_keys_rotated_90_days", - "identity_user_auth_tokens_rotated_90_days", - "identity_user_customer_secret_keys_rotated_90_days" - ] - }, - { - "Id": "IAM-08", - "Description": "Review and revalidate user access for least privilege and separation of duties with a frequency that is commensurate with organizational risk tolerance.", - "Name": "User Access Review", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-10" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.5", - "27001: A.9.2.6", - "27001: A.9.4.1", - "27017: 9.4.1", - "27001: A.6.1.2", - "27001: A 9.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.3", - "27001: A.5.18", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(4)", - "AC-6(8)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.5.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.5.1", - "7.2.5", - "7.2.4" - ] - } - ] - } - ], - "Checks": [ - "identity_user_api_keys_rotated_90_days", - "identity_user_auth_tokens_rotated_90_days", - "identity_user_customer_secret_keys_rotated_90_days", - "identity_user_db_passwords_rotated_90_days" - ] - }, - { - "Id": "IAM-09", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the segregation of privileged access roles such that administrative access to data, encryption and key management capabilities and logging capabilities are distinct and separated.", - "Name": "Segregation of Privileged Access Roles", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.1", - "CC6.1", - "CC6.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.3", - "27002: 9.2.3", - "27017: 9.2.3", - "27018: 9.2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.2", - "27001: A.8.18", - "27002: 8.2 (j)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-3(7)", - "AC-6(4)", - "AC-6(8)", - "IA-5", - "IA-5(6)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.3", - "3.5.2", - "7.1.2", - "7.1.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.6.1", - "3.7.6", - "6.5.3", - "6.5.4", - "7.2.1", - "7.2.2", - "10.3.1" - ] - } - ] - } - ], - "Checks": [ - "identity_tenancy_admin_permissions_limited", - "identity_iam_admins_cannot_update_tenancy_admins", - "identity_tenancy_admin_users_no_api_keys" - ] - }, - { - "Id": "IAM-10", - "Description": "Define and implement an access process to ensure privileged access roles and rights are granted for a time limited period, and implement procedures to prevent the culmination of segregated privileged access.", - "Name": "Management of Privileged Access Roles", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1", - "6.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.3", - "27002: 9.2.3", - "27017: 9.2.3", - "27018: 9.2.3", - "27001: A.9.4.4", - "27002: 9.4.4", - "27017: 9.4.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.2", - "27001: A.8.18", - "27002: 8.2 (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(7)", - "AC-3", - "AC-3(4)", - "AC-3(11)", - "AC-3(13)", - "AC-3(14)", - "AC-6", - "AC-6(4)", - "AC-6(5)", - "AC-6(8)", - "AC-12", - "AC-12(3)", - "AC-17", - "AC-17(4)", - "IA-8", - "IA-8(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "7.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "7.2.2" - ] - } - ] - } - ], - "Checks": [ - "identity_tenancy_admin_permissions_limited", - "identity_tenancy_admin_users_no_api_keys", - "identity_iam_admins_cannot_update_tenancy_admins" - ] - }, - { - "Id": "IAM-12", - "Description": "Define, implement and evaluate processes, procedures and technical measures to ensure the logging infrastructure is read-only for all with write access, including privileged access roles, and that the ability to disable it is controlled through a procedure that ensures the segregation of duties and break glass procedures.", - "Name": "Safeguard Logs Integrity", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.3" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1", - "27018: 12.4.1", - "27001: A.12.4.2", - "27002: 12.4.2", - "27017: 12.4.2", - "27018: 12.4.2", - "27001: A.12.4.3", - "27002: 12.4.3", - "27017: 12.4.3", - "27018: 12.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15", - "27001: A.8.18", - "27002: 8.15 Protection of Logs" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-2", - "AC-2(11)", - "AC-2(12)", - "IA-8", - "IA-8(4)", - "SA-8", - "SA-8(22)", - "SC-34", - "SC-34(1)", - "SC-34(2)", - "SC-36", - "SI-4", - "SI-4(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4" - ] - } - ] - } - ], - "Checks": [ - "audit_log_retention_period_365_days" - ] - }, - { - "Id": "IAM-13", - "Description": "Define, implement and evaluate processes, procedures and technical measures that ensure users are identifiable through unique IDs or which can associate individuals to the usage of user IDs.", - "Name": "Uniquely Identifiable Users", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.1", - "27002: 9.2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.16" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-3", - "AC-3(14)", - "AC-24", - "AC-24(2)", - "AU-10", - "AU-10(1)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(12)", - "IA-4", - "IA-4(1)", - "SA-8", - "SA-8(22)", - "SC-23", - "SC-23(3)", - "SC-40(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1", - "8.2", - "8.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "8.2.1", - "8.2.2", - "8.2.4" - ] - } - ] - } - ], - "Checks": [ - "identity_user_mfa_enabled_console_access", - "identity_user_valid_email_address" - ] - }, - { - "Id": "IAM-14", - "Description": "Define, implement and evaluate processes, procedures and technical measures for authenticating access to systems, application and data assets, including multifactor authentication for at least privileged user and sensitive data access. Adopt digital certificates or alternatives which achieve an equivalent level of security for system identities.", - "Name": "Strong Authentication", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02", - "IAM-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "6.3", - "6.5", - "12.5", - "12.7" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3", - "SA1.4", - "SA1.8" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.1.2", - "27002: 9.1.2", - "27017: 9.1.2", - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27001: A.9.4.2", - "27002: 9.4.2", - "27017: 9.4.2", - "27018: 9.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.15", - "27001: A.5.17", - "27001: A.8.5", - "27001: A.8.24", - "27002: 8.5", - "27002: 8.24 other information (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-6", - "AC-6(5)", - "AC-7", - "AC-7(4)", - "AU-10", - "AU-10(2)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(8)", - "IA-2(12)", - "IA-3", - "IA-3(1)", - "IA-5", - "IA-5(2)", - "IA-5(7)", - "IA-5(9)", - "IA-5(10)", - "IA-5(12)", - "IA-5(14)-(16)", - "IA-8", - "IA-8(1)", - "IA-8(6)", - "SC-23", - "SC-23(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-6", - "PR.AC-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.1.2", - "8.1.3", - "8.1.6", - "8.2", - "8.3", - "8.3.2", - "12.3.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.1", - "8.3.1", - "8.3.2", - "8.4.1", - "8.4.2", - "8.4.3" - ] - } - ] - } - ], - "Checks": [ - "identity_user_mfa_enabled_console_access" - ] - }, - { - "Id": "IAM-15", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the secure management of passwords.", - "Name": "Passwords Management", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.4", - "27002: 9.2.4", - "27017: 9.2.4", - "27018: 9.2.4", - "27001: A.9.3.1", - "27002: 9.3.1", - "27017: 9.3.1", - "27018: 9.3.1", - "27001: A.9.4.3", - "27002: 9.4.3", - "27017: 9.4.3", - "27018: 9.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.17" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IA-4", - "IA-4(8)", - "IA-5", - "IA-5(1)", - "IA-5(8)", - "IA-5(18)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "8.2", - "8.2.1-6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.2", - "2.3.1", - "8.3.5", - "8.3.6", - "8.3.7", - "8.3.8", - "8.3.9", - "8.3.10", - "8.3.10.1", - "8.6.2" - ] - } - ] - } - ], - "Checks": [ - "identity_password_policy_minimum_length_14", - "identity_password_policy_expires_within_365_days", - "identity_password_policy_prevents_reuse" - ] - }, - { - "Id": "IAM-16", - "Description": "Define, implement and evaluate processes, procedures and technical measures to verify access to data and system functions is authorized.", - "Name": "Authorization Mechanisms", - "Attributes": [ - { - "Section": "Identity & Access Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.2", - "CC6.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IAM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "5.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SA1.3", - "SA1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.9.2.5", - "27002: 9.2.5", - "27017: 9.2.5", - "27018: 9.2.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.18" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-3", - "AC-3(5)", - "AC-4", - "AC-4(17)", - "AC-4(21)", - "AC-4(22)", - "AC-6", - "AC-6(8)", - "AC-6(9)", - "AC-12", - "AC-12(1)", - "AC-20", - "AC-20(1)", - "AU-10", - "AU-10(1)", - "AU-10(2)", - "IA-2", - "IA-2(1)", - "IA-2(2)", - "IA-2(12)", - "IA-3", - "IA-3(1)", - "IA-5(1)", - "IA-5(2)", - "IA-5(5)", - "IA-5(8)", - "IA-5(10)", - "IA-5(12)", - "IA-8", - "IA-8(1)", - "IA-8(2)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4", - "PR.AC-6", - "PR.AC-7", - "PR.PT-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-01", - "PR.AA-02", - "PR.AA-03", - "PR.AA-04", - "PR.AA-05", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.3", - "7.1.4" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "7.2.4", - "7.2.3", - "7.2.5.1" - ] - } - ] - } - ], - "Checks": [ - "identity_tenancy_admin_permissions_limited", - "identity_service_level_admins_exist", - "database_autonomous_database_access_restricted", - "analytics_instance_access_restricted", - "integration_instance_access_restricted" - ] - }, - { - "Id": "IPY-03", - "Description": "Implement cryptographically secure and standardized network protocols for the management, import and export of data.", - "Name": "Secure Interoperability and Portability Management", - "Attributes": [ - { - "Section": "Interoperability & Portability", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IPY-04" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY1.1", - "SY1.2", - "NC1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1", - "27001: A.15.1.1", - "27002: 15.1.1", - "27017: 15.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.19", - "27001: A.5.23", - "27001: A.5.31", - "27001: A.5.32", - "27001: A.5.33", - "27001: A.5.34" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PT-2", - "PT-2(2)", - "SA-4", - "SC-16", - "SC-16(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.2.1", - "1.2.5", - "1.2.6", - "2.2.4", - "2.2.5", - "2.2.7", - "4.2.1" - ] - } - ] - } - ], - "Checks": [ - "compute_instance_in_transit_encryption_enabled" - ] - }, - { - "Id": "IVS-02", - "Description": "Plan and monitor the availability, quality, and adequate capacity of resources in order to deliver the required system performance as determined by the business.", - "Name": "Capacity and Resource Planning", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "No", - "IaaS": "CSP-Owned", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "A1.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-04" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.3", - "27001: 6.1", - "27001: 9.1", - "27001: A.12.1.3", - "27002: 12.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.3 (b)", - "27001: 6.1", - "27001: 9.1", - "27001: A.8.6", - "27001: A.8.14" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CP-2", - "CP-2(2)", - "SC-5", - "SC-5(2)", - "SC-4", - "SI-4" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-4", - "ID.BE-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.IR-04", - "GV.OC-04" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "IVS-03", - "Description": "Monitor, encrypt and restrict communications between environments to only authenticated and authorized connections, as justified by the business. Review these configurations at least annually, and support them by a documented justification of all allowed services, protocols, ports, and compensating controls.", - "Name": "Network Security", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-06" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.8", - "3.1", - "12.2", - "13.6", - "13.9" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "NC1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.13.1.1", - "27002: 13.1.1", - "27001: A.13.1.2", - "27002: 13.1.2", - "27001: A.13.1.3", - "27002: 13.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.5.15", - "27001: A.5.37", - "27001: A.8.5", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.21", - "27001: A.8.22", - "27001: A.8.24", - "27002: A.5.15 2nd c)", - "27002: 8.20", - "27002: 8.21", - "27002: 8.22", - "27002: 8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-1", - "SC-4", - "SC-7", - "SC-7(4)", - "SC-7(5)", - "SC-7(8)", - "SC-7(9)", - "SC-7(11)", - "SC-8", - "SC-8(1)", - "SC-11", - "SC-12", - "SC-16", - "SC-23", - "SC-29", - "SC-29(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-5", - "PR.AC-7", - "PR.PT-4", - "DE.CM-1", - "DE.CM-7", - "PR.DS-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.IR-01", - "PR.AA-03", - "PR.AA-05", - "DE.CM-01", - "PR.DS-02", - "ID.AM-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "1.1.6", - "1.2", - "1.2.3", - "2.2", - "4.1.1", - "10.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.2.5", - "1.2.6", - "1.2.7", - "1.4.2", - "2.2.4", - "2.2.5", - "2.2.7", - "4.2.1", - "10.1.1" - ] - } - ] - } - ], - "Checks": [ - "network_vcn_subnet_flow_logs_enabled", - "network_default_security_list_restricts_traffic", - "network_security_group_ingress_from_internet_to_ssh_port", - "network_security_group_ingress_from_internet_to_rdp_port", - "network_security_list_ingress_from_internet_to_ssh_port", - "network_security_list_ingress_from_internet_to_rdp_port" - ] - }, - { - "Id": "IVS-04", - "Description": "Harden host and guest OS, hypervisor or infrastructure control plane according to their respective best practices, and supported by technical controls, as part of a security baseline.", - "Name": "OS Hardening and Base Controls", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "CSP-Owned", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.8", - "CC7.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-07", - "IVS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "4.1", - "4.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.1.3", - "5.2.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SY1.1", - "SY1.3", - "SY1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.14.2.2", - "27002: 14.2.2", - "27001: A.14.2.3", - "27001 A.14.2.4", - "27018: 12.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5", - "27001: 9.1", - "27001: A.5.37", - "27001: A.8.5", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.22", - "27001: A.8.24", - "27002: 8.20", - "27002: 8.22", - "27002: 8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-6", - "CM-6(1)", - "SC-29", - "SC-29(1)", - "SC-2", - "SC-7", - "SC-7(12)", - "SC-30", - "SC-34", - "SC-35", - "SC-39", - "SC-44" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.IP-1", - "PR.PT-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "2.2.1" - ] - } - ] - } - ], - "Checks": [ - "compute_instance_legacy_metadata_endpoint_disabled", - "compute_instance_secure_boot_enabled" - ] - }, - { - "Id": "IVS-06", - "Description": "Design, develop, deploy and configure applications and infrastructures such that CSP and CSC (tenant) user access and intra-tenant access is appropriately segmented and segregated, monitored and restricted from other tenants.", - "Name": "Segmentation and Segregation", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-09" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.3.4", - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SC2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 9.1", - "27001: A.13.1.3", - "27002: 13.1.3", - "27017: 13.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 9.1", - "27001: A.5.15", - "27001: A.5.20", - "27001: A.8.3", - "27001: A.8.9", - "27001: A.8.16", - "27001: A.8.22", - "27002: 5.15 (b)", - "27002: 8.3 (b)", - "27002: 8.16 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-3", - "SC-7", - "SC-7(20)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.AC-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.IR-01", - "PR.PS-01", - "PR.PS-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "2.6", - "8.3.1", - "10.8", - "11.3", - "A3.2.1", - "A3.3.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "A1.1.1", - "A1.1.2", - "A1.1.3" - ] - } - ] - } - ], - "Checks": [ - "network_default_security_list_restricts_traffic", - "identity_non_root_compartment_exists", - "identity_no_resources_in_root_compartment" - ] - }, - { - "Id": "IVS-07", - "Description": "Use secure and encrypted communication channels when migrating servers, services, applications, or data to cloud environments. Such channels must include only up-to-date and approved protocols.", - "Name": "Migration to Cloud Environments", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-10" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.4", - "IM1.4", - "NC1.4", - "SC2.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.13.1.1", - "27002: 13.1.1", - "27017: 13.1.1", - "27018: 13.1.1", - "27001: A.13.1.2", - "27002: 13.1.2", - "27017: 13.1.2", - "27018: 13.1.2", - "27001: A.13.1.3", - "27002: 13.1.3", - "27017: 13.1.3", - "27018: 13.1.3", - "27001: A.13.2.1", - "27002: 13.2.1", - "27017: 13.2.1", - "27018: 13.2.1", - "27001: A.13.2.2", - "27002: 13.2.2", - "27017: 13.2.2", - "27018: 13.2.2", - "27001: A.13.2.3", - "27002: 13.2.3", - "27017: 13.2.3", - "27018: 13.2.3", - "27001: A.13.2.4", - "27002: 13.2.4", - "27017: 13.2.4", - "27018: 13.2.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.14", - "27001: A.8.20", - "27001: A.8.24", - "27002: 8.20 (e)", - "27002: 8.24 Guidance (b,f), other information (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-17", - "AC-20", - "SC-7", - "SC-7(28)", - "SC-8", - "SC-8(1)", - "SC-12", - "SC-23", - "SC-29", - "SI-7", - "SI-7(1)-(3)", - "SI-7(5)-(10)", - "SI-7(12)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-2", - "PR.PT-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "4.2.1" - ] - } - ] - } - ], - "Checks": [ - "compute_instance_in_transit_encryption_enabled" - ] - }, - { - "Id": "IVS-09", - "Description": "Define, implement and evaluate processes, procedures and defense-in-depth techniques for protection, detection, and timely response to network-based attacks.", - "Name": "Network Defense", - "Attributes": [ - { - "Section": "Infrastructure & Virtualization Security", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.6", - "CC6.8", - "CC7.1", - "CC7.2", - "CC7.5" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-13" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "13.3", - "13.8" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.3", - "5.2.4", - "5.2.5", - "5.2.7", - "5.3.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "NC1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1", - "27001: 6.2", - "27001: A.14.1.2", - "27002: 14.1.2", - "27017: 14.1.2", - "27001: A.11.1.4", - "27002: 11.1.4", - "27017: 11.1.4", - "27018: 16.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1", - "27001: 6.2", - "27001: A.5.24", - "27001: A.5.26", - "27001: A.8.8", - "27001: A.8.16", - "27001: A.8.20", - "27001: A.8.21", - "27001: A.8.22", - "27001: A.8.26", - "27002: 8.8 (i)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PL-8", - "PL-8(1)", - "SC-5", - "SC-5(1)", - "SC-5(3)", - "SC-7", - "SC-7(13)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.DP-1", - "DE.CM-1", - "DE.CM-7", - "PR.AC-5", - "RS.MI-2", - "PR.DS-2", - "RS.RP-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-03", - "DE.CM-01", - "PR.IR-01", - "RS.MA-01", - "RS.MI-01", - "RS.MI-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.6", - "1.1", - "1.2", - "1.3", - "1.5", - "12.10.5" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "1.1.1", - "1.3.1", - "1.3.2", - "1.3.3", - "1.4.1", - "1.4.2", - "1.4.3", - "1.4.4", - "1.4.5", - "1.5.1", - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "cloudguard_enabled", - "events_rule_cloudguard_problems" - ] - }, - { - "Id": "LOG-02", - "Description": "Define, implement and evaluate processes, procedures and technical measures to ensure the security and retention of audit logs.", - "Name": "Audit Logs Protection", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.1", - "8.9", - "8.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "3.1.3", - "5.1.2", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.18.1.3", - "27002: 18.1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.28", - "27001: A.5.33", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-4", - "AU-11" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.IP-4", - "PR.IP-6", - "PR.PT-1", - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.DS-01", - "PR.DS-02", - "ID.AM-08", - "PR.DS-11", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5", - "10.7" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4", - "10.5.1" - ] - } - ] - } - ], - "Checks": [ - "audit_log_retention_period_365_days" - ] - }, - { - "Id": "LOG-03", - "Description": "Identify and monitor security-related events within applications and the underlying infrastructure. Define and implement a system to generate alerts to responsible stakeholders based on such events and corresponding metrics.", - "Name": "Security Monitoring and Alerting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.8", - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-03", - "SEF-05" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.5" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4", - "5.2.7", - "1.6.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2", - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.28", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-5", - "AU-5(2)", - "AU-13" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.AE-2", - "DE.AE-3", - "DE.AE-5", - "DE.CM-1", - "DE.CM-2", - "DE.CM-3", - "DE.CM-4", - "DE.CM-5", - "DE.CM-6", - "DE.CM-7", - "DE.DP-1", - "DE.DP-4", - "DE.AE-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.AE-02", - "DE.AE-03", - "DE.AE-04", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1", - "10.2.2", - "10.4.1.1", - "10.4.2.1", - "10.4.3" - ] - } - ] - } - ], - "Checks": [ - "cloudguard_enabled", - "events_rule_cloudguard_problems", - "events_notification_topic_and_subscription_exists", - "events_rule_local_user_authentication" - ] - }, - { - "Id": "LOG-04", - "Description": "Restrict audit logs access to authorized personnel and maintain records that provide unique access accountability.", - "Name": "Audit Logs Access and Accountability", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "IVS-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.14" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "3.1.1", - "4.1.2", - "4.1.3", - "4.2.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.2", - "27001: A.12.4.1", - "27002: 12.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.33", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(4)", - "AU-9(6)", - "AU-10" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-1", - "PR.AC-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.1", - "10.2.1", - "10.2.3", - "10.5.1", - "10.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1.3", - "10.3.1" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "LOG-05", - "Description": "Monitor security audit logs to detect activity outside of typical or expected patterns. Establish and follow a defined process to review and take appropriate and timely actions on detected anomalies.", - "Name": "Audit Logs Monitoring and Response", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.8", - "8.11" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.1", - "1.6.2", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.3", - "27002: 12.4.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15", - "27001: A.8.16" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-6", - "AU-6(1)", - "AU-6(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-3", - "PR.PT-1", - "RS.AN-1", - "RS.CO-1.", - "DE.AE-1", - "DE.AE-5", - "DE.DP-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.AM-03", - "PR.PS-04", - "DE.AE-02", - "DE.AE-03", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.6", - "10.6.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.4.1.1", - "10.4.2.1" - ] - } - ] - } - ], - "Checks": [ - "events_rule_iam_group_changes", - "events_rule_iam_policy_changes", - "events_rule_identity_provider_changes", - "events_rule_idp_group_mapping_changes", - "events_rule_local_user_authentication", - "events_rule_network_gateway_changes", - "events_rule_network_security_group_changes", - "events_rule_route_table_changes", - "events_rule_security_list_changes", - "events_rule_user_changes", - "events_rule_vcn_changes", - "events_rule_cloudguard_problems" - ] - }, - { - "Id": "LOG-07", - "Description": "Establish, document and implement which information meta/data system events should be logged. Review and update the scope at least annually or whenever there is a change in the threat environment.", - "Name": "Logging Scope", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 7.5.3", - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 7.5.3", - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-1", - "AU-14", - "AU-16" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.SC-3", - "ID.SC-4", - "PR.PT-1", - "ID.GV-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.1", - "10.2.2" - ] - } - ] - } - ], - "Checks": [ - "audit_log_retention_period_365_days", - "network_vcn_subnet_flow_logs_enabled", - "objectstorage_bucket_logging_enabled" - ] - }, - { - "Id": "LOG-08", - "Description": "Generate audit records containing relevant security information.", - "Name": "Log Records", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "8.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.1", - "27002: 12.4.1", - "27017: 12.4.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-3", - "AU-3(1)", - "AU-3(3)", - "AU-6", - "AU-6(8)", - "AU-12", - "AU-12(1)", - "AU-12(2)", - "AU-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.PT-1", - "DE.AE-3", - "DE.CM-1", - "DE.CM-2", - "DE.CM-3", - "DE.CM-6", - "DE.CM-7" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-01", - "DE.CM-02", - "DE.CM-03", - "DE.CM-06", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.3" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.2.2" - ] - } - ] - } - ], - "Checks": [ - "audit_log_retention_period_365_days", - "network_vcn_subnet_flow_logs_enabled", - "objectstorage_bucket_logging_enabled" - ] - }, - { - "Id": "LOG-09", - "Description": "The information system protects audit records from unauthorized access, modification, and deletion.", - "Name": "Log Protection", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "GRM-04", - "IVS-01" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.4", - "4.2.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.4.2", - "27002: 12.4.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.15" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(2)", - "AU-9(3)", - "AU-9(4)", - "AU-12(3)", - "AU-12(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.AC-4", - "PR.IP-4", - "PR.IP-6", - "PR.PT-1", - "PR.DS-1", - "PR.DS-6" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AA-05", - "PR.DS-01", - "PR.DS-02", - "PR.DS-11" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.5", - "10.5.1", - "10.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.3.1", - "10.3.2", - "10.3.3", - "10.3.4" - ] - } - ] - } - ], - "Checks": [ - "audit_log_retention_period_365_days" - ] - }, - { - "Id": "LOG-10", - "Description": "Establish and maintain a monitoring and internal reporting capability over the operations of cryptographic, encryption and key management policies, processes, procedures, and controls.", - "Name": "Encryption Monitoring and Reporting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-02", - "EKM-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "4.2.1", - "5.1.1", - "5.1.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1", - "27002: 10.1", - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-1", - "AU-9", - "AU-9(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "PR.PT-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.1.1", - "10.2.1", - "10.4.1" - ] - } - ] - } - ], - "Checks": [ - "kms_key_rotation_enabled" - ] - }, - { - "Id": "LOG-11", - "Description": "Log and monitor key lifecycle management events to enable auditing and reporting on usage of cryptographic keys.", - "Name": "Transaction/Activity Logging", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "EKM-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.1" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.10.1.2", - "27017: 10.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.24" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-9", - "AU-9(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.PT-1", - "DE.AE-3" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.CM-09" - ] - } - ] - } - ], - "Checks": [ - "audit_log_retention_period_365_days" - ] - }, - { - "Id": "LOG-13", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the reporting of anomalies and failures of the monitoring system and provide immediate notification to the accountable party.", - "Name": "Failures and Anomalies Reporting", - "Attributes": [ - { - "Section": "Logging and Monitoring", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.3", - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-03" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.1", - "5.2.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.1", - "27002: 16.1.1", - "27001: A.16.1.2", - "27017: 16.1.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.24", - "27001: A.6.8", - "27002: 6.8 (g)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AU-5", - "AU-5(2)", - "AU-6", - "AU-6(3)", - "AU-6(4)", - "AU-6(5)", - "AU-16" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-3", - "DE.DP-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-04", - "DE.AE-06" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "10.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "10.4.3", - "10.7.1", - "10.7.2", - "10.7.3" - ] - } - ] - } - ], - "Checks": [ - "cloudguard_enabled", - "events_rule_cloudguard_problems", - "events_notification_topic_and_subscription_exists" - ] - }, - { - "Id": "SEF-03", - "Description": "'Establish, document, approve, communicate, apply, evaluate and maintain a security incident response plan, which includes but is not limited to: relevant internal departments, impacted CSCs, and other business critical relationships (such as supply-chain) that may be impacted.'", - "Name": "Incident Response Plans", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2", - "CC7.3", - "CC7.4" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "BCR-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "17.2", - "17.4" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27017: CLD.12.1.5", - "27018: 16.1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: A.5.26", - "27002: 5.26 (e,f)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IR-1", - "IR-2", - "IR-2(1)-(3)", - "IR-3", - "IR-3(1)-(3)", - "IR-4", - "IR-4(1)-(15)", - "IR-5", - "IR-5(1)", - "IR-6", - "IR-6(1)-(3)", - "IR-7", - "IR-7(1)", - "IR-7(2)", - "IR-8", - "IR-8(1)", - "IR-9", - "IR-9(1)-(4)", - "PM-12" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "RS.CO-1", - "RS.CO-4", - "ID.AM-6", - "ID.GV-2", - "ID.SC-5", - "PR.IP-9", - "PR.IP10" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.AT-01", - "PR.AT-02", - "RS.MA-01", - "GV.SC-08", - "ID.IM-02", - "ID.IM-04", - "RC.RP-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.1", - "12.10.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1", - "12.10.5" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "SEF-06", - "Description": "Define, implement and evaluate processes, procedures and technical measures supporting business processes to triage security-related events.", - "Name": "Event Triage Processes", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-02" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.16.1.4", - "27002: 16.1.4", - "27017: 16.1.4", - "27018: 16.1.4", - "27001: A.16.1.5", - "27002: 16.1.5", - "27017: 16.1.5", - "27018: 16.1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.25" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CA-7", - "CA-7(3)", - "CA-7(4)", - "CA-7(5)", - "CA-7(6)", - "IR-4", - "IR-4(1)", - "IR-4(3)", - "IR-4(4)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.AE-1", - "DE.AE-2", - "DE.AE-4", - "RS.RP-1", - "RS.AN-2" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "RS.MA-02", - "RS.MA-03", - "RS.AN-03", - "DE.AE-02", - "DE.AE-04", - "DE.AE-06", - "DE.AE-07", - "DE.AE-08", - "RS.MI-02", - "RC.RP-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "12.5.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1" - ] - } - ] - } - ], - "Checks": [ - "cloudguard_enabled" - ] - }, - { - "Id": "SEF-08", - "Description": "Maintain points of contact for applicable regulation authorities, national and local law enforcement, and other legal jurisdictional authorities.", - "Name": "Points of Contact Maintenance", - "Attributes": [ - { - "Section": "Security Incident Management, E-Discovery, & Cloud Forensics", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC2.3" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "SEF-01" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "17.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.6.2", - "1.6.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "SM2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 4.2", - "27001: A.6.1.3", - "27002: 6.1.3", - "27017: 6.1.3", - "27018: 6.1.3", - "27001: A.16.1.1", - "27002: 16.1.1", - "27001: A.18.1.1", - "27002: 18.1.1", - "27017: 18.1.1", - "27018: 18.1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.5", - "27001: A.5.24", - "27002: 5.24 Incident management procedure (d)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "IR-4", - "IR-4(8)", - "IR-6", - "IR-6(3)", - "IR-7", - "IR-7(2)", - "PM-21", - "PM-23", - "PM-26" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-2", - "RS.CO-3", - "RS.CO-4" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.RR-02", - "RS.CO-02", - "RS.CO-03" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.10.1" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "TVM-02", - "Description": "Establish, document, approve, communicate, apply, evaluate and maintain policies and procedures to protect against malware on managed assets. Review and update the policies and procedures at least annually.", - "Name": "Malware Protection Policy and Procedures", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC6.8" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-01", - "GRM-06", - "GRM-09" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "9.7", - "10.1" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "1.1.1", - "1.5.1", - "5.2.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS1.2", - "TS1.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5", - "27002: 5", - "27001: A.12.2.1", - "27001: A.6.2.1", - "27002: 6.2.1 (h)", - "27001: A.6.2.2", - "27002: 6.2.2 (j)", - "27001: A.7.2.2", - "27002: 7.2.2 (d)", - "27001: A.10.1.1", - "27002: 10.1.1 (g)", - "27001: A.13.2.1", - "27002: 13.2.1 (b)", - "27001: A.15.1.2", - "27017: 15.1.2", - "27001: A.12.2.1", - "27002: 12.2.1 (a),(d)", - "27017: CLD.9.5.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 5.1", - "27001: 5.2", - "27001: 7.3", - "27001: 7.4", - "27001: 7.5", - "27001: 9.1", - "27001: 9.3", - "27001: A.5.1", - "27001: A.5.4", - "27001: A.5.7", - "27001: A.5.37", - "27001: A.8.7", - "27002: 5.7 (b)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-3", - "RA-3(3)", - "RA-5", - "RA-5(3)", - "RA-5(5)", - "SI-3", - "SI-3(4)", - "SI-3(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.GV-1", - "DE.CM-4", - "DE.CM-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "GV.PO-01", - "GV.PO-02", - "ID.IM-03", - "DE.CM-01", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.4", - "12.1", - "12.1.1", - "12.3.1", - "12.5.1", - "12.11" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "12.1.1", - "12.1.2", - "5.1.1", - "5.3.2.1" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "TVM-03", - "Description": "Define, implement and evaluate processes, procedures and technical measures to enable both scheduled and emergency responses to vulnerability identifications, based on the identified risk.", - "Name": "Vulnerability Remediation Schedule", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC5.3", - "CC7.1", - "CC7.4" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "7.2", - "7.7", - "17.9" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.5" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1", - "TM2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.12.2.1", - "27001: A.12.6.1", - "27002: 12.6.1(c)(d)(j)", - "27018: 12.6.1(k)(i)" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.8.7", - "27001: A.8.8", - "27001: A.8.32", - "27002: 8.7", - "27002: 8.8", - "27002: 8.32" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "PM-31", - "RA-3", - "RA-3(1)", - "RA-5", - "RA-5(2)-(4)", - "RA-5(6)", - "SI-3", - "SI-3(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "RS.AN-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01", - "ID.RA-06", - "ID.RA-08", - "PR.PS-02", - "PR.PS-03" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "6.1.a", - "6.1.b" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.1.1", - "6.3.1", - "6.3.2", - "6.3.3", - "12.10.1" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "TVM-04", - "Description": "Define, implement and evaluate processes, procedures and technical measures to update detection tools, threat signatures, and indicators of compromise on a weekly, or more frequent basis.", - "Name": "Detection Updates", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "No mapping" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "10.2" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.3" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TS1.3", - "TS1.4", - "TM1.3", - "TM1.4", - "IM1.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.5.1.1", - "27002: 5.1.1 (h)", - "27001: A.12.6.1", - "27002: 12.6.1 (b),(c)" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.5.1", - "27001: A.8.8", - "27001: A.8.15", - "27001: A.8.16", - "27002: 5.1", - "27002: 5.37", - "27002: 8.8", - "27002: 8.15 (d)", - "27002: 8.16 (d,e)", - "27002: 8.31 2nd (a)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "CM-7", - "CM-7(4)", - "RA-3", - "RA-3(3)", - "RA-5(2)", - "SA-10", - "SA-10(5)", - "SA-11", - "SA-11(2)", - "SI-2", - "SI-2(4)", - "SI-3", - "SI-3(4)", - "SI-4", - "SI-4(9)", - "SI-4(24)", - "SI-8", - "SI-8(2)", - "SI-8(3)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.PS-02", - "ID.RA-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "5.2", - "5.2a", - "5.2b", - "5.2c" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "5.3.1" - ] - } - ] - } - ], - "Checks": [ - "cloudguard_enabled" - ] - }, - { - "Id": "TVM-05", - "Description": "Define, implement and evaluate processes, procedures and technical measures to identify updates for applications which use third party or open source libraries according to the organization's vulnerability management policy.", - "Name": "External Library Vulnerabilities", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "CSP-Owned", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC3.2" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "No mapping" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1", - "SD2.3" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: 6.1.3", - "27001: A.12.6.2", - "27002: 12.6.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: 6.1.3", - "27001: A 5.6", - "27001: A.8.19", - "27001: A.8.8", - "27001: A.8.28", - "27001: A.8.31", - "27002: 5.6 (c)", - "27001: 8.19", - "27001: 8.8", - "27001: 8.28", - "27001: 8.31" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-5", - "RA-5(3)", - "SA-11", - "SA-11(2)", - "SA-11(5)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "DE.DP-5", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01", - "ID.RA-03", - "PR.PS-02" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "6.2", - "6.3.2" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "6.3.2", - "6.3.3" - ] - } - ] - } - ], - "Checks": [] - }, - { - "Id": "TVM-07", - "Description": "Define, implement and evaluate processes, procedures and technical measures for the detection of vulnerabilities on organizationally managed assets at least monthly.", - "Name": "Vulnerability Identification", - "Attributes": [ - { - "Section": "Threat & Vulnerability Management", - "CCMLite": "Yes", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC7.1" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "TVM-02" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "7.1", - "7.5", - "7.6" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.5", - "5.2.6" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "TM1.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.6", - "27001: A.12.6.1", - "27002: 12.6.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.8", - "27002: 8.8" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "RA-5", - "RA-5(4)", - "RA-5(5)", - "SA-11", - "SA-11(5)", - "SA-15(5)", - "SC-7", - "SC-7(10)", - "SI-3(8)", - "SI-3(10)", - "SI-7", - "SI-7(9)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "ID.RA-1", - "DE.CM-8", - "PR.IP-12" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "ID.RA-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "6.1", - "11.2", - "11.2.1" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "6.3.1", - "6.3.2", - "6.3.3", - "11.3.2", - "11.3.2.1" - ] - } - ] - } - ], - "Checks": [ - "cloudguard_enabled" - ] - }, - { - "Id": "UEM-08", - "Description": "Protect information from unauthorized disclosure on managed endpoint devices with storage encryption.", - "Name": "Storage Encryption", - "Attributes": [ - { - "Section": "Universal Endpoint Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.1", - "CC6.7" - ] - }, - { - "ReferenceId": "CCM v3.0.1", - "Identifiers": [ - "MOS-11" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.6" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.1.2", - "3.1.4" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "PA1.2", - "PA1.3", - "PA1.5", - "PA2.2", - "PM1.4" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.11.2.7", - "27002: 11.2.7", - "27001: A.18.1.1", - "27017: 18.1.1", - "27001: A.12.3.1", - "27017: 12.3.1", - "27018: A.11.4", - "27018: A.11.5" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.8.1", - "27002: 8.1 (h)" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "AC-19(5)", - "SC-28", - "SC-28(1)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-1" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-01" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "3.4", - "3.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "3.5.1", - "3.6" - ] - } - ] - } - ], - "Checks": [ - "blockstorage_block_volume_encrypted_with_cmk", - "blockstorage_boot_volume_encrypted_with_cmk", - "filestorage_file_system_encrypted_with_cmk" - ] - }, - { - "Id": "UEM-11", - "Description": "Configure managed endpoints with Data Loss Prevention (DLP) technologies and rules in accordance with a risk assessment.", - "Name": "Data Loss Prevention", - "Attributes": [ - { - "Section": "Universal Endpoint Management", - "CCMLite": "No", - "IaaS": "Shared", - "PaaS": "Shared", - "SaaS": "Shared", - "ScopeApplicability": [ - { - "ReferenceId": "AICPA TSC 2017", - "Identifiers": [ - "CC6.7" - ] - }, - { - "ReferenceId": "CIS v8.0", - "Identifiers": [ - "3.13" - ] - }, - { - "ReferenceId": "ENX ISA v6.0", - "Identifiers": [ - "5.2.7" - ] - }, - { - "ReferenceId": "ISF SOGP 2022", - "Identifiers": [ - "IM1.5", - "PA2.2" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2013,27002:2013,27017:2015,27018:2019", - "Identifiers": [ - "27001: A.12.3", - "27002: 12.3", - "27001: A.8.3.1", - "27002: 8.3.1", - "27001: A.12.2", - "27002: 12.2", - "27001: A.18.1.3", - "27002: 18.1.3", - "27001: A.6.1.1", - "27017: 6.1.1", - "27018: 12.3.1", - "27018: 10.1" - ] - }, - { - "ReferenceId": "ISO/IEC 27001:2022, 27002:2022", - "Identifiers": [ - "27001: A.5.12", - "27001: A.8.3" - ] - }, - { - "ReferenceId": "NIST 800-53 rev 5", - "Identifiers": [ - "SC-7", - "SC-7(10)" - ] - }, - { - "ReferenceId": "NIST CSF v1.1", - "Identifiers": [ - "PR.DS-5" - ] - }, - { - "ReferenceId": "NIST CSF v2.0", - "Identifiers": [ - "PR.DS-02", - "PR.DS-10", - "PR.PS-01", - "ID.AM-08", - "DE.CM-09" - ] - }, - { - "ReferenceId": "PCI DSS v3.2.1", - "Identifiers": [ - "A3.2.6" - ] - }, - { - "ReferenceId": "PCI DSS v4.0", - "Identifiers": [ - "A3.2.6" - ] - } - ] - } - ], - "Checks": [] - } - ] -} diff --git a/prowler/config/config.py b/prowler/config/config.py index 4c76d98c1c..9e2b079da6 100644 --- a/prowler/config/config.py +++ b/prowler/config/config.py @@ -1,3 +1,4 @@ +import importlib.metadata import os import pathlib from datetime import datetime, timezone @@ -48,7 +49,7 @@ class _MutableTimestamp: timestamp = _MutableTimestamp(datetime.today()) timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc)) -prowler_version = "5.30.0" +prowler_version = "5.31.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" @@ -85,13 +86,38 @@ class Provider(str, Enum): actual_directory = pathlib.Path(os.path.dirname(os.path.realpath(__file__))) +def _get_ep_compliance_dirs() -> dict: + """Discover compliance directories from entry points. Returns {provider: path}.""" + dirs = {} + for ep in importlib.metadata.entry_points(group="prowler.compliance"): + try: + module = ep.load() + if hasattr(module, "__path__"): + dirs[ep.name] = module.__path__[0] + elif hasattr(module, "__file__"): + dirs[ep.name] = os.path.dirname(module.__file__) + except Exception as error: + logger.warning( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return dirs + + def get_available_compliance_frameworks(provider=None): available_compliance_frameworks = [] - providers = [p.value for p in Provider] + # Built-in compliance + compliance_base = f"{actual_directory}/../compliance" if provider: providers = [provider] - for current_provider in providers: - compliance_dir = f"{actual_directory}/../compliance/{current_provider}" + else: + # Scan compliance directory for all provider subdirectories + providers = [] + if os.path.isdir(compliance_base): + for entry in os.scandir(compliance_base): + if entry.is_dir(): + providers.append(entry.name) + for prov in providers: + compliance_dir = f"{compliance_base}/{prov}" if not os.path.isdir(compliance_dir): continue with os.scandir(compliance_dir) as files: @@ -100,7 +126,8 @@ def get_available_compliance_frameworks(provider=None): available_compliance_frameworks.append( file.name.removesuffix(".json") ) - # Also scan top-level compliance/ for multi-provider (universal) JSONs. + # Built-in multi-provider frameworks at top-level compliance/ directory. + # Placed before external entry points so built-ins win on name collisions. # When a specific provider was requested, only include the framework if it # declares support for that provider; otherwise include all universal frameworks. compliance_root = f"{actual_directory}/../compliance" @@ -117,6 +144,43 @@ def get_available_compliance_frameworks(provider=None): continue if name not in available_compliance_frameworks: available_compliance_frameworks.append(name) + # External per-provider compliance via entry points. + ep_dirs = _get_ep_compliance_dirs() + for prov, path in ep_dirs.items(): + if provider and prov != provider: + continue + if os.path.isdir(path): + for file in os.scandir(path): + if file.is_file() and file.name.endswith(".json"): + name = file.name.removesuffix(".json") + if name not in available_compliance_frameworks: + 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 file in os.scandir(path): + if file.is_file() and file.name.endswith(".json"): + name = file.name.removesuffix(".json") + if provider: + framework = load_compliance_framework_universal(file.path) + if framework is None or not framework.supports_provider(provider): + continue + if name not in available_compliance_frameworks: + available_compliance_frameworks.append(name) return available_compliance_frameworks @@ -228,18 +292,26 @@ def load_and_validate_config_file(provider: str, config_file_path: str) -> dict: with open(config_file_path, "r", encoding=encoding_format_utf_8) as f: config_file = yaml.safe_load(f) - # Not to introduce a breaking change, allow the old format config file without any provider keys - # and a new format with a key for each provider to include their configuration values within. - if any( - key in config_file - for key in ["aws", "gcp", "azure", "kubernetes", "m365"] + # Namespaced format: each provider has its own top-level key. + # Works for every built-in and every external plugin without a hardcoded list. + # Flat legacy format is AWS-only (historical, pre-multicloud). We identify it + # by the absence of nested-dict top-level values (namespaced files always + # have dict values; the legacy AWS format only has primitives/lists). + if ( + isinstance(config_file, dict) + and provider in config_file + and isinstance(config_file[provider], dict) ): - config = config_file.get(provider, {}) + config = config_file.get(provider, {}) or {} + elif ( + isinstance(config_file, dict) + and config_file + and provider == "aws" + and not any(isinstance(v, dict) for v in config_file.values()) + ): + config = config_file else: - config = config_file if config_file else {} - # Not to break Azure, K8s and GCP does not support or use the old config format - if provider in ["azure", "gcp", "kubernetes", "m365"]: - config = {} + config = {} return config diff --git a/prowler/lib/banner.py b/prowler/lib/banner.py index 72031805e2..8115983bc6 100644 --- a/prowler/lib/banner.py +++ b/prowler/lib/banner.py @@ -3,12 +3,13 @@ from colorama import Fore, Style from prowler.config.config import banner_color, orange_color, prowler_version, timestamp -def print_banner(legend: bool = False): +def print_banner(legend: bool = False, provider: str = None): """ Prints the banner with optional legend for color codes. Parameters: - legend (bool): Flag to indicate whether to print the color legend or not. Default is False. + - provider (str): The provider being scanned, used to tailor the Prowler Cloud banner. Returns: - None @@ -20,20 +21,50 @@ def print_banner(legend: bool = False): | .__/|_| \___/ \_/\_/ |_|\___|_|v{prowler_version} |_|{Fore.BLUE} Get the most at https://cloud.prowler.com {Style.RESET_ALL} -{Fore.GREEN}New! Send findings from Prowler CLI to Prowler Cloud{Style.RESET_ALL} -{Fore.GREEN}More details here: goto.prowler.com/import-findings{Style.RESET_ALL} - {Fore.YELLOW}Date: {timestamp.strftime("%Y-%m-%d %H:%M:%S")}{Style.RESET_ALL} """ print(banner) + print_prowler_cloud_banner(provider) + if legend: - print( - f""" + print(f""" {Style.BRIGHT}Color code for results:{Style.RESET_ALL} - {Fore.YELLOW}MANUAL (Manual check){Style.RESET_ALL} - {Fore.GREEN}PASS (Recommended value){Style.RESET_ALL} - {orange_color}MUTED (Muted by muted list){Style.RESET_ALL} - {Fore.RED}FAIL (Fix required){Style.RESET_ALL} - """ - ) + """) + + +def print_prowler_cloud_banner(provider: str = None): + """ + Prints a promotional banner highlighting what Prowler Cloud adds on top of + the open-source CLI. + + Shown at the start and end of a scan to let users know about the managed + platform capabilities they are missing (attack paths, AI, organizations, + continuous scanning, integrations and live compliance dashboards). + + Parameters: + - provider (str): The provider that was scanned, used to tailor the message. + + Returns: + - None + """ + check = f"{Fore.GREEN}✓{Style.RESET_ALL}" + bar = f"{banner_color}│{Style.RESET_ALL}" + print(f""" +{bar} {Style.BRIGHT}You're getting a snapshot 📸. Prowler Cloud gives you the full picture:{Style.RESET_ALL} +{bar} +{bar} {check} {Style.BRIGHT}Continuous Security Monitoring{Style.RESET_ALL} - scheduled scans with history, trends and alerts. +{bar} {check} {Style.BRIGHT}Lighthouse AI + MCP{Style.RESET_ALL} - autonomous triage, custom dashboards, prioritization with prevention and remediation. +{bar} {check} {Style.BRIGHT}Alerts{Style.RESET_ALL} - get notified when anything you want is happening. +{bar} {check} {Style.BRIGHT}Live Compliance{Style.RESET_ALL} - dashboards for 50+ frameworks, always up to date. +{bar} {check} {Style.BRIGHT}Remediation{Style.RESET_ALL} - complete guided remediation including Autonomous remediation with Lighthouse AI. +{bar} {check} {Style.BRIGHT}Attack Path Visualization{Style.RESET_ALL} - see how attackers chain risks to reach your crown jewels. +{bar} {check} {Style.BRIGHT}Bulk Provisioning{Style.RESET_ALL} - add your entire AWS Organization in seconds. +{bar} {check} {Style.BRIGHT}Integrations{Style.RESET_ALL} - Anything with our MCP + Jira, Slack, AWS Security Hub, Amazon S3, SSO and RBAC. +{bar} +{bar} {Fore.BLUE}Start free at 👉 cloud.prowler.com{Style.RESET_ALL} +""") diff --git a/prowler/lib/check/check.py b/prowler/lib/check/check.py index 53b832290f..300520f589 100644 --- a/prowler/lib/check/check.py +++ b/prowler/lib/check/check.py @@ -1,4 +1,6 @@ import importlib +import importlib.metadata +import importlib.util import json import os import re @@ -19,6 +21,7 @@ from prowler.lib.check.utils import recover_checks_from_provider from prowler.lib.logger import logger from prowler.lib.outputs.outputs import report from prowler.lib.utils.utils import open_file, parse_json_file, print_boxes +from prowler.providers.common.builtin import is_builtin_provider from prowler.providers.common.models import Audit_Metadata @@ -385,6 +388,45 @@ def import_check(check_path: str) -> ModuleType: return lib +def _resolve_check_module( + provider_type: str, service: str, check_name: str +) -> ModuleType: + """Resolve and import a check module. + + Built-in wins on CheckID collision. Plug-ins are first-class extenders + (they can add new checks under new CheckIDs) but cannot override + existing built-ins — a security tool prefers fail-loud predictability + over silent overrides. CheckMetadata.get_bulk() applies the same + precedence on the metadata side (first-write-wins) and emits a warning + when a plug-in tries to override, so the user knows their plug-in + duplicate is being ignored and can rename it. + + Gates the built-in branch on `is_builtin_provider(provider_type)` — + calling `find_spec` on `prowler.providers.{provider_type}.services...` + directly would propagate `ModuleNotFoundError` for external providers + (their parent package `prowler.providers.{provider_type}` does not + exist) instead of returning None. The leaf helper encapsulates the + safe lookup, so external providers go straight to entry points. For + built-ins we still use `find_spec` to distinguish "check doesn't + exist" from "check exists but failed to import" (broken transitive + dep, etc.). + """ + # Built-in first — built-in wins on CheckID collision + if is_builtin_provider(provider_type): + builtin_path = f"prowler.providers.{provider_type}.services.{service}.{check_name}.{check_name}" + if importlib.util.find_spec(builtin_path) is not None: + return import_check(builtin_path) + + # Entry point lookup — only consulted when the built-in truly doesn't exist + for ep in importlib.metadata.entry_points(group=f"prowler.checks.{provider_type}"): + if ep.name == check_name: + return importlib.import_module(ep.value) + + raise ModuleNotFoundError( + f"Check '{check_name}' not found for provider '{provider_type}'" + ) + + def run_fixer(check_findings: list) -> int: """ Run the fixer for the check if it exists and there are any FAIL findings @@ -525,9 +567,10 @@ def execute_checks( service = check_name.split("_")[0] try: try: - # Import check module - check_module_path = f"prowler.providers.{global_provider.type}.services.{service}.{check_name}.{check_name}" - lib = import_check(check_module_path) + # Import check module (built-in or entry point) + lib = _resolve_check_module( + global_provider.type, service, check_name + ) # Recover functions from check check_to_execute = getattr(lib, check_name) check = check_to_execute() @@ -605,9 +648,10 @@ def execute_checks( ) try: try: - # Import check module - check_module_path = f"prowler.providers.{global_provider.type}.services.{service}.{check_name}.{check_name}" - lib = import_check(check_module_path) + # Import check module (built-in or entry point) + lib = _resolve_check_module( + global_provider.type, service, check_name + ) # Recover functions from check check_to_execute = getattr(lib, check_name) check = check_to_execute() @@ -753,6 +797,10 @@ def execute( is_finding_muted_args["org_domain"] = ( global_provider.identity.org_domain ) + elif not is_builtin_provider(global_provider.type): + # External/custom provider — delegate identity args + is_finding_muted_args = global_provider.get_mutelist_finding_args() + for finding in check_findings: if global_provider.type == "cloudflare": is_finding_muted_args["account_id"] = finding.account_id diff --git a/prowler/lib/check/checks_loader.py b/prowler/lib/check/checks_loader.py index 9ef672df6b..77840084ff 100644 --- a/prowler/lib/check/checks_loader.py +++ b/prowler/lib/check/checks_loader.py @@ -2,10 +2,10 @@ import sys from colorama import Fore, Style -from prowler.config.config import EXTERNAL_TOOL_PROVIDERS from prowler.lib.check.check import parse_checks_from_file from prowler.lib.check.compliance_models import Compliance from prowler.lib.check.models import CheckMetadata, Severity +from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider from prowler.lib.logger import logger @@ -26,8 +26,13 @@ def load_checks_to_execute( ) -> set: """Generate the list of checks to execute based on the cloud provider and the input arguments given""" try: - # Bypass check loading for providers that use external tools directly - if provider in EXTERNAL_TOOL_PROVIDERS: + # Bypass check loading for tool-wrapper providers — they delegate + # scanning to an external tool and have no checks to recover. + # Single source of truth across __main__, the CheckMetadata validators, + # check discovery and this loader, covering both built-in tool wrappers + # (iac/llm/image) and external plug-ins that declare + # `is_external_tool_provider = True` via the contract. + if is_tool_wrapper_provider(provider): return set() # Local subsets diff --git a/prowler/lib/check/compliance_models.py b/prowler/lib/check/compliance_models.py index 82ac54e4d9..f883bf60b1 100644 --- a/prowler/lib/check/compliance_models.py +++ b/prowler/lib/check/compliance_models.py @@ -1,3 +1,4 @@ +import importlib.metadata import json import os import sys @@ -282,6 +283,26 @@ class CSA_CCM_Requirement_Attribute(BaseModel): ScopeApplicability: list[dict] +class STIG_Requirement_Attribute_Severity(str, Enum): + """DISA STIG Requirement Attribute Severity (maps to CAT I/II/III)""" + + high = "high" + medium = "medium" + low = "low" + + +class STIG_Requirement_Attribute(BaseModel): + """DISA STIG Requirement Attribute""" + + Section: str + Severity: STIG_Requirement_Attribute_Severity + RuleID: str + StigID: str + CCI: Optional[list[str]] = None + CheckText: Optional[str] = None + FixText: Optional[str] = None + + # Base Compliance Model # TODO: move this to compliance folder class Compliance_Requirement(BaseModel): @@ -302,6 +323,7 @@ class Compliance_Requirement(BaseModel): CCC_Requirement_Attribute, C5Germany_Requirement_Attribute, CSA_CCM_Requirement_Attribute, + STIG_Requirement_Attribute, # Generic_Compliance_Requirement_Attribute must be the last one since it is the fallback for generic compliance framework Generic_Compliance_Requirement_Attribute, ] @@ -434,26 +456,63 @@ class Compliance(BaseModel): """Bulk load all compliance frameworks specification into a dict""" try: bulk_compliance_frameworks = {} + # Built-in compliance from prowler/compliance/{provider}/ available_compliance_framework_modules = list_compliance_modules() for compliance_framework in available_compliance_framework_modules: - if provider in compliance_framework.name: + # Match the provider segment exactly, not as a substring, so + # e.g. `cloud` does not capture `cloudflare`. + if compliance_framework.name.split(".")[-1] == provider: compliance_specification_dir_path = ( f"{compliance_framework.module_finder.path}/{provider}" ) - # for compliance_framework in available_compliance_framework_modules: for filename in os.listdir(compliance_specification_dir_path): file_path = os.path.join( compliance_specification_dir_path, filename ) - # Check if it is a file and ti size is greater than 0 if os.path.isfile(file_path) and os.stat(file_path).st_size > 0: - # Open Compliance file in JSON - # cis_v1.4_aws.json --> cis_v1.4_aws compliance_framework_name = filename.split(".json")[0] - # Store the compliance info bulk_compliance_frameworks[compliance_framework_name] = ( load_compliance_framework(file_path) ) + + # External compliance via entry points + for ep in importlib.metadata.entry_points(group="prowler.compliance"): + if ep.name == provider: + try: + module = ep.load() + compliance_dir = ( + module.__path__[0] + if hasattr(module, "__path__") + else os.path.dirname(module.__file__) + ) + for filename in os.listdir(compliance_dir): + if filename.endswith(".json"): + file_path = os.path.join(compliance_dir, filename) + if ( + os.path.isfile(file_path) + and os.stat(file_path).st_size > 0 + ): + compliance_framework_name = filename.split(".json")[ + 0 + ] + if ( + compliance_framework_name + not in bulk_compliance_frameworks + ): + # External JSON: tolerate non-legacy + # schemas (skip + warn) instead of aborting. + framework = load_compliance_framework( + file_path, fatal=False + ) + if framework is not None: + bulk_compliance_frameworks[ + compliance_framework_name + ] = framework + except Exception as error: + logger.warning( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as e: logger.error(f"{e.__class__.__name__}[{e.__traceback__.tb_lineno}] -- {e}") @@ -462,18 +521,26 @@ class Compliance(BaseModel): # Testing Pending def load_compliance_framework( - compliance_specification_file: str, -) -> Compliance: - """load_compliance_framework loads and parse a Compliance Framework Specification""" + compliance_specification_file: str, fatal: bool = True +) -> Optional[Compliance]: + """load_compliance_framework loads and parse a Compliance Framework Specification. + + With ``fatal=True`` (built-in JSONs) an invalid file aborts the run; with + ``fatal=False`` (external JSONs) it is skipped with a warning and ``None`` + is returned. + """ try: - compliance_framework = Compliance.parse_file(compliance_specification_file) + return Compliance.parse_file(compliance_specification_file) except ValidationError as error: - logger.critical( - f"Compliance Framework Specification from {compliance_specification_file} is not valid: {error}" + if fatal: + logger.critical( + f"Compliance Framework Specification from {compliance_specification_file} is not valid: {error}" + ) + sys.exit(1) + logger.warning( + f"Skipping invalid compliance framework {compliance_specification_file}: {error}" ) - sys.exit(1) - else: - return compliance_framework + return None # ─── Universal Compliance Schema Models (Phase 1-3) ───────────────────────── @@ -950,6 +1017,25 @@ 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"): + 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) + except Exception as error: + logger.warning( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as e: logger.error(f"{e.__class__.__name__}[{e.__traceback__.tb_lineno}] -- {e}") return bulk diff --git a/prowler/lib/check/models.py b/prowler/lib/check/models.py index d97be78cbe..f9155c68f7 100644 --- a/prowler/lib/check/models.py +++ b/prowler/lib/check/models.py @@ -11,10 +11,10 @@ from typing import Any, Dict, Optional, Set from pydantic.v1 import BaseModel, Field, ValidationError, validator from pydantic.v1.error_wrappers import ErrorWrapper -from prowler.config.config import EXTERNAL_TOOL_PROVIDERS, Provider from prowler.lib.check.compliance_models import Compliance from prowler.lib.check.utils import recover_checks_from_provider from prowler.lib.logger import logger +from prowler.providers.common.provider import Provider as ProviderABC # Valid ResourceGroup values as defined in the RFC VALID_RESOURCE_GROUPS = frozenset( @@ -259,7 +259,7 @@ class CheckMetadata(BaseModel): ) if ( value_lower not in VALID_CATEGORIES - and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS + and not ProviderABC.is_tool_wrapper_provider(values.get("Provider")) ): raise ValueError( f"Invalid category: '{value_lower}'. Must be one of: {', '.join(sorted(VALID_CATEGORIES))}." @@ -288,7 +288,9 @@ class CheckMetadata(BaseModel): raise ValueError("ServiceName must be a non-empty string") check_id = values.get("CheckID") - if check_id and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS: + if check_id and not ProviderABC.is_tool_wrapper_provider( + values.get("Provider") + ): service_from_check_id = check_id.split("_")[0] if service_name != service_from_check_id: raise ValueError( @@ -304,7 +306,9 @@ class CheckMetadata(BaseModel): if not check_id: raise ValueError("CheckID must be a non-empty string") - if check_id and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS: + if check_id and not ProviderABC.is_tool_wrapper_provider( + values.get("Provider") + ): if "-" in check_id: raise ValueError( f"CheckID {check_id} contains a hyphen, which is not allowed" @@ -313,8 +317,9 @@ class CheckMetadata(BaseModel): return check_id @validator("CheckTitle", pre=True, always=True) + @classmethod def validate_check_title(cls, check_title, values): # noqa: F841 - if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS: + if not ProviderABC.is_tool_wrapper_provider(values.get("Provider")): if len(check_title) > 150: raise ValueError( f"CheckTitle must not exceed 150 characters, got {len(check_title)} characters" @@ -326,14 +331,18 @@ class CheckMetadata(BaseModel): return check_title @validator("RelatedUrl", pre=True, always=True) + @classmethod def validate_related_url(cls, related_url, values): # noqa: F841 - if related_url and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS: + if related_url and not ProviderABC.is_tool_wrapper_provider( + values.get("Provider") + ): raise ValueError("RelatedUrl must be empty. This field is deprecated.") return related_url @validator("Remediation") + @classmethod def validate_recommendation_url(cls, remediation, values): # noqa: F841 - if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS: + if not ProviderABC.is_tool_wrapper_provider(values.get("Provider")): url = remediation.Recommendation.Url if url and not url.startswith("https://hub.prowler.com/"): raise ValueError( @@ -346,7 +355,7 @@ class CheckMetadata(BaseModel): provider = values.get("Provider", "").lower() # Non-AWS providers must have an empty CheckType list - if provider != "aws" and provider not in EXTERNAL_TOOL_PROVIDERS: + if provider != "aws" and not ProviderABC.is_tool_wrapper_provider(provider): if check_type: raise ValueError( f"CheckType must be empty for non-AWS providers. Got {check_type} for provider '{provider}'." @@ -371,8 +380,9 @@ class CheckMetadata(BaseModel): return check_type @validator("Description", pre=True, always=True) + @classmethod def validate_description(cls, description, values): # noqa: F841 - if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS: + if not ProviderABC.is_tool_wrapper_provider(values.get("Provider")): if len(description) > 400: raise ValueError( f"Description must not exceed 400 characters, got {len(description)} characters" @@ -380,8 +390,9 @@ class CheckMetadata(BaseModel): return description @validator("Risk", pre=True, always=True) + @classmethod def validate_risk(cls, risk, values): # noqa: F841 - if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS: + if not ProviderABC.is_tool_wrapper_provider(values.get("Provider")): if len(risk) > 400: raise ValueError( f"Risk must not exceed 400 characters, got {len(risk)} characters" @@ -433,6 +444,20 @@ class CheckMetadata(BaseModel): metadata_file = f"{check_path}/{check_name}.metadata.json" # Load metadata check_metadata = load_check_metadata(metadata_file) + # Built-in wins on CheckID collision. Plug-in entry points are + # appended after built-ins by `recover_checks_from_provider`, so + # a duplicate CheckID here means an entry-point check is trying + # to override a built-in. Ignore the override (the built-in + # metadata stays) and surface it via a warning — matching the + # precedence enforced by `_resolve_check_module`. + if check_metadata.CheckID in bulk_check_metadata: + logger.warning( + f"Plug-in check metadata '{check_metadata.CheckID}' " + f"(loaded from '{metadata_file}') is being IGNORED — " + f"a built-in with the same CheckID exists. To use your " + f"plug-in, register it under a different CheckID." + ) + continue bulk_check_metadata[check_metadata.CheckID] = check_metadata return bulk_check_metadata @@ -470,7 +495,7 @@ class CheckMetadata(BaseModel): # If the bulk checks metadata is not provided, get it if not bulk_checks_metadata: bulk_checks_metadata = {} - available_providers = [p.value for p in Provider] + available_providers = ProviderABC.get_available_providers() for provider_name in available_providers: bulk_checks_metadata.update(CheckMetadata.get_bulk(provider_name)) if provider: @@ -495,7 +520,7 @@ class CheckMetadata(BaseModel): # Loaded here, as it is not always needed if not bulk_compliance_frameworks: bulk_compliance_frameworks = {} - available_providers = [p.value for p in Provider] + available_providers = ProviderABC.get_available_providers() for provider in available_providers: bulk_compliance_frameworks = Compliance.get_bulk(provider=provider) checks_from_compliance_framework = ( diff --git a/prowler/lib/check/tool_wrapper.py b/prowler/lib/check/tool_wrapper.py new file mode 100644 index 0000000000..a1d606594e --- /dev/null +++ b/prowler/lib/check/tool_wrapper.py @@ -0,0 +1,62 @@ +"""Standalone helper for tool-wrapper provider detection. + +A provider is a "tool wrapper" if it delegates scanning to an external tool +(Trivy, promptfoo, etc.) instead of running checks/services through the +standard Prowler engine. This module is the single source of truth for that +classification across the codebase. + +Kept as a leaf module with no Prowler imports beyond the leaf +`external_tool_providers` so it can be referenced from `prowler.lib.check.*` +and `prowler.providers.common.provider` without forming an import cycle. +""" + +import importlib.metadata + +from prowler.lib.check.external_tool_providers import EXTERNAL_TOOL_PROVIDERS +from prowler.providers.common.builtin import is_builtin_provider + +# Module-level cache for entry-point classes consulted by this helper. +# Independent of `Provider._ep_providers` to keep this module leaf — the cost +# of a duplicate cache entry is negligible (one class object per external +# provider, loaded lazily on first lookup). +_ep_class_cache: dict = {} + + +def _load_ep_class(provider: str): + """Return the entry-point provider class for `provider`, or None. + + Caches the result in `_ep_class_cache`. Errors during entry-point loading + are swallowed (returning None) so a broken plug-in never crashes the + is-tool-wrapper check; it just falls through to "not a tool wrapper". + """ + if provider in _ep_class_cache: + return _ep_class_cache[provider] + for ep in importlib.metadata.entry_points(group="prowler.providers"): + if ep.name == provider: + try: + cls = ep.load() + except Exception: + cls = None + _ep_class_cache[provider] = cls + return cls + _ep_class_cache[provider] = None + return None + + +def is_tool_wrapper_provider(provider: str) -> bool: + """Return True if the provider delegates scanning to an external tool. + + Combines the built-in `EXTERNAL_TOOL_PROVIDERS` frozenset (fast path for + iac/llm/image) with the `is_external_tool_provider` class attribute of + external plug-ins registered via entry points. This is the single source + of truth consulted by `__main__`, the `CheckMetadata` validators, the + check-loading utilities, and the checks loader. + """ + if provider in EXTERNAL_TOOL_PROVIDERS: + return True + # Built-in wins: short-circuit before ep.load() so a same-name plug-in + # cannot flip a built-in onto the tool-wrapper path or run its code. + if is_builtin_provider(provider): + return False + cls = _load_ep_class(provider) + return bool(cls and getattr(cls, "is_external_tool_provider", False)) diff --git a/prowler/lib/check/utils.py b/prowler/lib/check/utils.py index 0e4807078f..9c9a9c0523 100644 --- a/prowler/lib/check/utils.py +++ b/prowler/lib/check/utils.py @@ -1,9 +1,43 @@ import importlib +import importlib.metadata +import importlib.util +import os import sys from pkgutil import walk_packages -from prowler.lib.check.external_tool_providers import EXTERNAL_TOOL_PROVIDERS +from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider from prowler.lib.logger import logger +from prowler.providers.common.builtin import is_builtin_provider + + +def _recover_ep_checks(provider: str, service: str = None) -> list[tuple]: + """Discover external checks registered via entry points for a provider. + + External plugins follow the same layout as built-ins: + `{plugin_root}.services.{service}.{check}.{check}` + + When `service` is provided, only entry points whose dotted path contains + `.services.{service}.` are included — mirroring how built-in discovery + filters by the `prowler.providers.{provider}.services.{service}` package. + + Uses find_spec to locate the check module without importing it, + avoiding service client initialization at discovery time. + """ + checks = [] + for ep in importlib.metadata.entry_points(group=f"prowler.checks.{provider}"): + try: + if service and f".services.{service}." not in ep.value: + continue + + spec = importlib.util.find_spec(ep.value) + if spec and spec.origin: + check_path = os.path.dirname(spec.origin) + checks.append((ep.name, check_path)) + except Exception as error: + logger.warning( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return checks def recover_checks_from_provider( @@ -15,29 +49,55 @@ def recover_checks_from_provider( Returns a list of tuples with the following format (check_name, check_path) """ try: - # Bypass check loading for providers that use external tools directly - if provider in EXTERNAL_TOOL_PROVIDERS: + # Bypass check loading for tool-wrapper providers — they delegate + # scanning to an external tool and have no checks to recover. + # Single source of truth: combines the EXTERNAL_TOOL_PROVIDERS + # frozenset (built-ins) with the per-provider `is_external_tool_provider` + # class attribute (so external plug-ins opt in via the contract). + if is_tool_wrapper_provider(provider): return [] checks = [] - modules = list_modules(provider, service) - for module_name in modules: - # Format: "prowler.providers.{provider}.services.{service}.{check_name}.{check_name}" - check_module_name = module_name.name - # We need to exclude common shared libraries in services - if ( - check_module_name.count(".") == 6 - and ".lib." not in check_module_name - and (not check_module_name.endswith("_fixer") or include_fixers) - ): - check_path = module_name.module_finder.path - # Check name is the last part of the check_module_name - check_name = check_module_name.split(".")[-1] - check_info = (check_name, check_path) - checks.append(check_info) - except ModuleNotFoundError: - logger.critical(f"Service {service} was not found for the {provider} provider.") - sys.exit(1) + # Built-in checks from prowler.providers.{provider}.services. Gate + # the built-in branch on `is_builtin_provider(provider)` — calling + # `find_spec` directly on `prowler.providers.{provider}.services` + # would propagate `ModuleNotFoundError` when the parent package + # `prowler.providers.{provider}` does not exist (i.e. the provider + # is external), instead of returning None. The leaf helper + # encapsulates the safe lookup, so we only run the built-in + # discovery when the provider actually ships with the SDK; for + # external providers we go straight to entry points. + if is_builtin_provider(provider): + modules = list_modules(provider, service) + for module_name in modules: + # Format: "prowler.providers.{provider}.services.{service}.{check_name}.{check_name}" + check_module_name = module_name.name + # We need to exclude common shared libraries in services + if ( + check_module_name.count(".") == 6 + and ".lib." not in check_module_name + and (not check_module_name.endswith("_fixer") or include_fixers) + ): + check_path = module_name.module_finder.path + check_name = check_module_name.split(".")[-1] + check_info = (check_name, check_path) + checks.append(check_info) + + # External checks registered via entry points — always consulted, with + # optional service filter. Previously gated by `if not service:`, which + # prevented external providers from being usable with --service. + checks.extend(_recover_ep_checks(provider, service)) + + # A service was requested but nothing matched in either built-ins or + # entry points — surface this as a clear error instead of silently + # returning an empty list. + if service and not checks: + logger.critical( + f"Service '{service}' was not found for the '{provider}' provider " + f"(neither as a built-in nor via external entry points)." + ) + sys.exit(1) + except Exception as e: logger.critical(f"{e.__class__.__name__}[{e.__traceback__.tb_lineno}]: {e}") sys.exit(1) @@ -64,8 +124,9 @@ def recover_checks_from_service(service_list: list, provider: str) -> set: Returns a set of checks from the given services """ try: - # Bypass check loading for providers that use external tools directly - if provider in EXTERNAL_TOOL_PROVIDERS: + # Bypass check loading for tool-wrapper providers — symmetric with + # `recover_checks_from_provider` above, using the same source of truth. + if is_tool_wrapper_provider(provider): return set() checks = set() diff --git a/prowler/lib/cli/parser.py b/prowler/lib/cli/parser.py index 449c15a22c..2848e9c788 100644 --- a/prowler/lib/cli/parser.py +++ b/prowler/lib/cli/parser.py @@ -15,24 +15,68 @@ from prowler.lib.check.models import Severity from prowler.lib.cli.redact import warn_sensitive_argument_values from prowler.lib.outputs.common import Status from prowler.providers.common.arguments import ( + PROVIDER_ALIASES, + enforce_invoked_provider_loaded, init_providers_parser, validate_asff_usage, validate_provider_arguments, validate_sarif_usage, ) +from prowler.providers.common.provider import Provider class ProwlerArgumentParser: # Set the default parser def __init__(self): + # Discover any providers not in the hardcoded list below + # TODO - First step to support current providers and the new external provider implementation + known_providers = { + "aws", + "azure", + "gcp", + "kubernetes", + "m365", + "github", + "googleworkspace", + "cloudflare", + "oraclecloud", + "openstack", + "alibabacloud", + "iac", + "llm", + "image", + "nhn", + "mongodbatlas", + "vercel", + "okta", + "scaleway", + "stackit", + } + all_providers = set(Provider.get_available_providers()) + new_providers = sorted(all_providers - known_providers) + + # Build extra strings for dynamically discovered providers + extra_providers_csv = "" + extra_providers_text = "" + if new_providers: + providers_help = Provider.get_providers_help_text() + extra_providers_csv = "," + ",".join(new_providers) + extra_lines = [] + for name in new_providers: + help_text = providers_help.get(name, "") + if help_text: + extra_lines.append(f" {name:<20}{help_text}") + if extra_lines: + extra_providers_text = "\n" + "\n".join(extra_lines) + # CLI Arguments self.parser = argparse.ArgumentParser( prog="prowler", formatter_class=RawTextHelpFormatter, - usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,dashboard,iac,image,llm} ...", - epilog=""" + usage=f"prowler [-h] [--version] {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,dashboard,iac,image,llm{extra_providers_csv}}} ...", + epilog=f""" Available Cloud Providers: - {aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel} + {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel{extra_providers_csv}}} aws AWS Provider azure Azure Provider gcp GCP Provider @@ -52,13 +96,14 @@ Available Cloud Providers: nhn NHN Provider (Unofficial) mongodbatlas MongoDB Atlas Provider scaleway Scaleway Provider - vercel Vercel Provider + vercel Vercel Provider{extra_providers_text} + Available components: dashboard Local dashboard To see the different available options on a specific component, run: - prowler {provider|dashboard} -h|--help + prowler {{provider|dashboard}} -h|--help Detailed documentation at https://docs.prowler.com """, @@ -117,17 +162,19 @@ Detailed documentation at https://docs.prowler.com and (sys.argv[1] not in ("-v", "--version")) ): # Since the provider is always the second argument, we are checking if - # a flag, starting by "-", is supplied - if "-" in sys.argv[1]: + # a flag is supplied. Use startswith("-") instead of "in" to avoid + # matching external provider names that contain hyphens + # (e.g. "local-acme-snowflake"). + if sys.argv[1].startswith("-"): sys.argv = self.__set_default_provider__(sys.argv) - # Provider aliases mapping - # Microsoft 365 - elif sys.argv[1] == "microsoft365": - sys.argv[1] = "m365" - # Oracle Cloud Infrastructure - elif sys.argv[1] == "oci": - sys.argv[1] = "oraclecloud" + # Provider aliases mapping (single source: arguments.PROVIDER_ALIASES) + elif sys.argv[1] in PROVIDER_ALIASES: + sys.argv[1] = PROVIDER_ALIASES[sys.argv[1]] + + # Selective fail-loud here (post argv-normalisation, pre parse_args) + # so the invoked-provider check stays correct under parse(args=...). + enforce_invoked_provider_loaded(self) # Warn about sensitive flags passed with explicit values # Snapshot argv before parse_args() which may exit on errors diff --git a/prowler/lib/outputs/compliance/asd_essential_eight/asd_essential_eight.py b/prowler/lib/outputs/compliance/asd_essential_eight/asd_essential_eight.py index 75481fb6aa..df23aeb1d1 100644 --- a/prowler/lib/outputs/compliance/asd_essential_eight/asd_essential_eight.py +++ b/prowler/lib/outputs/compliance/asd_essential_eight/asd_essential_eight.py @@ -22,11 +22,14 @@ def get_asd_essential_eight_table( pass_count = [] fail_count = [] muted_count = [] + section_seen = {} + provider = "" for index, finding in enumerate(findings): check = bulk_checks_metadata[finding.check_metadata.CheckID] check_compliances = check.Compliance for compliance in check_compliances: if compliance.Framework == "ASD-Essential-Eight": + provider = compliance.Provider for requirement in compliance.Requirements: for attribute in requirement.Attributes: section = attribute.Section @@ -36,21 +39,33 @@ def get_asd_essential_eight_table( "PASS": 0, "Muted": 0, } + section_seen[section] = set() + + # Overview totals: count each finding once per framework if finding.muted: if index not in muted_count: muted_count.append(index) - sections[section]["Muted"] += 1 - else: - if finding.status == "FAIL" and index not in fail_count: + elif finding.status == "FAIL": + if index not in fail_count: fail_count.append(index) - sections[section]["FAIL"] += 1 - elif finding.status == "PASS" and index not in pass_count: + elif finding.status == "PASS": + if index not in pass_count: pass_count.append(index) + + # Per-section counts: count each finding once per section + # it belongs to (a finding can map to several sections). + if index not in section_seen[section]: + section_seen[section].add(index) + if finding.muted: + sections[section]["Muted"] += 1 + elif finding.status == "FAIL": + sections[section]["FAIL"] += 1 + elif finding.status == "PASS": sections[section]["PASS"] += 1 sections = dict(sorted(sections.items())) for section in sections: - asd_essential_eight_compliance_table["Provider"].append(compliance.Provider) + asd_essential_eight_compliance_table["Provider"].append(provider) asd_essential_eight_compliance_table["Section"].append(section) if sections[section]["FAIL"] > 0: asd_essential_eight_compliance_table["Status"].append( diff --git a/prowler/lib/outputs/compliance/c5/c5.py b/prowler/lib/outputs/compliance/c5/c5.py index 32eb7e0f5a..b48260b5a2 100644 --- a/prowler/lib/outputs/compliance/c5/c5.py +++ b/prowler/lib/outputs/compliance/c5/c5.py @@ -22,33 +22,47 @@ def get_c5_table( fail_count = [] muted_count = [] sections = {} + section_seen = {} + provider = "" for index, finding in enumerate(findings): check = bulk_checks_metadata[finding.check_metadata.CheckID] check_compliances = check.Compliance for compliance in check_compliances: if compliance.Framework == "C5": + provider = compliance.Provider for requirement in compliance.Requirements: for attribute in requirement.Attributes: section = attribute.Section if section not in sections: sections[section] = {"FAIL": 0, "PASS": 0, "Muted": 0} + section_seen[section] = set() + # Overview totals: count each finding once per framework if finding.muted: if index not in muted_count: muted_count.append(index) - sections[section]["Muted"] += 1 - else: - if finding.status == "FAIL" and index not in fail_count: + elif finding.status == "FAIL": + if index not in fail_count: fail_count.append(index) - sections[section]["FAIL"] += 1 - elif finding.status == "PASS" and index not in pass_count: + elif finding.status == "PASS": + if index not in pass_count: pass_count.append(index) + + # Per-section counts: count each finding once per section + # it belongs to (a finding can map to several sections). + if index not in section_seen[section]: + section_seen[section].add(index) + if finding.muted: + sections[section]["Muted"] += 1 + elif finding.status == "FAIL": + sections[section]["FAIL"] += 1 + elif finding.status == "PASS": sections[section]["PASS"] += 1 sections = dict(sorted(sections.items())) for section in sections: - section_table["Provider"].append(compliance.Provider) + section_table["Provider"].append(provider) section_table["Section"].append(section) if sections[section]["FAIL"] > 0: section_table["Status"].append( diff --git a/prowler/lib/outputs/compliance/ccc/ccc.py b/prowler/lib/outputs/compliance/ccc/ccc.py index 99a6c91cd9..d8d76ad2d9 100644 --- a/prowler/lib/outputs/compliance/ccc/ccc.py +++ b/prowler/lib/outputs/compliance/ccc/ccc.py @@ -22,33 +22,47 @@ def get_ccc_table( fail_count = [] muted_count = [] sections = {} + section_seen = {} + provider = "" for index, finding in enumerate(findings): check = bulk_checks_metadata[finding.check_metadata.CheckID] check_compliances = check.Compliance for compliance in check_compliances: if compliance.Framework == "CCC": + provider = compliance.Provider for requirement in compliance.Requirements: for attribute in requirement.Attributes: section = attribute.Section if section not in sections: sections[section] = {"FAIL": 0, "PASS": 0, "Muted": 0} + section_seen[section] = set() + # Overview totals: count each finding once per framework if finding.muted: if index not in muted_count: muted_count.append(index) - sections[section]["Muted"] += 1 - else: - if finding.status == "FAIL" and index not in fail_count: + elif finding.status == "FAIL": + if index not in fail_count: fail_count.append(index) - sections[section]["FAIL"] += 1 - elif finding.status == "PASS" and index not in pass_count: + elif finding.status == "PASS": + if index not in pass_count: pass_count.append(index) + + # Per-section counts: count each finding once per section + # it belongs to (a finding can map to several sections). + if index not in section_seen[section]: + section_seen[section].add(index) + if finding.muted: + sections[section]["Muted"] += 1 + elif finding.status == "FAIL": + sections[section]["FAIL"] += 1 + elif finding.status == "PASS": sections[section]["PASS"] += 1 sections = dict(sorted(sections.items())) for section in sections: - section_table["Provider"].append(compliance.Provider) + section_table["Provider"].append(provider) section_table["Section"].append(section) if sections[section]["FAIL"] > 0: section_table["Status"].append( diff --git a/prowler/lib/outputs/compliance/cis/cis.py b/prowler/lib/outputs/compliance/cis/cis.py index 7f161f34c2..4acbd7fe58 100644 --- a/prowler/lib/outputs/compliance/cis/cis.py +++ b/prowler/lib/outputs/compliance/cis/cis.py @@ -13,6 +13,9 @@ def get_cis_table( compliance_overview: bool, ): sections = {} + section_muted_seen = {} + section_split_seen = {} + provider = "" cis_compliance_table = { "Provider": [], "Section": [], @@ -29,6 +32,7 @@ def get_cis_table( for compliance in check_compliances: version_in_name = compliance_framework.split("_")[1] if compliance.Framework == "CIS" and version_in_name in compliance.Version: + provider = compliance.Provider for requirement in compliance.Requirements: for attribute in requirement.Attributes: section = attribute.Section @@ -40,9 +44,19 @@ def get_cis_table( "Level 2": {"FAIL": 0, "PASS": 0}, "Muted": 0, } + section_muted_seen[section] = set() + section_split_seen[section] = { + "Level 1": set(), + "Level 2": set(), + } if finding.muted: + # Overview total: count each finding once per framework if index not in muted_count: muted_count.append(index) + # Per-section Muted: count each finding once per section + # it belongs to (a finding can map to several sections). + if index not in section_muted_seen[section]: + section_muted_seen[section].add(index) sections[section]["Muted"] += 1 else: if finding.status == "FAIL" and index not in fail_count: @@ -50,13 +64,21 @@ def get_cis_table( elif finding.status == "PASS" and index not in pass_count: pass_count.append(index) if "Level 1" in attribute.Profile: - if not finding.muted: + if ( + not finding.muted + and index not in section_split_seen[section]["Level 1"] + ): + section_split_seen[section]["Level 1"].add(index) if finding.status == "FAIL": sections[section]["Level 1"]["FAIL"] += 1 else: sections[section]["Level 1"]["PASS"] += 1 elif "Level 2" in attribute.Profile: - if not finding.muted: + if ( + not finding.muted + and index not in section_split_seen[section]["Level 2"] + ): + section_split_seen[section]["Level 2"].add(index) if finding.status == "FAIL": sections[section]["Level 2"]["FAIL"] += 1 else: @@ -65,7 +87,7 @@ def get_cis_table( # Add results to table sections = dict(sorted(sections.items())) for section in sections: - cis_compliance_table["Provider"].append(compliance.Provider) + cis_compliance_table["Provider"].append(provider) cis_compliance_table["Section"].append(section) if sections[section]["Level 1"]["FAIL"] > 0: cis_compliance_table["Level 1"].append( diff --git a/prowler/lib/outputs/compliance/compliance.py b/prowler/lib/outputs/compliance/compliance.py index b6b2f1f81f..4e4bd78232 100644 --- a/prowler/lib/outputs/compliance/compliance.py +++ b/prowler/lib/outputs/compliance/compliance.py @@ -10,7 +10,6 @@ from prowler.lib.outputs.compliance.cis.cis import get_cis_table from prowler.lib.outputs.compliance.compliance_check import ( # noqa: F401 - re-export for backward compatibility get_check_compliance, ) -from prowler.lib.outputs.compliance.csa.csa import get_csa_table from prowler.lib.outputs.compliance.ens.ens import get_ens_table from prowler.lib.outputs.compliance.generic.generic_table import ( get_generic_compliance_table, @@ -19,6 +18,9 @@ from prowler.lib.outputs.compliance.kisa_ismsp.kisa_ismsp import get_kisa_ismsp_ from prowler.lib.outputs.compliance.mitre_attack.mitre_attack import ( get_mitre_attack_table, ) +from prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig import ( + get_okta_idaas_stig_table, +) from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore import ( get_prowler_threatscore_table, ) @@ -33,24 +35,28 @@ def process_universal_compliance_frameworks( output_filename: str, provider: str, generated_outputs: dict, + from_cli: bool = True, + is_last: bool = True, ) -> set: """Process universal compliance frameworks, generating CSV and OCSF outputs. For each framework in *input_compliance_frameworks* that exists in - *universal_frameworks* and has an outputs.table_config, this function - creates both a CSV (UniversalComplianceOutput) and an OCSF JSON - (OCSFComplianceOutput) file. OCSF is always generated regardless of + *universal_frameworks* and has an ``outputs.table_config``, this function + writes both a CSV (``UniversalComplianceOutput``) and an OCSF JSON + (``OCSFComplianceOutput``) file. OCSF is always generated regardless of the user's ``--output-formats`` flag. - The function is idempotent: it tracks already-created writers via - ``generated_outputs["compliance"]`` keyed by ``file_path``. If invoked - again for the same framework (e.g. once per streaming batch), it - reuses the existing writer instead of recreating it. This guarantees - one output writer per framework for the whole execution and keeps - the OCSF JSON array valid across multiple calls. + Streaming-aware: writers are tracked via ``generated_outputs["compliance"]`` + keyed by ``file_path``. On the first call per framework a new writer is + created and emits both findings and manual requirements; subsequent calls + reuse the writer, transform only the new ``finding_outputs`` (manual + requirements are not re-emitted), and append to the open file. Set + ``from_cli=False`` and ``is_last=False`` for intermediate batches; pass + ``is_last=True`` on the final batch to close the file (OCSF is also + finalized as a valid JSON array). - Returns the set of framework names that were processed so the caller - can remove them before entering the legacy per-provider output loop. + Returns the set of framework names processed so the caller can subtract + them from the legacy per-provider output loop. """ from prowler.lib.outputs.compliance.universal.ocsf_compliance import ( OCSFComplianceOutput, @@ -65,6 +71,13 @@ def process_universal_compliance_frameworks( if isinstance(out, (UniversalComplianceOutput, OCSFComplianceOutput)) } + def _flush(writer, framework, label, is_new): + if not is_new: + writer._transform(finding_outputs, framework, label, include_manual=False) + writer.close_file = is_last + writer.batch_write_data_to_file() + writer._data.clear() + processed = set() for compliance_name in input_compliance_frameworks: if not ( @@ -75,37 +88,46 @@ def process_universal_compliance_frameworks( continue fw = universal_frameworks[compliance_name] + compliance_label = ( + fw.framework + "-" + fw.version if fw.version else fw.framework + ) # CSV output csv_path = ( f"{output_directory}/compliance/" f"{output_filename}_{compliance_name}.csv" ) - if csv_path not in existing_writers: - output = UniversalComplianceOutput( + csv_writer = existing_writers.get(csv_path) + csv_is_new = csv_writer is None + if csv_is_new: + csv_writer = UniversalComplianceOutput( findings=finding_outputs, framework=fw, file_path=csv_path, + from_cli=from_cli, provider=provider, ) - generated_outputs["compliance"].append(output) - existing_writers[csv_path] = output - output.batch_write_data_to_file() + generated_outputs["compliance"].append(csv_writer) + existing_writers[csv_path] = csv_writer + _flush(csv_writer, fw, compliance_label, csv_is_new) # OCSF output (always generated for universal frameworks) ocsf_path = ( f"{output_directory}/compliance/" f"{output_filename}_{compliance_name}.ocsf.json" ) - if ocsf_path not in existing_writers: - ocsf_output = OCSFComplianceOutput( + ocsf_writer = existing_writers.get(ocsf_path) + ocsf_is_new = ocsf_writer is None + if ocsf_is_new: + ocsf_writer = OCSFComplianceOutput( findings=finding_outputs, framework=fw, file_path=ocsf_path, + from_cli=from_cli, provider=provider, ) - generated_outputs["compliance"].append(ocsf_output) - existing_writers[ocsf_path] = ocsf_output - ocsf_output.batch_write_data_to_file() + generated_outputs["compliance"].append(ocsf_writer) + existing_writers[ocsf_path] = ocsf_writer + _flush(ocsf_writer, fw, compliance_label, ocsf_is_new) processed.add(compliance_name) @@ -206,15 +228,6 @@ def display_compliance_table( output_directory, compliance_overview, ) - elif compliance_framework.startswith("csa_ccm_"): - get_csa_table( - findings, - bulk_checks_metadata, - compliance_framework, - output_filename, - output_directory, - compliance_overview, - ) elif compliance_framework.startswith("c5_"): get_c5_table( findings, @@ -242,8 +255,8 @@ def display_compliance_table( output_directory, compliance_overview, ) - else: - get_generic_compliance_table( + elif compliance_framework.startswith("okta_idaas_stig"): + get_okta_idaas_stig_table( findings, bulk_checks_metadata, compliance_framework, @@ -251,6 +264,33 @@ def display_compliance_table( output_directory, compliance_overview, ) + else: + # Try provider-specific table first, fall back to generic + from prowler.providers.common.provider import Provider + + provider = Provider.get_global_provider() + handled = False + if provider is not None: + try: + handled = provider.display_compliance_table( + findings, + bulk_checks_metadata, + compliance_framework, + output_filename, + output_directory, + compliance_overview, + ) + except NotImplementedError: + handled = False + if not handled: + get_generic_compliance_table( + findings, + bulk_checks_metadata, + compliance_framework, + output_filename, + output_directory, + compliance_overview, + ) except Exception as error: logger.critical( f"{error.__class__.__name__}:{error.__traceback__.tb_lineno} -- {error}" diff --git a/prowler/lib/outputs/compliance/csa/csa_alibabacloud.py b/prowler/lib/outputs/compliance/csa/csa_alibabacloud.py deleted file mode 100644 index e0867ab5f1..0000000000 --- a/prowler/lib/outputs/compliance/csa/csa_alibabacloud.py +++ /dev/null @@ -1,95 +0,0 @@ -from prowler.config.config import timestamp -from prowler.lib.check.compliance_models import Compliance -from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput -from prowler.lib.outputs.compliance.csa.models import AlibabaCloudCSAModel -from prowler.lib.outputs.finding import Finding - - -class AlibabaCloudCSA(ComplianceOutput): - """ - This class represents the Alibaba Cloud CSA compliance output. - - Attributes: - - _data (list): A list to store transformed data from findings. - - _file_descriptor (TextIOWrapper): A file descriptor to write data to a file. - - Methods: - - transform: Transforms findings into Alibaba Cloud CSA compliance format. - """ - - def transform( - self, - findings: list[Finding], - compliance: Compliance, - compliance_name: str, - ) -> None: - """ - Transforms a list of findings into Alibaba Cloud CSA compliance format. - - Parameters: - - findings (list): A list of findings. - - compliance (Compliance): A compliance model. - - compliance_name (str): The name of the compliance model. - - Returns: - - None - """ - for finding in findings: - for requirement in compliance.Requirements: - # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). - if finding.check_id in requirement.Checks: - for attribute in requirement.Attributes: - compliance_row = AlibabaCloudCSAModel( - Provider=finding.provider, - Description=compliance.Description, - AccountId=finding.account_uid, - Region=finding.region, - AssessmentDate=str(timestamp), - Requirements_Id=requirement.Id, - Requirements_Description=requirement.Description, - Requirements_Name=requirement.Name, - Requirements_Attributes_Section=attribute.Section, - Requirements_Attributes_CCMLite=attribute.CCMLite, - Requirements_Attributes_IaaS=attribute.IaaS, - Requirements_Attributes_PaaS=attribute.PaaS, - Requirements_Attributes_SaaS=attribute.SaaS, - Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability, - Status=finding.status, - StatusExtended=finding.status_extended, - ResourceId=finding.resource_uid, - ResourceName=finding.resource_name, - CheckId=finding.check_id, - Muted=finding.muted, - Framework=compliance.Framework, - Name=compliance.Name, - ) - self._data.append(compliance_row) - # Add manual requirements to the compliance output - for requirement in compliance.Requirements: - if not requirement.Checks: - for attribute in requirement.Attributes: - compliance_row = AlibabaCloudCSAModel( - Provider=compliance.Provider.lower(), - Description=compliance.Description, - AccountId="", - Region="", - AssessmentDate=str(timestamp), - Requirements_Id=requirement.Id, - Requirements_Description=requirement.Description, - Requirements_Name=requirement.Name, - Requirements_Attributes_Section=attribute.Section, - Requirements_Attributes_CCMLite=attribute.CCMLite, - Requirements_Attributes_IaaS=attribute.IaaS, - Requirements_Attributes_PaaS=attribute.PaaS, - Requirements_Attributes_SaaS=attribute.SaaS, - Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability, - Status="MANUAL", - StatusExtended="Manual check", - ResourceId="manual_check", - ResourceName="Manual check", - CheckId="manual", - Muted=False, - Framework=compliance.Framework, - Name=compliance.Name, - ) - self._data.append(compliance_row) diff --git a/prowler/lib/outputs/compliance/csa/csa_aws.py b/prowler/lib/outputs/compliance/csa/csa_aws.py deleted file mode 100644 index 6309dbe287..0000000000 --- a/prowler/lib/outputs/compliance/csa/csa_aws.py +++ /dev/null @@ -1,95 +0,0 @@ -from prowler.config.config import timestamp -from prowler.lib.check.compliance_models import Compliance -from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput -from prowler.lib.outputs.compliance.csa.models import AWSCSAModel -from prowler.lib.outputs.finding import Finding - - -class AWSCSA(ComplianceOutput): - """ - This class represents the AWS CSA compliance output. - - Attributes: - - _data (list): A list to store transformed data from findings. - - _file_descriptor (TextIOWrapper): A file descriptor to write data to a file. - - Methods: - - transform: Transforms findings into AWS CSA compliance format. - """ - - def transform( - self, - findings: list[Finding], - compliance: Compliance, - compliance_name: str, - ) -> None: - """ - Transforms a list of findings into AWS CSA compliance format. - - Parameters: - - findings (list): A list of findings. - - compliance (Compliance): A compliance model. - - compliance_name (str): The name of the compliance model. - - Returns: - - None - """ - for finding in findings: - for requirement in compliance.Requirements: - # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). - if finding.check_id in requirement.Checks: - for attribute in requirement.Attributes: - compliance_row = AWSCSAModel( - Provider=finding.provider, - Description=compliance.Description, - AccountId=finding.account_uid, - Region=finding.region, - AssessmentDate=str(timestamp), - Requirements_Id=requirement.Id, - Requirements_Description=requirement.Description, - Requirements_Name=requirement.Name, - Requirements_Attributes_Section=attribute.Section, - Requirements_Attributes_CCMLite=attribute.CCMLite, - Requirements_Attributes_IaaS=attribute.IaaS, - Requirements_Attributes_PaaS=attribute.PaaS, - Requirements_Attributes_SaaS=attribute.SaaS, - Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability, - Status=finding.status, - StatusExtended=finding.status_extended, - ResourceId=finding.resource_uid, - ResourceName=finding.resource_name, - CheckId=finding.check_id, - Muted=finding.muted, - Framework=compliance.Framework, - Name=compliance.Name, - ) - self._data.append(compliance_row) - # Add manual requirements to the compliance output - for requirement in compliance.Requirements: - if not requirement.Checks: - for attribute in requirement.Attributes: - compliance_row = AWSCSAModel( - Provider=compliance.Provider.lower(), - Description=compliance.Description, - AccountId="", - Region="", - AssessmentDate=str(timestamp), - Requirements_Id=requirement.Id, - Requirements_Description=requirement.Description, - Requirements_Name=requirement.Name, - Requirements_Attributes_Section=attribute.Section, - Requirements_Attributes_CCMLite=attribute.CCMLite, - Requirements_Attributes_IaaS=attribute.IaaS, - Requirements_Attributes_PaaS=attribute.PaaS, - Requirements_Attributes_SaaS=attribute.SaaS, - Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability, - Status="MANUAL", - StatusExtended="Manual check", - ResourceId="manual_check", - ResourceName="Manual check", - CheckId="manual", - Muted=False, - Framework=compliance.Framework, - Name=compliance.Name, - ) - self._data.append(compliance_row) diff --git a/prowler/lib/outputs/compliance/csa/csa_azure.py b/prowler/lib/outputs/compliance/csa/csa_azure.py deleted file mode 100644 index cf9a1064e6..0000000000 --- a/prowler/lib/outputs/compliance/csa/csa_azure.py +++ /dev/null @@ -1,95 +0,0 @@ -from prowler.config.config import timestamp -from prowler.lib.check.compliance_models import Compliance -from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput -from prowler.lib.outputs.compliance.csa.models import AzureCSAModel -from prowler.lib.outputs.finding import Finding - - -class AzureCSA(ComplianceOutput): - """ - This class represents the Azure CSA compliance output. - - Attributes: - - _data (list): A list to store transformed data from findings. - - _file_descriptor (TextIOWrapper): A file descriptor to write data to a file. - - Methods: - - transform: Transforms findings into Azure CSA compliance format. - """ - - def transform( - self, - findings: list[Finding], - compliance: Compliance, - compliance_name: str, - ) -> None: - """ - Transforms a list of findings into Azure CSA compliance format. - - Parameters: - - findings (list): A list of findings. - - compliance (Compliance): A compliance model. - - compliance_name (str): The name of the compliance model. - - Returns: - - None - """ - for finding in findings: - for requirement in compliance.Requirements: - # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). - if finding.check_id in requirement.Checks: - for attribute in requirement.Attributes: - compliance_row = AzureCSAModel( - Provider=finding.provider, - Description=compliance.Description, - SubscriptionId=finding.account_uid, - Location=finding.region, - AssessmentDate=str(timestamp), - Requirements_Id=requirement.Id, - Requirements_Description=requirement.Description, - Requirements_Name=requirement.Name, - Requirements_Attributes_Section=attribute.Section, - Requirements_Attributes_CCMLite=attribute.CCMLite, - Requirements_Attributes_IaaS=attribute.IaaS, - Requirements_Attributes_PaaS=attribute.PaaS, - Requirements_Attributes_SaaS=attribute.SaaS, - Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability, - Status=finding.status, - StatusExtended=finding.status_extended, - ResourceId=finding.resource_uid, - ResourceName=finding.resource_name, - CheckId=finding.check_id, - Muted=finding.muted, - Framework=compliance.Framework, - Name=compliance.Name, - ) - self._data.append(compliance_row) - # Add manual requirements to the compliance output - for requirement in compliance.Requirements: - if not requirement.Checks: - for attribute in requirement.Attributes: - compliance_row = AzureCSAModel( - Provider=compliance.Provider.lower(), - Description=compliance.Description, - SubscriptionId="", - Location="", - AssessmentDate=str(timestamp), - Requirements_Id=requirement.Id, - Requirements_Description=requirement.Description, - Requirements_Name=requirement.Name, - Requirements_Attributes_Section=attribute.Section, - Requirements_Attributes_CCMLite=attribute.CCMLite, - Requirements_Attributes_IaaS=attribute.IaaS, - Requirements_Attributes_PaaS=attribute.PaaS, - Requirements_Attributes_SaaS=attribute.SaaS, - Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability, - Status="MANUAL", - StatusExtended="Manual check", - ResourceId="manual_check", - ResourceName="Manual check", - CheckId="manual", - Muted=False, - Framework=compliance.Framework, - Name=compliance.Name, - ) - self._data.append(compliance_row) diff --git a/prowler/lib/outputs/compliance/csa/csa_gcp.py b/prowler/lib/outputs/compliance/csa/csa_gcp.py deleted file mode 100644 index 4a829295db..0000000000 --- a/prowler/lib/outputs/compliance/csa/csa_gcp.py +++ /dev/null @@ -1,95 +0,0 @@ -from prowler.config.config import timestamp -from prowler.lib.check.compliance_models import Compliance -from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput -from prowler.lib.outputs.compliance.csa.models import GCPCSAModel -from prowler.lib.outputs.finding import Finding - - -class GCPCSA(ComplianceOutput): - """ - This class represents the GCP CSA compliance output. - - Attributes: - - _data (list): A list to store transformed data from findings. - - _file_descriptor (TextIOWrapper): A file descriptor to write data to a file. - - Methods: - - transform: Transforms findings into GCP CSA compliance format. - """ - - def transform( - self, - findings: list[Finding], - compliance: Compliance, - compliance_name: str, - ) -> None: - """ - Transforms a list of findings into GCP CSA compliance format. - - Parameters: - - findings (list): A list of findings. - - compliance (Compliance): A compliance model. - - compliance_name (str): The name of the compliance model. - - Returns: - - None - """ - for finding in findings: - for requirement in compliance.Requirements: - # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). - if finding.check_id in requirement.Checks: - for attribute in requirement.Attributes: - compliance_row = GCPCSAModel( - Provider=finding.provider, - Description=compliance.Description, - ProjectId=finding.account_uid, - Location=finding.region, - AssessmentDate=str(timestamp), - Requirements_Id=requirement.Id, - Requirements_Description=requirement.Description, - Requirements_Name=requirement.Name, - Requirements_Attributes_Section=attribute.Section, - Requirements_Attributes_CCMLite=attribute.CCMLite, - Requirements_Attributes_IaaS=attribute.IaaS, - Requirements_Attributes_PaaS=attribute.PaaS, - Requirements_Attributes_SaaS=attribute.SaaS, - Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability, - Status=finding.status, - StatusExtended=finding.status_extended, - ResourceId=finding.resource_uid, - ResourceName=finding.resource_name, - CheckId=finding.check_id, - Muted=finding.muted, - Framework=compliance.Framework, - Name=compliance.Name, - ) - self._data.append(compliance_row) - # Add manual requirements to the compliance output - for requirement in compliance.Requirements: - if not requirement.Checks: - for attribute in requirement.Attributes: - compliance_row = GCPCSAModel( - Provider=compliance.Provider.lower(), - Description=compliance.Description, - ProjectId="", - Location="", - AssessmentDate=str(timestamp), - Requirements_Id=requirement.Id, - Requirements_Description=requirement.Description, - Requirements_Name=requirement.Name, - Requirements_Attributes_Section=attribute.Section, - Requirements_Attributes_CCMLite=attribute.CCMLite, - Requirements_Attributes_IaaS=attribute.IaaS, - Requirements_Attributes_PaaS=attribute.PaaS, - Requirements_Attributes_SaaS=attribute.SaaS, - Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability, - Status="MANUAL", - StatusExtended="Manual check", - ResourceId="manual_check", - ResourceName="Manual check", - CheckId="manual", - Muted=False, - Framework=compliance.Framework, - Name=compliance.Name, - ) - self._data.append(compliance_row) diff --git a/prowler/lib/outputs/compliance/csa/models.py b/prowler/lib/outputs/compliance/csa/models.py deleted file mode 100644 index 78c7384fc6..0000000000 --- a/prowler/lib/outputs/compliance/csa/models.py +++ /dev/null @@ -1,146 +0,0 @@ -from pydantic.v1 import BaseModel - - -class AWSCSAModel(BaseModel): - """ - AWSCSAModel generates a finding's output in CSV CSA format for AWS. - """ - - Provider: str - Description: str - AccountId: str - Region: str - AssessmentDate: str - Requirements_Id: str - Requirements_Description: str - Requirements_Name: str - Requirements_Attributes_Section: str - Requirements_Attributes_CCMLite: str - Requirements_Attributes_IaaS: str - Requirements_Attributes_PaaS: str - Requirements_Attributes_SaaS: str - Requirements_Attributes_ScopeApplicability: list[dict] - Status: str - StatusExtended: str - ResourceId: str - CheckId: str - Muted: bool - ResourceName: str - Framework: str - Name: str - - -class GCPCSAModel(BaseModel): - """ - GCPCSAModel generates a finding's output in CSV CSA format for GCP. - """ - - Provider: str - Description: str - ProjectId: str - Location: str - AssessmentDate: str - Requirements_Id: str - Requirements_Description: str - Requirements_Name: str - Requirements_Attributes_Section: str - Requirements_Attributes_CCMLite: str - Requirements_Attributes_IaaS: str - Requirements_Attributes_PaaS: str - Requirements_Attributes_SaaS: str - Requirements_Attributes_ScopeApplicability: list[dict] - Status: str - StatusExtended: str - ResourceId: str - CheckId: str - Muted: bool - ResourceName: str - Framework: str - Name: str - - -class OracleCloudCSAModel(BaseModel): - """ - OracleCloudCSAModel generates a finding's output in CSV CSA format for OracleCloud. - """ - - Provider: str - Description: str - TenancyId: str - Region: str - AssessmentDate: str - Requirements_Id: str - Requirements_Description: str - Requirements_Name: str - Requirements_Attributes_Section: str - Requirements_Attributes_CCMLite: str - Requirements_Attributes_IaaS: str - Requirements_Attributes_PaaS: str - Requirements_Attributes_SaaS: str - Requirements_Attributes_ScopeApplicability: list[dict] - Status: str - StatusExtended: str - ResourceId: str - CheckId: str - Muted: bool - ResourceName: str - Framework: str - Name: str - - -class AlibabaCloudCSAModel(BaseModel): - """ - AlibabaCloudCSAModel generates a finding's output in CSV CSA format for Alibaba Cloud. - """ - - Provider: str - Description: str - AccountId: str - Region: str - AssessmentDate: str - Requirements_Id: str - Requirements_Description: str - Requirements_Name: str - Requirements_Attributes_Section: str - Requirements_Attributes_CCMLite: str - Requirements_Attributes_IaaS: str - Requirements_Attributes_PaaS: str - Requirements_Attributes_SaaS: str - Requirements_Attributes_ScopeApplicability: list[dict] - Status: str - StatusExtended: str - ResourceId: str - CheckId: str - Muted: bool - ResourceName: str - Framework: str - Name: str - - -class AzureCSAModel(BaseModel): - """ - AzureCSAModel generates a finding's output in CSV CSA format for Azure. - """ - - Provider: str - Description: str - SubscriptionId: str - Location: str - AssessmentDate: str - Requirements_Id: str - Requirements_Description: str - Requirements_Name: str - Requirements_Attributes_Section: str - Requirements_Attributes_CCMLite: str - Requirements_Attributes_IaaS: str - Requirements_Attributes_PaaS: str - Requirements_Attributes_SaaS: str - Requirements_Attributes_ScopeApplicability: list[dict] - Status: str - StatusExtended: str - ResourceId: str - CheckId: str - Muted: bool - ResourceName: str - Framework: str - Name: str diff --git a/prowler/lib/outputs/compliance/ens/ens.py b/prowler/lib/outputs/compliance/ens/ens.py index a414a0206d..c39abe0a6a 100644 --- a/prowler/lib/outputs/compliance/ens/ens.py +++ b/prowler/lib/outputs/compliance/ens/ens.py @@ -13,6 +13,8 @@ def get_ens_table( compliance_overview: bool, ): marcos = {} + marco_muted_seen = {} + provider = "" ens_compliance_table = { "Proveedor": [], "Marco/Categoria": [], @@ -31,6 +33,7 @@ def get_ens_table( check_compliances = check.Compliance for compliance in check_compliances: if compliance.Framework == "ENS": + provider = compliance.Provider for requirement in compliance.Requirements: for attribute in requirement.Attributes: marco_categoria = f"{attribute.Marco}/{attribute.Categoria}" @@ -44,17 +47,23 @@ def get_ens_table( "Bajo": 0, "Muted": 0, } + marco_muted_seen[marco_categoria] = set() if finding.muted: + # Overview total: count each finding once per framework if index not in muted_count: muted_count.append(index) + # Per-marco Muted: count each finding once per marco + # it belongs to (a finding can map to several marcos). + if index not in marco_muted_seen[marco_categoria]: + marco_muted_seen[marco_categoria].add(index) marcos[marco_categoria]["Muted"] += 1 else: if finding.status == "FAIL": - if ( - attribute.Tipo != "recomendacion" - and index not in fail_count - ): - fail_count.append(index) + if attribute.Tipo != "recomendacion": + if index not in fail_count: + fail_count.append(index) + # Mark every marco the finding belongs to as + # NO CUMPLE, not just the first one seen. marcos[marco_categoria][ "Estado" ] = f"{Fore.RED}NO CUMPLE{Style.RESET_ALL}" @@ -71,7 +80,7 @@ def get_ens_table( # Add results to table for marco in sorted(marcos): - ens_compliance_table["Proveedor"].append(compliance.Provider) + ens_compliance_table["Proveedor"].append(provider) ens_compliance_table["Marco/Categoria"].append(marco) ens_compliance_table["Estado"].append(marcos[marco]["Estado"]) ens_compliance_table["Opcional"].append( diff --git a/prowler/lib/outputs/compliance/generic/generic.py b/prowler/lib/outputs/compliance/generic/generic.py index c7217db5b3..b774f09577 100644 --- a/prowler/lib/outputs/compliance/generic/generic.py +++ b/prowler/lib/outputs/compliance/generic/generic.py @@ -34,60 +34,48 @@ class GenericCompliance(ComplianceOutput): Returns: - None """ + + def compliance_row(requirement, attribute, finding=None): + # Read attribute fields defensively: GenericCompliance is the + # last-resort renderer for any framework, and provider-specific + # schemas (e.g. CIS, ENS, ISO27001) do not declare the universal + # Section/SubSection/SubGroup/Service/Type/Comment fields. + return GenericComplianceModel( + Provider=(finding.provider if finding else compliance.Provider.lower()), + Description=compliance.Description, + AccountId=finding.account_uid if finding else "", + Region=finding.region if finding else "", + AssessmentDate=str(timestamp), + Requirements_Id=requirement.Id, + Requirements_Description=requirement.Description, + Requirements_Attributes_Section=getattr(attribute, "Section", None), + Requirements_Attributes_SubSection=getattr( + attribute, "SubSection", None + ), + Requirements_Attributes_SubGroup=getattr(attribute, "SubGroup", None), + Requirements_Attributes_Service=getattr(attribute, "Service", None), + Requirements_Attributes_Type=getattr(attribute, "Type", None), + Requirements_Attributes_Comment=getattr(attribute, "Comment", None), + Status=finding.status if finding else "MANUAL", + StatusExtended=(finding.status_extended if finding else "Manual check"), + ResourceId=finding.resource_uid if finding else "manual_check", + ResourceName=finding.resource_name if finding else "Manual check", + CheckId=finding.check_id if finding else "manual", + Muted=finding.muted if finding else False, + Framework=compliance.Framework, + Name=compliance.Name, + ) + for finding in findings: for requirement in compliance.Requirements: # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). if finding.check_id in requirement.Checks: for attribute in requirement.Attributes: - compliance_row = GenericComplianceModel( - Provider=finding.provider, - Description=compliance.Description, - AccountId=finding.account_uid, - Region=finding.region, - AssessmentDate=str(timestamp), - Requirements_Id=requirement.Id, - Requirements_Description=requirement.Description, - Requirements_Attributes_Section=attribute.Section, - Requirements_Attributes_SubSection=attribute.SubSection, - Requirements_Attributes_SubGroup=attribute.SubGroup, - Requirements_Attributes_Service=attribute.Service, - Requirements_Attributes_Type=attribute.Type, - Requirements_Attributes_Comment=attribute.Comment, - Status=finding.status, - StatusExtended=finding.status_extended, - ResourceId=finding.resource_uid, - ResourceName=finding.resource_name, - CheckId=finding.check_id, - Muted=finding.muted, - Framework=compliance.Framework, - Name=compliance.Name, + self._data.append( + compliance_row(requirement, attribute, finding) ) - self._data.append(compliance_row) # Add manual requirements to the compliance output for requirement in compliance.Requirements: if not requirement.Checks: for attribute in requirement.Attributes: - compliance_row = GenericComplianceModel( - Provider=compliance.Provider.lower(), - Description=compliance.Description, - AccountId="", - Region="", - AssessmentDate=str(timestamp), - Requirements_Id=requirement.Id, - Requirements_Description=requirement.Description, - Requirements_Attributes_Section=attribute.Section, - Requirements_Attributes_SubSection=attribute.SubSection, - Requirements_Attributes_SubGroup=attribute.SubGroup, - Requirements_Attributes_Service=attribute.Service, - Requirements_Attributes_Type=attribute.Type, - Requirements_Attributes_Comment=attribute.Comment, - Status="MANUAL", - StatusExtended="Manual check", - ResourceId="manual_check", - ResourceName="Manual check", - CheckId="manual", - Muted=False, - Framework=compliance.Framework, - Name=compliance.Name, - ) - self._data.append(compliance_row) + self._data.append(compliance_row(requirement, attribute)) diff --git a/prowler/lib/outputs/compliance/kisa_ismsp/kisa_ismsp.py b/prowler/lib/outputs/compliance/kisa_ismsp/kisa_ismsp.py index 93b925a5ff..e7c00b188d 100644 --- a/prowler/lib/outputs/compliance/kisa_ismsp/kisa_ismsp.py +++ b/prowler/lib/outputs/compliance/kisa_ismsp/kisa_ismsp.py @@ -13,7 +13,9 @@ def get_kisa_ismsp_table( compliance_overview: bool, ): sections = {} + section_seen = {} sections_status = {} + provider = "" kisa_ismsp_compliance_table = { "Provider": [], "Section": [], @@ -31,6 +33,7 @@ def get_kisa_ismsp_table( compliance.Framework.startswith("KISA") and compliance.Version in compliance_framework ): + provider = compliance.Provider for requirement in compliance.Requirements: for attribute in requirement.Attributes: section = attribute.Section @@ -43,16 +46,28 @@ def get_kisa_ismsp_table( }, "Muted": 0, } + section_seen[section] = set() + + # Overview totals: count each finding once per framework if finding.muted: if index not in muted_count: muted_count.append(index) - sections[section]["Muted"] += 1 - else: - if finding.status == "FAIL" and index not in fail_count: + elif finding.status == "FAIL": + if index not in fail_count: fail_count.append(index) - sections[section]["Status"]["FAIL"] += 1 - elif finding.status == "PASS" and index not in pass_count: + elif finding.status == "PASS": + if index not in pass_count: pass_count.append(index) + + # Per-section counts: count each finding once per section + # it belongs to (a finding can map to several sections). + if index not in section_seen[section]: + section_seen[section].add(index) + if finding.muted: + sections[section]["Muted"] += 1 + elif finding.status == "FAIL": + sections[section]["Status"]["FAIL"] += 1 + elif finding.status == "PASS": sections[section]["Status"]["PASS"] += 1 # Add results to table @@ -70,7 +85,7 @@ def get_kisa_ismsp_table( else: sections_status[section] = f"{Fore.GREEN}PASS{Style.RESET_ALL}" for section in sections: - kisa_ismsp_compliance_table["Provider"].append(compliance.Provider) + kisa_ismsp_compliance_table["Provider"].append(provider) kisa_ismsp_compliance_table["Section"].append(section) kisa_ismsp_compliance_table["Status"].append(sections_status[section]) kisa_ismsp_compliance_table["Muted"].append( diff --git a/prowler/lib/outputs/compliance/mitre_attack/mitre_attack.py b/prowler/lib/outputs/compliance/mitre_attack/mitre_attack.py index bab3e4e31a..7492624aff 100644 --- a/prowler/lib/outputs/compliance/mitre_attack/mitre_attack.py +++ b/prowler/lib/outputs/compliance/mitre_attack/mitre_attack.py @@ -13,6 +13,8 @@ def get_mitre_attack_table( compliance_overview: bool, ): tactics = {} + tactic_seen = {} + provider = "" mitre_compliance_table = { "Provider": [], "Tactic": [], @@ -30,27 +32,38 @@ def get_mitre_attack_table( "MITRE-ATTACK" in compliance.Framework and compliance.Version in compliance_framework ): + provider = compliance.Provider for requirement in compliance.Requirements: for tactic in requirement.Tactics: if tactic not in tactics: tactics[tactic] = {"FAIL": 0, "PASS": 0, "Muted": 0} + tactic_seen[tactic] = set() + + # Overview totals: count each finding once per framework if finding.muted: if index not in muted_count: muted_count.append(index) + elif finding.status == "FAIL": + if index not in fail_count: + fail_count.append(index) + elif finding.status == "PASS": + if index not in pass_count: + pass_count.append(index) + + # Per-tactic counts: count each finding once per tactic + # it belongs to (a finding can map to several tactics). + if index not in tactic_seen[tactic]: + tactic_seen[tactic].add(index) + if finding.muted: tactics[tactic]["Muted"] += 1 - else: - if finding.status == "FAIL": - if index not in fail_count: - fail_count.append(index) - tactics[tactic]["FAIL"] += 1 + elif finding.status == "FAIL": + tactics[tactic]["FAIL"] += 1 elif finding.status == "PASS": - if index not in pass_count: - pass_count.append(index) - tactics[tactic]["PASS"] += 1 + tactics[tactic]["PASS"] += 1 # Add results to table tactics = dict(sorted(tactics.items())) for tactic in tactics: - mitre_compliance_table["Provider"].append(compliance.Provider) + mitre_compliance_table["Provider"].append(provider) mitre_compliance_table["Tactic"].append(tactic) if tactics[tactic]["FAIL"] > 0: mitre_compliance_table["Status"].append( diff --git a/prowler/lib/outputs/compliance/okta_idaas_stig/__init__.py b/prowler/lib/outputs/compliance/okta_idaas_stig/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/lib/outputs/compliance/okta_idaas_stig/models.py b/prowler/lib/outputs/compliance/okta_idaas_stig/models.py new file mode 100644 index 0000000000..674d9656b7 --- /dev/null +++ b/prowler/lib/outputs/compliance/okta_idaas_stig/models.py @@ -0,0 +1,32 @@ +from typing import Optional + +from pydantic.v1 import BaseModel + + +class OktaIDaaSSTIGModel(BaseModel): + """ + OktaIDaaSSTIGModel generates a finding's output in DISA Okta IDaaS STIG Compliance format. + """ + + Provider: str + Description: str + OrganizationDomain: str + AssessmentDate: str + Requirements_Id: str + Requirements_Name: str + Requirements_Description: str + Requirements_Attributes_Section: str + Requirements_Attributes_Severity: str + Requirements_Attributes_RuleID: str + Requirements_Attributes_StigID: str + Requirements_Attributes_CCI: Optional[list[str]] = None + Requirements_Attributes_CheckText: Optional[str] = None + Requirements_Attributes_FixText: Optional[str] = None + Status: str + StatusExtended: str + ResourceId: str + ResourceName: str + CheckId: str + Muted: bool + Framework: str + Name: str diff --git a/prowler/lib/outputs/compliance/csa/csa.py b/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig.py similarity index 78% rename from prowler/lib/outputs/compliance/csa/csa.py rename to prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig.py index ab8a021a70..1febe02f60 100644 --- a/prowler/lib/outputs/compliance/csa/csa.py +++ b/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig.py @@ -4,7 +4,7 @@ from tabulate import tabulate from prowler.config.config import orange_color -def get_csa_table( +def get_okta_idaas_stig_table( findings: list, bulk_checks_metadata: dict, compliance_framework: str, @@ -22,36 +22,47 @@ def get_csa_table( fail_count = [] muted_count = [] sections = {} + section_seen = {} + provider = "" for index, finding in enumerate(findings): check = bulk_checks_metadata[finding.check_metadata.CheckID] check_compliances = check.Compliance for compliance in check_compliances: - if ( - compliance.Framework == "CSA-CCM" - and compliance.Version in compliance_framework - ): + if compliance.Framework == "Okta-IDaaS-STIG": + provider = compliance.Provider for requirement in compliance.Requirements: for attribute in requirement.Attributes: section = attribute.Section if section not in sections: sections[section] = {"FAIL": 0, "PASS": 0, "Muted": 0} + section_seen[section] = set() + # Overview totals: count each finding once per framework if finding.muted: if index not in muted_count: muted_count.append(index) - sections[section]["Muted"] += 1 - else: - if finding.status == "FAIL" and index not in fail_count: + elif finding.status == "FAIL": + if index not in fail_count: fail_count.append(index) - sections[section]["FAIL"] += 1 - elif finding.status == "PASS" and index not in pass_count: + elif finding.status == "PASS": + if index not in pass_count: pass_count.append(index) + + # Per-section counts: count each finding once per section + # it belongs to (a finding can map to several sections). + if index not in section_seen[section]: + section_seen[section].add(index) + if finding.muted: + sections[section]["Muted"] += 1 + elif finding.status == "FAIL": + sections[section]["FAIL"] += 1 + elif finding.status == "PASS": sections[section]["PASS"] += 1 sections = dict(sorted(sections.items())) for section in sections: - section_table["Provider"].append(compliance.Provider) + section_table["Provider"].append(provider) section_table["Section"].append(section) if sections[section]["FAIL"] > 0: section_table["Status"].append( diff --git a/prowler/lib/outputs/compliance/csa/csa_oraclecloud.py b/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_okta.py similarity index 66% rename from prowler/lib/outputs/compliance/csa/csa_oraclecloud.py rename to prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_okta.py index 107589c0f7..25f71b4def 100644 --- a/prowler/lib/outputs/compliance/csa/csa_oraclecloud.py +++ b/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_okta.py @@ -1,35 +1,35 @@ from prowler.config.config import timestamp from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput -from prowler.lib.outputs.compliance.csa.models import OracleCloudCSAModel +from prowler.lib.outputs.compliance.okta_idaas_stig.models import OktaIDaaSSTIGModel from prowler.lib.outputs.finding import Finding -class OracleCloudCSA(ComplianceOutput): +class OktaIDaaSSTIG(ComplianceOutput): """ - This class represents the OracleCloud CSA compliance output. + This class represents the Okta IDaaS STIG compliance output. Attributes: - _data (list): A list to store transformed data from findings. - _file_descriptor (TextIOWrapper): A file descriptor to write data to a file. Methods: - - transform: Transforms findings into OracleCloud CSA compliance format. + - transform: Transforms findings into Okta IDaaS STIG compliance format. """ def transform( self, findings: list[Finding], compliance: Compliance, - compliance_name: str, + _compliance_name: str, ) -> None: """ - Transforms a list of findings into OracleCloud CSA compliance format. + Transforms a list of findings into Okta IDaaS STIG compliance format. Parameters: - findings (list): A list of findings. - compliance (Compliance): A compliance model. - - compliance_name (str): The name of the compliance model. + - _compliance_name (str): The name of the compliance model (unused). Returns: - None @@ -39,21 +39,21 @@ class OracleCloudCSA(ComplianceOutput): # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). if finding.check_id in requirement.Checks: for attribute in requirement.Attributes: - compliance_row = OracleCloudCSAModel( + compliance_row = OktaIDaaSSTIGModel( Provider=finding.provider, Description=compliance.Description, - TenancyId=finding.account_uid, - Region=finding.region, + OrganizationDomain=finding.account_name, AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, - Requirements_Description=requirement.Description, Requirements_Name=requirement.Name, + Requirements_Description=requirement.Description, Requirements_Attributes_Section=attribute.Section, - Requirements_Attributes_CCMLite=attribute.CCMLite, - Requirements_Attributes_IaaS=attribute.IaaS, - Requirements_Attributes_PaaS=attribute.PaaS, - Requirements_Attributes_SaaS=attribute.SaaS, - Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability, + Requirements_Attributes_Severity=attribute.Severity.value, + Requirements_Attributes_RuleID=attribute.RuleID, + Requirements_Attributes_StigID=attribute.StigID, + Requirements_Attributes_CCI=attribute.CCI, + Requirements_Attributes_CheckText=attribute.CheckText, + Requirements_Attributes_FixText=attribute.FixText, Status=finding.status, StatusExtended=finding.status_extended, ResourceId=finding.resource_uid, @@ -68,21 +68,21 @@ class OracleCloudCSA(ComplianceOutput): for requirement in compliance.Requirements: if not requirement.Checks: for attribute in requirement.Attributes: - compliance_row = OracleCloudCSAModel( + compliance_row = OktaIDaaSSTIGModel( Provider=compliance.Provider.lower(), Description=compliance.Description, - TenancyId="", - Region="", + OrganizationDomain="", AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, - Requirements_Description=requirement.Description, Requirements_Name=requirement.Name, + Requirements_Description=requirement.Description, Requirements_Attributes_Section=attribute.Section, - Requirements_Attributes_CCMLite=attribute.CCMLite, - Requirements_Attributes_IaaS=attribute.IaaS, - Requirements_Attributes_PaaS=attribute.PaaS, - Requirements_Attributes_SaaS=attribute.SaaS, - Requirements_Attributes_ScopeApplicability=attribute.ScopeApplicability, + Requirements_Attributes_Severity=attribute.Severity.value, + Requirements_Attributes_RuleID=attribute.RuleID, + Requirements_Attributes_StigID=attribute.StigID, + Requirements_Attributes_CCI=attribute.CCI, + Requirements_Attributes_CheckText=attribute.CheckText, + Requirements_Attributes_FixText=attribute.FixText, Status="MANUAL", StatusExtended="Manual check", ResourceId="manual_check", diff --git a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore.py b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore.py index cfcd4a006e..b17307f04a 100644 --- a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore.py +++ b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore.py @@ -24,6 +24,8 @@ def get_prowler_threatscore_table( fail_count = [] muted_count = [] pillars = {} + pillar_seen = {} + provider = "" generic_score = 0 max_generic_score = 0 counted_findings_generic = [] @@ -35,6 +37,7 @@ def get_prowler_threatscore_table( check_compliances = check.Compliance for compliance in check_compliances: if compliance.Framework == "ProwlerThreatScore": + provider = compliance.Provider for requirement in compliance.Requirements: for attribute in requirement.Attributes: pillar = attribute.Section @@ -65,17 +68,28 @@ def get_prowler_threatscore_table( if pillar not in pillars: pillars[pillar] = {"FAIL": 0, "PASS": 0, "Muted": 0} + pillar_seen[pillar] = set() + # Overview totals: count each finding once per framework if finding.muted: if index not in muted_count: muted_count.append(index) - pillars[pillar]["Muted"] += 1 - else: - if finding.status == "FAIL" and index not in fail_count: + elif finding.status == "FAIL": + if index not in fail_count: fail_count.append(index) - pillars[pillar]["FAIL"] += 1 - elif finding.status == "PASS" and index not in pass_count: + elif finding.status == "PASS": + if index not in pass_count: pass_count.append(index) + + # Per-pillar counts: count each finding once per pillar + # it belongs to (a finding can map to several pillars). + if index not in pillar_seen[pillar]: + pillar_seen[pillar].add(index) + if finding.muted: + pillars[pillar]["Muted"] += 1 + elif finding.status == "FAIL": + pillars[pillar]["FAIL"] += 1 + elif finding.status == "PASS": pillars[pillar]["PASS"] += 1 # Generic score @@ -90,18 +104,21 @@ def get_prowler_threatscore_table( counted_findings_generic.append(index) no_findings_pillars = [] - bulk_compliance = Compliance.get_bulk(provider=compliance.Provider.lower()).get( - compliance_framework + bulk_compliance = ( + Compliance.get_bulk(provider=provider.lower()).get(compliance_framework) + if provider + else None ) - for requirement in bulk_compliance.Requirements: - for attribute in requirement.Attributes: - pillar = attribute.Section - if pillar not in pillars.keys() and pillar not in no_findings_pillars: - no_findings_pillars.append(pillar) + if bulk_compliance: + for requirement in bulk_compliance.Requirements: + for attribute in requirement.Attributes: + pillar = attribute.Section + if pillar not in pillars.keys() and pillar not in no_findings_pillars: + no_findings_pillars.append(pillar) pillars = dict(sorted(pillars.items())) for pillar in pillars: - pillar_table["Provider"].append(compliance.Provider) + pillar_table["Provider"].append(provider) pillar_table["Pillar"].append(pillar) if max_score_per_pillar[pillar] == 0: pillar_score = 100.0 @@ -127,7 +144,7 @@ def get_prowler_threatscore_table( ) for pillar in no_findings_pillars: - pillar_table["Provider"].append(compliance.Provider) + pillar_table["Provider"].append(provider) pillar_table["Pillar"].append(pillar) pillar_table["Score"].append(f"{Style.BRIGHT}{Fore.GREEN}100%{Style.RESET_ALL}") pillar_table["Status"].append(f"{Fore.GREEN}PASS{Style.RESET_ALL}") diff --git a/prowler/lib/outputs/compliance/universal/ocsf_compliance.py b/prowler/lib/outputs/compliance/universal/ocsf_compliance.py index 2ce69412e1..2886f7e4d2 100644 --- a/prowler/lib/outputs/compliance/universal/ocsf_compliance.py +++ b/prowler/lib/outputs/compliance/universal/ocsf_compliance.py @@ -79,30 +79,43 @@ def _to_snake_case(name: str) -> str: return s.lower() -def _build_requirement_attrs(requirement, framework) -> dict: - """Build a dict with requirement attributes for the unmapped section. +def _build_requirement_attrs(requirement, framework): + """Build the requirement attributes payload for the unmapped section. - Keys are normalized to snake_case for OCSF consistency. - Only includes attributes whose AttributeMetadata has output_formats.ocsf=True. - When no metadata is declared, all attributes are included. + Keys are snake_cased and filtered by ``AttributeMetadata.output_formats.ocsf`` + when declared. MITRE-style attrs (``{"_raw_attributes": [...]}``) are + unwrapped into a list of per-entry dicts. """ - attrs = requirement.attributes - if not attrs: + requirement_attributes = requirement.attributes + if not requirement_attributes: return {} - # Build set of keys allowed for OCSF output metadata = framework.attributes_metadata - if metadata: - ocsf_keys = {m.key for m in metadata if m.output_formats.ocsf} - else: - ocsf_keys = None # No metadata → include all + allowed_keys = ( + {entry.key for entry in metadata if entry.output_formats.ocsf} + if metadata + else None + ) - result = {} - for key, value in attrs.items(): - if ocsf_keys is not None and key not in ocsf_keys: - continue - result[_to_snake_case(key)] = value - return result + def _to_snake_case_dict(entry: dict) -> dict: + return { + _to_snake_case(key): value + for key, value in entry.items() + if allowed_keys is None or key in allowed_keys + } + + if ( + isinstance(requirement_attributes, dict) + and "_raw_attributes" in requirement_attributes + ): + raw_entries = requirement_attributes.get("_raw_attributes") or [] + return [ + _to_snake_case_dict(entry) + for entry in raw_entries + if isinstance(entry, dict) + ] + + return _to_snake_case_dict(requirement_attributes) class OCSFComplianceOutput: @@ -147,7 +160,14 @@ class OCSFComplianceOutput: findings: List["Finding"], framework: ComplianceFramework, compliance_name: str, + include_manual: bool = True, ) -> None: + """Transform findings into OCSF ComplianceFinding events. + + Manual requirements are emitted only when ``include_manual=True``. The + caller must pass ``False`` for subsequent streaming batches so manual + events are not duplicated. + """ # Build check -> requirements map check_req_map = {} for req in framework.requirements: @@ -170,6 +190,9 @@ class OCSFComplianceOutput: if cf: self._data.append(cf) + if not include_manual: + return + # Manual requirements (no checks or empty for current provider) for req in framework.requirements: checks = req.checks diff --git a/prowler/lib/outputs/compliance/universal/universal_output.py b/prowler/lib/outputs/compliance/universal/universal_output.py index 5f99f05755..a3cdb1389a 100644 --- a/prowler/lib/outputs/compliance/universal/universal_output.py +++ b/prowler/lib/outputs/compliance/universal/universal_output.py @@ -198,8 +198,15 @@ class UniversalComplianceOutput: findings: list["Finding"], framework: ComplianceFramework, compliance_name: str, + include_manual: bool = True, ) -> None: - """Transform findings into universal compliance CSV rows.""" + """Transform findings into universal compliance CSV rows. + + Manual requirements (no checks or empty for current provider) are + emitted only when ``include_manual=True``. When the writer is reused + across streaming batches, the caller should pass ``False`` after the + first batch so manual rows are not duplicated. + """ # Build check -> requirements map (filtered by provider for dict checks) check_req_map = {} for req in framework.requirements: @@ -228,6 +235,9 @@ class UniversalComplianceOutput: except Exception as e: logger.debug(f"Skipping row for {req.id}: {e}") + if not include_manual: + return + # Manual requirements (no checks or empty dict) for req in framework.requirements: checks = req.checks diff --git a/prowler/lib/outputs/compliance/universal/universal_table.py b/prowler/lib/outputs/compliance/universal/universal_table.py index e838c5e9cf..e54ad5155c 100644 --- a/prowler/lib/outputs/compliance/universal/universal_table.py +++ b/prowler/lib/outputs/compliance/universal/universal_table.py @@ -163,6 +163,7 @@ def _render_grouped( """Grouped mode: one row per group with pass/fail counts.""" check_map = _build_requirement_check_map(framework, provider) groups = {} + group_seen = {} pass_count = [] fail_count = [] muted_count = [] @@ -176,17 +177,28 @@ def _render_grouped( for group_key in _get_group_key(req, group_by): if group_key not in groups: groups[group_key] = {"FAIL": 0, "PASS": 0, "Muted": 0} + group_seen[group_key] = set() + # Overview totals: count each finding once per framework if finding.muted: if index not in muted_count: muted_count.append(index) - groups[group_key]["Muted"] += 1 - else: - if finding.status == "FAIL" and index not in fail_count: + elif finding.status == "FAIL": + if index not in fail_count: fail_count.append(index) - groups[group_key]["FAIL"] += 1 - elif finding.status == "PASS" and index not in pass_count: + elif finding.status == "PASS": + if index not in pass_count: pass_count.append(index) + + # Per-group counts: count each finding once per group it belongs + # to (a finding can map to several groups via several requirements). + if index not in group_seen[group_key]: + group_seen[group_key].add(index) + if finding.muted: + groups[group_key]["Muted"] += 1 + elif finding.status == "FAIL": + groups[group_key]["FAIL"] += 1 + elif finding.status == "PASS": groups[group_key]["PASS"] += 1 if not _print_overview( @@ -258,6 +270,8 @@ def _render_split( split_field = split_by.field split_values = split_by.values groups = {} + group_muted_seen = {} + group_split_seen = {} pass_count = [] fail_count = [] muted_count = [] @@ -274,12 +288,19 @@ def _render_split( sv: {"FAIL": 0, "PASS": 0} for sv in split_values } groups[group_key]["Muted"] = 0 + group_muted_seen[group_key] = set() + group_split_seen[group_key] = {sv: set() for sv in split_values} split_val = req.attributes.get(split_field, "") if finding.muted: + # Overview total: count each finding once per framework if index not in muted_count: muted_count.append(index) + # Per-group Muted: count each finding once per group it + # belongs to (a finding can map to several groups). + if index not in group_muted_seen[group_key]: + group_muted_seen[group_key].add(index) groups[group_key]["Muted"] += 1 else: if finding.status == "FAIL" and index not in fail_count: @@ -289,7 +310,8 @@ def _render_split( for sv in split_values: if sv in str(split_val): - if not finding.muted: + if index not in group_split_seen[group_key][sv]: + group_split_seen[group_key][sv].add(index) if finding.status == "FAIL": groups[group_key][sv]["FAIL"] += 1 else: @@ -364,6 +386,7 @@ def _render_scored( risk_field = scoring.risk_field weight_field = scoring.weight_field groups = {} + group_seen = {} pass_count = [] fail_count = [] muted_count = [] @@ -388,6 +411,7 @@ def _render_scored( if group_key not in groups: groups[group_key] = {"FAIL": 0, "PASS": 0, "Muted": 0} + group_seen[group_key] = set() score_per_group[group_key] = 0 max_score_per_group[group_key] = 0 counted_per_group[group_key] = [] @@ -398,16 +422,26 @@ def _render_scored( max_score_per_group[group_key] += risk * weight counted_per_group[group_key].append(index) + # Overview totals: count each finding once per framework if finding.muted: if index not in muted_count: muted_count.append(index) - groups[group_key]["Muted"] += 1 - else: - if finding.status == "FAIL" and index not in fail_count: + elif finding.status == "FAIL": + if index not in fail_count: fail_count.append(index) - groups[group_key]["FAIL"] += 1 - elif finding.status == "PASS" and index not in pass_count: + elif finding.status == "PASS": + if index not in pass_count: pass_count.append(index) + + # Per-group counts: count each finding once per group it belongs + # to (a finding can map to several groups via several requirements). + if index not in group_seen[group_key]: + group_seen[group_key].add(index) + if finding.muted: + groups[group_key]["Muted"] += 1 + elif finding.status == "FAIL": + groups[group_key]["FAIL"] += 1 + elif finding.status == "PASS": groups[group_key]["PASS"] += 1 if index not in counted_generic and not finding.muted: diff --git a/prowler/lib/outputs/finding.py b/prowler/lib/outputs/finding.py index 950460e4e3..9f772d17c4 100644 --- a/prowler/lib/outputs/finding.py +++ b/prowler/lib/outputs/finding.py @@ -517,6 +517,11 @@ class Finding(BaseModel): check_output, "fixed_version", "" ) + else: + # Dynamic fallback: any external/custom provider + provider_data = provider.get_finding_output_data(check_output) + output_data.update(provider_data) + # check_output Unique ID # TODO: move this to a function # TODO: in Azure, GCP and K8s there are findings without resource_name diff --git a/prowler/lib/outputs/html/html.py b/prowler/lib/outputs/html/html.py index 33f7ef4934..545bcbc456 100644 --- a/prowler/lib/outputs/html/html.py +++ b/prowler/lib/outputs/html/html.py @@ -73,8 +73,7 @@ class HTML(Output): elif finding.status == "FAIL": row_class = "table-danger" - self._data.append( - f""" + self._data.append(f""" {finding_status} {finding.metadata.Severity.value} @@ -89,8 +88,7 @@ class HTML(Output):

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

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

- """ - ) + """) except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" @@ -143,8 +141,7 @@ class HTML(Output): from_cli (bool): whether the request is from the CLI or not """ try: - file_descriptor.write( - f""" + file_descriptor.write(f""" @@ -253,8 +250,7 @@ class HTML(Output): Compliance - """ - ) + """) except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" @@ -269,8 +265,7 @@ class HTML(Output): file_descriptor (file): the file descriptor to write the footer """ try: - file_descriptor.write( - """ + file_descriptor.write(""" @@ -409,8 +404,7 @@ class HTML(Output): -""" - ) +""") except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" @@ -1608,11 +1602,13 @@ class HTML(Output): # Azure_provider --> azure # Kubernetes_provider --> kubernetes - # Dynamically get the Provider quick inventory handler - provider_html_assessment_summary_function = ( - f"get_{provider.type}_assessment_summary" - ) - return getattr(HTML, provider_html_assessment_summary_function)(provider) + # Try static method first, fall back to provider method + method_name = f"get_{provider.type}_assessment_summary" + if hasattr(HTML, method_name): + return getattr(HTML, method_name)(provider) + else: + # Dynamic fallback: any external/custom provider + return provider.get_html_assessment_summary() except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" diff --git a/prowler/lib/outputs/jira/jira.py b/prowler/lib/outputs/jira/jira.py index ed8f7faab0..9005b5274e 100644 --- a/prowler/lib/outputs/jira/jira.py +++ b/prowler/lib/outputs/jira/jira.py @@ -229,7 +229,9 @@ class MarkdownToADFConverter: return node def _paragraph_with_text(self, text: str) -> Dict: - return {"type": "paragraph", "content": [self._create_text_node(text, None)]} + # ADF forbids empty text nodes; emit an empty paragraph instead. + content = [self._create_text_node(text, None)] if text else [] + return {"type": "paragraph", "content": content} @staticmethod def _pop_mark(marks_stack: List[Dict], mark_type: str) -> None: @@ -339,6 +341,7 @@ class Jira: } TOKEN_URL = "https://auth.atlassian.com/oauth/token" API_TOKEN_URL = "https://api.atlassian.com/oauth/token/accessible-resources" + REQUEST_TIMEOUT = 90 HEADER_TEMPLATE = { "Content-Type": "application/json", "X-Force-Accept-Language": "true", @@ -576,7 +579,12 @@ class Jira: } headers = self.get_headers(content_type_json=True) - response = requests.post(self.TOKEN_URL, json=body, headers=headers) + response = requests.post( + self.TOKEN_URL, + json=body, + headers=headers, + timeout=self.REQUEST_TIMEOUT, + ) if response.status_code == 200: tokens = response.json() @@ -628,12 +636,17 @@ class Jira: response = requests.get( f"https://{domain}.atlassian.net/_edge/tenant_info", headers=headers, + timeout=self.REQUEST_TIMEOUT, ) response = response.json() return response.get("cloudId") else: headers = self.get_headers(access_token) - response = requests.get(self.API_TOKEN_URL, headers=headers) + response = requests.get( + self.API_TOKEN_URL, + headers=headers, + timeout=self.REQUEST_TIMEOUT, + ) if response.status_code == 200: resources = response.json() @@ -715,7 +728,12 @@ class Jira: } headers = self.get_headers(content_type_json=True) - response = requests.post(url, json=body, headers=headers) + response = requests.post( + url, + json=body, + headers=headers, + timeout=self.REQUEST_TIMEOUT, + ) if response.status_code == 200: tokens = response.json() @@ -872,6 +890,7 @@ class Jira: response = requests.get( f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/3/project", headers=headers, + timeout=self.REQUEST_TIMEOUT, ) if response.status_code == 200: @@ -939,6 +958,7 @@ class Jira: response = requests.get( f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/3/issue/createmeta?projectKeys={project_key}&expand=projects.issuetypes.fields", headers=headers, + timeout=self.REQUEST_TIMEOUT, ) if response.status_code == 200: @@ -984,6 +1004,7 @@ class Jira: response = requests.get( f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/3/project", headers=headers, + timeout=self.REQUEST_TIMEOUT, ) if response.status_code == 200: projects_data = {} @@ -999,6 +1020,7 @@ class Jira: project_response = requests.get( f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/3/issue/createmeta?projectKeys={project['key']}&expand=projects.issuetypes.fields", headers=headers, + timeout=self.REQUEST_TIMEOUT, ) if project_response.status_code == 200: project_metadata = project_response.json() @@ -1118,6 +1140,18 @@ class Jira: 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 "-" + + check_id = _safe(check_id) + check_title = _safe(check_title) + status_extended = _safe(status_extended) + provider = _safe(provider) + region = _safe(region) + resource_uid = _safe(resource_uid) + resource_name = _safe(resource_name) + table_rows = [ { "type": "tableRow", @@ -1909,6 +1943,7 @@ class Jira: f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/3/issue", json=payload, headers=headers, + timeout=self.REQUEST_TIMEOUT, ) if response.status_code != 201: @@ -2113,6 +2148,7 @@ class Jira: f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/3/issue", json=payload, headers=headers, + timeout=self.REQUEST_TIMEOUT, ) if response.status_code != 201: diff --git a/prowler/lib/outputs/ocsf/ocsf.py b/prowler/lib/outputs/ocsf/ocsf.py index 731d029284..53f27d0e1b 100644 --- a/prowler/lib/outputs/ocsf/ocsf.py +++ b/prowler/lib/outputs/ocsf/ocsf.py @@ -227,6 +227,10 @@ class OCSF(Output): json_output = finding.json(exclude_none=True, indent=4) self._file_descriptor.write(json_output) self._file_descriptor.write(",") + except OSError: + # I/O errors (e.g. ENOSPC) are not recoverable per finding: + # fail fast instead of logging once per finding. + raise except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" @@ -239,6 +243,10 @@ class OCSF(Output): self._file_descriptor.truncate() self._file_descriptor.write("]") self._file_descriptor.close() + except OSError: + # Propagate unrecoverable I/O errors (e.g. ENOSPC) so the caller can + # fail fast instead of producing a corrupt output file. + raise except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" diff --git a/prowler/lib/outputs/outputs.py b/prowler/lib/outputs/outputs.py index e4d935e3cf..a1f37a9dfc 100644 --- a/prowler/lib/outputs/outputs.py +++ b/prowler/lib/outputs/outputs.py @@ -7,45 +7,52 @@ from prowler.lib.outputs.common import Status from prowler.lib.outputs.finding import Finding -def stdout_report(finding, color, verbose, status, fix): +def stdout_report(finding, color, verbose, status, fix, provider=None): if finding.check_metadata.Provider == "aws": details = finding.region - if finding.check_metadata.Provider == "azure": + elif finding.check_metadata.Provider == "azure": details = finding.location - if finding.check_metadata.Provider == "gcp": + elif finding.check_metadata.Provider == "gcp": details = finding.location.lower() - if finding.check_metadata.Provider == "kubernetes": + elif finding.check_metadata.Provider == "kubernetes": details = finding.namespace.lower() - if finding.check_metadata.Provider == "github": + elif finding.check_metadata.Provider == "github": details = finding.owner - if finding.check_metadata.Provider == "m365": + elif finding.check_metadata.Provider == "m365": details = finding.location - if finding.check_metadata.Provider == "mongodbatlas": + elif finding.check_metadata.Provider == "mongodbatlas": details = finding.location - if finding.check_metadata.Provider == "nhn": + elif finding.check_metadata.Provider == "nhn": details = finding.location - if finding.check_metadata.Provider == "stackit": + elif finding.check_metadata.Provider == "stackit": details = finding.location - if finding.check_metadata.Provider == "llm": + elif finding.check_metadata.Provider == "llm": details = finding.check_metadata.CheckID - if finding.check_metadata.Provider == "iac": + elif finding.check_metadata.Provider == "iac": details = finding.check_metadata.CheckID - if finding.check_metadata.Provider == "oraclecloud": + elif finding.check_metadata.Provider == "oraclecloud": details = finding.region - if finding.check_metadata.Provider == "alibabacloud": + elif finding.check_metadata.Provider == "alibabacloud": details = finding.region - if finding.check_metadata.Provider == "openstack": + elif finding.check_metadata.Provider == "openstack": details = finding.region - if finding.check_metadata.Provider == "cloudflare": + elif finding.check_metadata.Provider == "cloudflare": details = finding.zone_name - if finding.check_metadata.Provider == "googleworkspace": + elif finding.check_metadata.Provider == "googleworkspace": details = finding.location - if finding.check_metadata.Provider == "vercel": + elif finding.check_metadata.Provider == "vercel": details = finding.region - if finding.check_metadata.Provider == "okta": + elif finding.check_metadata.Provider == "okta": details = finding.region - if finding.check_metadata.Provider == "scaleway": + elif finding.check_metadata.Provider == "scaleway": details = finding.region + else: + # Dynamic fallback: any external/custom provider + if provider is None: + from prowler.providers.common.provider import Provider + + provider = Provider.get_global_provider() + details = provider.get_stdout_detail(finding) if (verbose or fix) and (not status or finding.status in status): if finding.muted: @@ -65,12 +72,15 @@ def report(check_findings, provider, output_options): if hasattr(output_options, "verbose"): verbose = output_options.verbose if check_findings: - # TO-DO Generic Function if provider.type == "aws": check_findings.sort(key=lambda x: x.region) - - if provider.type == "azure": + elif provider.type == "azure": check_findings.sort(key=lambda x: x.subscription) + else: + # Dynamic fallback: any external/custom provider + sort_key = provider.get_finding_sort_key() + if sort_key and isinstance(sort_key, str): + check_findings.sort(key=lambda x: getattr(x, sort_key, "")) for finding in check_findings: # Print findings by stdout @@ -81,12 +91,16 @@ def report(check_findings, provider, output_options): if hasattr(output_options, "fixer"): fixer = output_options.fixer color = set_report_color(finding.status, finding.muted) + # Pass the local `provider` through so the dynamic else inside + # `stdout_report` does not have to consult the global singleton + # — defeating the whole purpose of the new parameter. stdout_report( finding, color, verbose, status, fixer, + provider=provider, ) else: # No service resources in the whole account diff --git a/prowler/lib/outputs/summary_table.py b/prowler/lib/outputs/summary_table.py index e76728ccc8..43d4a547c1 100644 --- a/prowler/lib/outputs/summary_table.py +++ b/prowler/lib/outputs/summary_table.py @@ -121,6 +121,9 @@ def display_summary_table( elif provider.type == "scaleway": entity_type = "Organization" audited_entities = provider.identity.organization_id + else: + # Dynamic fallback: any external/custom provider + entity_type, audited_entities = provider.get_summary_entity() # Check if there are findings and that they are not all MANUAL if findings and not all(finding.status == "MANUAL" for finding in findings): diff --git a/prowler/lib/scan/scan.py b/prowler/lib/scan/scan.py index 2ce7263e2b..4bef660d33 100644 --- a/prowler/lib/scan/scan.py +++ b/prowler/lib/scan/scan.py @@ -4,8 +4,8 @@ from types import SimpleNamespace from typing import Generator from prowler.lib.check.check import ( + _resolve_check_module, execute, - import_check, list_services, update_audit_metadata, ) @@ -426,9 +426,14 @@ class Scan: # Recover service from check name service = get_service_name_from_check_name(check_name) try: - # Import check module - check_module_path = f"prowler.providers.{self._provider.type}.services.{service}.{check_name}.{check_name}" - lib = import_check(check_module_path) + # Import check module (built-in or entry point) — + # delegates to `_resolve_check_module` so external + # providers registered via entry points are resolved + # correctly (their checks do not live under + # `prowler.providers.{type}.services...`). + lib = _resolve_check_module( + self._provider.type, service, check_name + ) # Recover functions from check check_to_execute = getattr(lib, check_name) check = check_to_execute() diff --git a/prowler/providers/aws/aws_regions_by_service.json b/prowler/providers/aws/aws_regions_by_service.json index 2e451910fb..0874d14a07 100644 --- a/prowler/providers/aws/aws_regions_by_service.json +++ b/prowler/providers/aws/aws_regions_by_service.json @@ -2582,6 +2582,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -2591,6 +2592,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", @@ -2604,6 +2608,7 @@ "il-central-1", "me-central-1", "me-south-1", + "mx-central-1", "sa-east-1", "us-east-1", "us-east-2", @@ -7344,6 +7349,7 @@ "lightsail": { "regions": { "aws": [ + "ap-east-1", "ap-northeast-1", "ap-northeast-2", "ap-south-1", @@ -7354,9 +7360,11 @@ "ca-central-1", "eu-central-1", "eu-north-1", + "eu-south-2", "eu-west-1", "eu-west-2", "eu-west-3", + "sa-east-1", "us-east-1", "us-east-2", "us-west-2" @@ -8269,7 +8277,9 @@ "cn-north-1", "cn-northwest-1" ], - "aws-eusc": [], + "aws-eusc": [ + "eusc-de-east-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -9220,6 +9230,7 @@ "eu-west-1", "eu-west-2", "eu-west-3", + "sa-east-1", "us-east-1", "us-east-2", "us-west-2" @@ -9986,6 +9997,8 @@ "ap-south-1", "ap-southeast-1", "ap-southeast-2", + "ap-southeast-5", + "ap-southeast-7", "ca-central-1", "eu-central-1", "eu-south-2", diff --git a/prowler/providers/aws/lib/ip_ranges/__init__.py b/prowler/providers/aws/lib/ip_ranges/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/lib/ip_ranges/ip_ranges.py b/prowler/providers/aws/lib/ip_ranges/ip_ranges.py new file mode 100644 index 0000000000..3dbb9d1b17 --- /dev/null +++ b/prowler/providers/aws/lib/ip_ranges/ip_ranges.py @@ -0,0 +1,51 @@ +import json +import urllib.error +import urllib.request +from ipaddress import ip_network + +from prowler.lib.logger import logger + +AWS_IP_RANGES_URL = "https://ip-ranges.amazonaws.com/ip-ranges.json" +AWS_IP_RANGES_TIMEOUT = 10 + + +def get_public_ip_networks() -> list: + """Fetch the AWS public IP prefixes as a list of ip_network objects. + + The request verifies the server certificate against the system trust store, + matching urllib's default behaviour. This replaces the unmaintained + awsipranges package, whose latest release (0.3.3) calls + urllib.request.urlopen() with the cafile/capath arguments that Python 3.13 + removed. + + Returns an empty list when the feed cannot be fetched or parsed, and skips + individual malformed prefixes, so a transient or corrupt feed never aborts + the calling check. + """ + try: + with urllib.request.urlopen( + AWS_IP_RANGES_URL, timeout=AWS_IP_RANGES_TIMEOUT + ) as response: + ranges = json.loads(response.read()) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return [] + + networks = [] + for key, prefixes in ( + ("ip_prefix", ranges.get("prefixes", [])), + ("ipv6_prefix", ranges.get("ipv6_prefixes", [])), + ): + for prefix in prefixes: + cidr = prefix.get(key) + if not cidr: + continue + try: + networks.append(ip_network(cidr)) + except ValueError as error: + logger.warning( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return networks diff --git a/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/__init__.py b/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege.metadata.json b/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege.metadata.json new file mode 100644 index 0000000000..7625b6a626 --- /dev/null +++ b/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "aws", + "CheckID": "bedrock_agent_role_least_privilege", + "CheckTitle": "Amazon Bedrock agent execution role follows least privilege", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices/Runtime Behavior Analysis", + "TTPs/Privilege Escalation" + ], + "ServiceName": "bedrock", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "Other", + "ResourceGroup": "ai_ml", + "Description": "**Bedrock Agent** execution roles (`agentResourceRoleArn`) should grant only the minimum permissions the agent needs. The evaluation FAILs when the role has an AWS-managed `*FullAccess` policy attached, has an inline statement allowing broad actions on `Resource: \"*\"`, or has no permissions boundary configured.", + "Risk": "An overly permissive **Bedrock Agent** execution role turns a successful **prompt injection** into AWS privilege escalation. A model coerced into calling tools can invoke any API the role allows — reading secrets, modifying IAM, exfiltrating data from S3, or pivoting laterally. **Least privilege** plus a **permissions boundary** keeps the blast radius bounded even when guardrails fail.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/bedrock/latest/userguide/agents-permissions.html", + "https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html", + "https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#grant-least-privilege" + ], + "Remediation": { + "Code": { + "CLI": "aws iam put-role-permissions-boundary --role-name --permissions-boundary ", + "NativeIaC": "", + "Other": "1. Identify the Bedrock Agent's execution role (agentResourceRoleArn) in the IAM console\n2. Detach any AWS-managed *FullAccess policies (e.g. AmazonBedrockFullAccess, AdministratorAccess)\n3. Replace inline policies that use Resource: \"*\" with statements scoped to specific resource ARNs and minimal action sets\n4. Attach a permissions boundary that caps what the role can ever do, even if a future policy is added\n5. Re-run Prowler to confirm the check passes", + "Terraform": "```hcl\nresource \"aws_iam_role\" \"bedrock_agent\" {\n name = \"\"\n assume_role_policy = data.aws_iam_policy_document.trust.json\n permissions_boundary = aws_iam_policy.bedrock_agent_boundary.arn # CRITICAL: caps maximum privileges\n}\n\nresource \"aws_iam_role_policy\" \"bedrock_agent_inline\" {\n role = aws_iam_role.bedrock_agent.name\n policy = jsonencode({\n Version = \"2012-10-17\",\n Statement = [{\n Effect = \"Allow\",\n Action = [\"s3:GetObject\"], # CRITICAL: narrow action\n Resource = [\"arn:aws:s3:::my-rag-bucket/*\"] # CRITICAL: narrow resource\n }]\n })\n}\n```" + }, + "Recommendation": { + "Text": "Apply **least privilege** to every Bedrock Agent execution role: scope `Action` and `Resource` to exactly what the agent needs, avoid AWS-managed `*FullAccess` policies, and always attach a **permissions boundary** so that future policy edits cannot exceed an approved ceiling. Treat agent roles as high-risk because prompt injection can weaponize any granted permission.", + "Url": "https://hub.prowler.com/check/bedrock_agent_role_least_privilege" + } + }, + "Categories": [ + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} 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 new file mode 100644 index 0000000000..e53183d90f --- /dev/null +++ b/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege.py @@ -0,0 +1,101 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.bedrock.bedrock_agent_client import ( + bedrock_agent_client, +) +from prowler.providers.aws.services.iam.iam_client import iam_client +from prowler.providers.aws.services.iam.lib.policy import check_admin_access +from prowler.providers.aws.services.iam.lib.privilege_escalation import ( + check_privilege_escalation, +) + + +class bedrock_agent_role_least_privilege(Check): + """Ensure Bedrock Agent execution roles follow least privilege. + + A Bedrock Agent's execution role is evaluated against three criteria: + - No AWS-managed ``*FullAccess`` policy attached. + - No attached or inline policy granting administrative access or known + privilege escalation combinations. + - A permissions boundary is configured on the role. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Run the least-privilege evaluation across all Bedrock Agents. + + 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. + """ + findings = [] + roles_by_arn = {role.arn: role for role in (iam_client.roles or [])} + + for agent in bedrock_agent_client.agents.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=agent) + report.status = "PASS" + report.status_extended = ( + f"Bedrock Agent {agent.name} execution role follows least privilege." + ) + + role = roles_by_arn.get(agent.role_arn) if agent.role_arn else None + if role is None: + report.status = "FAIL" + report.status_extended = ( + f"Bedrock Agent {agent.name} execution role could not be " + f"resolved in IAM and cannot be evaluated for least privilege." + ) + findings.append(report) + continue + + violations = [] + + for policy in role.attached_policies: + policy_arn = policy.get("PolicyArn", "") + policy_name = policy.get("PolicyName") or policy_arn + if policy_arn.startswith( + "arn:aws:iam::aws:policy/" + ) and policy_arn.endswith("FullAccess"): + violations.append( + f"managed policy {policy_name} grants full access" + ) + continue + policy_obj = iam_client.policies.get(policy_arn) + if policy_obj is None or not policy_obj.document: + continue + document = policy_obj.document + if check_admin_access(document): + violations.append( + f"managed policy {policy_name} grants administrative access" + ) + elif check_privilege_escalation(document): + violations.append( + f"managed policy {policy_name} allows privilege escalation" + ) + + for inline_name in role.inline_policies: + policy_obj = iam_client.policies.get(f"{role.arn}:policy/{inline_name}") + if policy_obj is None or not policy_obj.document: + continue + document = policy_obj.document + if check_admin_access(document): + violations.append( + f"inline policy {inline_name} grants administrative access" + ) + elif check_privilege_escalation(document): + violations.append( + f"inline policy {inline_name} allows privilege escalation" + ) + + if not role.permissions_boundary: + violations.append("no permissions boundary configured") + + if violations: + report.status = "FAIL" + report.status_extended = ( + f"Bedrock Agent {agent.name} execution role violates least " + f"privilege: {'; '.join(violations)}." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.metadata.json b/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.metadata.json index f9b8ee0df9..c85279e4a4 100644 --- a/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.metadata.json +++ b/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.metadata.json @@ -1,7 +1,7 @@ { "Provider": "aws", "CheckID": "bedrock_api_key_no_long_term_credentials", - "CheckTitle": "Amazon Bedrock API key is expired", + "CheckTitle": "Amazon Bedrock long-term API key has expired", "CheckType": [ "Software and Configuration Checks/AWS Security Best Practices", "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", @@ -14,23 +14,24 @@ "Severity": "high", "ResourceType": "AwsIamUser", "ResourceGroup": "IAM", - "Description": "**Bedrock API keys** are evaluated for **lifetime** and **expiration**.\n\nThe finding identifies keys that are long-lived, set to expire far in the future, or configured to `never expire`, and distinguishes them from keys that have already expired.", - "Risk": "Long-lived or non-expiring keys enable persistent access if compromised.\n- Confidentiality: unauthorized inference and exposure of prompts/outputs\n- Availability/Cost: uncontrolled usage and spend spikes\n- Integrity: actions can continue without timely revocation or rotation", + "Description": "AWS recommends Amazon Bedrock **long-term API keys** only for **exploration**; production workloads should use **short-term API keys** (session-scoped, valid up to **12 hours**). This check fails for any active long-term Bedrock API key, escalating to `critical` severity when configured to **never expire**. Already-expired keys pass — they can no longer authenticate.", + "Risk": "Long-term Bedrock API keys persist beyond a session until their stored expiration, and keys set to **never expire** grant indefinite access until manually revoked, enabling unauthorized inference, uncontrolled usage and spend, and activity that continues past timely revocation.", "RelatedUrl": "", "AdditionalURLs": [ - "https://docs.aws.amazon.com/ja_jp/bedrock/latest/userguide/getting-started-api-keys.html", - "https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#rotate-credentials", - "https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html" + "https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html", + "https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys-generate.html", + "https://docs.aws.amazon.com/IAM/latest/UserGuide/security-creds-programmatic-access.html#security-creds-alternatives-to-long-term-access-keys", + "https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#rotate-credentials" ], "Remediation": { "Code": { "CLI": "aws iam delete-service-specific-credential --user-name --service-specific-credential-id ", "NativeIaC": "", - "Other": "1. Sign in to the AWS Management Console and open IAM\n2. Go to Users > select > Security credentials\n3. In \"API keys for Amazon Bedrock\", find the non-expired key and click Delete\n4. Confirm deletion to remove the key (removes the long-term credential so the check passes)", + "Other": "1. Sign in to the AWS Management Console and open IAM\n2. Go to Users > select the IAM user backing the Bedrock API key > Security credentials\n3. In \"API keys for Amazon Bedrock\", select the active long-term key and click Delete\n4. For workloads that still need Bedrock access, generate a short-term API key from the Bedrock console (Short-term API keys tab), or call the Bedrock API with short-term credentials issued by AWS STS", "Terraform": "" }, "Recommendation": { - "Text": "Prefer **short-term credentials** and **IAM roles**; avoid `never expire`.\n\nEnforce **least privilege**, strict **rotation**, and automatic **expiration** for any long-term key. Store secrets securely, monitor with audit logs, and revoke unused or stale keys quickly.", + "Text": "Use short-term Amazon Bedrock API keys for any non-exploratory workload — they are bound to the IAM principal's session, valid for at most 12 hours, scoped to a single Region, and can be auto-refreshed by the SDK. For existing long-term keys, delete the underlying IAM service-specific credential. If a long-term key must be retained for an exploration scenario, set an explicit short expiration and never select `never expire`.", "Url": "https://hub.prowler.com/check/bedrock_api_key_no_long_term_credentials" } }, @@ -40,5 +41,5 @@ ], "DependsOn": [], "RelatedTo": [], - "Notes": "This check verifies that Amazon Bedrock API keys have expiration dates set. API keys without expiration dates are considered long-term credentials and pose a security risk. The check follows security best practices for credential management and the principle of least privilege." + "Notes": "AWS recommends against using long-term Amazon Bedrock API keys outside of exploration; production workloads should use short-term API keys (session-scoped, valid up to 12 hours). The IAM `ListServiceSpecificCredentials` API only enumerates long-term keys — short-term keys are session-scoped credentials that never appear here. The check therefore passes only when an existing long-term key has already expired and can no longer authenticate; any active long-term key fails, with critical severity when it is configured to never expire." } diff --git a/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.py b/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.py index 2edffd7ccb..9697ecbff0 100644 --- a/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.py +++ b/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.py @@ -1,49 +1,62 @@ from datetime import datetime, timezone -from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.lib.check.models import Check, Check_Report_AWS, Severity from prowler.providers.aws.services.iam.iam_client import iam_client +# Days threshold above which a Bedrock long-term API key is considered effectively non-expiring. +NEVER_EXPIRES_THRESHOLD_DAYS = 10000 + class bedrock_api_key_no_long_term_credentials(Check): - """ - Bedrock API keys should be short-lived to reduce the risk of unauthorized access. - This check verifies if there are any long-term Bedrock API keys. - If there are, it checks if they are expired or will be expired. - If they are expired, it will be marked as PASS. - If they are not expired, it will be marked as FAIL and the severity will be critical if the key will never expire. + """Amazon Bedrock long-term API keys should not be used outside of exploration. + + AWS recommends short-term Bedrock API keys (session-scoped, valid up to 12 hours) + for any non-exploratory workload. ``ListServiceSpecificCredentials`` only enumerates + long-term keys, so every key inspected here is by definition a long-term credential. + + PASS when the long-term key has already expired (it can no longer authenticate). + FAIL (critical) when the key is configured to never expire. + FAIL (high) for any other active long-term key. """ def execute(self): - """ - Execute the Bedrock API key no long-term credentials check. - - Iterate over all the Bedrock API keys and check if they are expired or will be expired. - - Returns: - List[Check_Report_AWS]: A list of report objects with the results of the check. - """ - findings = [] for api_key in iam_client.service_specific_credentials: if api_key.service_name != "bedrock.amazonaws.com": continue - if api_key.expiration_date: - report = Check_Report_AWS(metadata=self.metadata(), resource=api_key) - # Check if the expiration date is in the future - if api_key.expiration_date > datetime.now(timezone.utc): - report.status = "FAIL" - # Get the days until the expiration date - days_until_expiration = ( - api_key.expiration_date - datetime.now(timezone.utc) - ).days - if days_until_expiration > 10000: - self.Severity = "critical" - report.status_extended = f"Long-term Bedrock API key {api_key.id} in user {api_key.user.name} exists and never expires." - else: - report.status_extended = f"Long-term Bedrock API key {api_key.id} in user {api_key.user.name} exists and will expire in {days_until_expiration} days." - else: - report.status = "PASS" - report.status_extended = f"Long-term Bedrock API key {api_key.id} in user {api_key.user.name} exists but has expired." - findings.append(report) + if not api_key.expiration_date: + continue + + report = Check_Report_AWS(metadata=self.metadata(), resource=api_key) + now = datetime.now(timezone.utc) + + if api_key.expiration_date <= now: + report.status = "PASS" + report.status_extended = ( + f"Bedrock long-term API key {api_key.id} in user " + f"{api_key.user.name} has already expired and can no longer " + f"authenticate." + ) + elif (api_key.expiration_date - now).days > NEVER_EXPIRES_THRESHOLD_DAYS: + report.status = "FAIL" + report.check_metadata.Severity = Severity.critical + report.status_extended = ( + f"Bedrock long-term API key {api_key.id} in user " + f"{api_key.user.name} is configured to never expire. Use " + f"short-term Bedrock API keys (session-scoped, valid up to " + f"12 hours) for non-exploratory workloads instead." + ) + else: + days_until_expiration = (api_key.expiration_date - now).days + report.status = "FAIL" + report.status_extended = ( + f"Bedrock long-term API key {api_key.id} in user " + f"{api_key.user.name} is active and will expire in " + f"{days_until_expiration} days. Use short-term Bedrock API " + f"keys (session-scoped, valid up to 12 hours) for " + f"non-exploratory workloads instead." + ) + + 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 7222456341..aead63a67f 100644 --- a/prowler/providers/aws/services/bedrock/bedrock_service.py +++ b/prowler/providers/aws/services/bedrock/bedrock_service.py @@ -146,6 +146,7 @@ class BedrockAgent(AWSService): self.prompts = {} self.prompt_scanned_regions: set = set() self.__threading_call__(self._list_agents) + self.__threading_call__(self._get_agent, self.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()) @@ -174,6 +175,22 @@ class BedrockAgent(AWSService): f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _get_agent(self, agent): + """Fetch full agent details to capture the execution role ARN. + + list_agents only returns summaries (no agentResourceRoleArn), so we + need a per-agent GetAgent call. Stored on the Agent model for use by + checks like bedrock_agent_role_least_privilege. + """ + logger.info("Bedrock Agent - Getting Agent...") + try: + agent_info = self.regional_clients[agent.region].get_agent(agentId=agent.id) + agent.role_arn = agent_info.get("agent", {}).get("agentResourceRoleArn") + except Exception as error: + 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...") @@ -236,6 +253,7 @@ class Agent(BaseModel): name: str arn: str guardrail_id: Optional[str] = None + role_arn: Optional[str] = None region: str tags: Optional[list] = [] diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.metadata.json b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.metadata.json index 20ed5f0adb..9070c7b0c7 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.metadata.json +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.metadata.json @@ -17,9 +17,8 @@ "RelatedUrl": "", "AdditionalURLs": [ "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html", - "https://www.clouddefense.ai/compliance-rules/cis-v130/monitoring/cis-v130-4-11", "https://support.icompaas.com/support/solutions/articles/62000084031-ensure-a-log-metric-filter-and-alarm-exist-for-changes-to-network-access-control-lists-nacl-", - "https://trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/aws/CloudWatchLogs/network-acl-changes-alarm.html", + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/aws/CloudWatchLogs/network-acl-changes-alarm.html", "https://support.icompaas.com/support/solutions/articles/62000233134-4-11-ensure-network-access-control-list-nacl-changes-are-monitored-manual-" ], "Remediation": { 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 20d68a0121..68f45a8d27 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 @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,16 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_changes_to_network_acls_alarm_configured(Check): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?CreateNetworkAcl.+\$\.eventName\s*=\s*.?CreateNetworkAclEntry.+\$\.eventName\s*=\s*.?DeleteNetworkAcl.+\$\.eventName\s*=\s*.?DeleteNetworkAclEntry.+\$\.eventName\s*=\s*.?ReplaceNetworkAclEntry.+\$\.eventName\s*=\s*.?ReplaceNetworkAclAssociation.?" + pattern = build_metric_filter_pattern( + event_names=[ + "CreateNetworkAcl", + "CreateNetworkAclEntry", + "DeleteNetworkAcl", + "DeleteNetworkAclEntry", + "ReplaceNetworkAclEntry", + "ReplaceNetworkAclAssociation", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( 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 5f6eda3973..f7bf8e1d22 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 @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,16 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_changes_to_network_gateways_alarm_configured(Check): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?CreateCustomerGateway.+\$\.eventName\s*=\s*.?DeleteCustomerGateway.+\$\.eventName\s*=\s*.?AttachInternetGateway.+\$\.eventName\s*=\s*.?CreateInternetGateway.+\$\.eventName\s*=\s*.?DeleteInternetGateway.+\$\.eventName\s*=\s*.?DetachInternetGateway.?" + pattern = build_metric_filter_pattern( + event_names=[ + "CreateCustomerGateway", + "DeleteCustomerGateway", + "AttachInternetGateway", + "CreateInternetGateway", + "DeleteInternetGateway", + "DetachInternetGateway", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.metadata.json b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.metadata.json index 82490a63da..89949cfbd6 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.metadata.json +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.metadata.json @@ -37,5 +37,5 @@ ], "DependsOn": [], "RelatedTo": [], - "Notes": "" + "Notes": "Logging and Monitoring" } 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 f8fcc8eacb..460765cb2f 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 @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,18 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_changes_to_network_route_tables_alarm_configured(Check): def execute(self): - pattern = r"\$\.eventSource\s*=\s*.?ec2.amazonaws.com.+\$\.eventName\s*=\s*.?CreateRoute.+\$\.eventName\s*=\s*.?CreateRouteTable.+\$\.eventName\s*=\s*.?ReplaceRoute.+\$\.eventName\s*=\s*.?ReplaceRouteTableAssociation.+\$\.eventName\s*=\s*.?DeleteRouteTable.+\$\.eventName\s*=\s*.?DeleteRoute.+\$\.eventName\s*=\s*.?DisassociateRouteTable.?" + pattern = build_metric_filter_pattern( + event_source="ec2.amazonaws.com", + event_names=[ + "CreateRoute", + "CreateRouteTable", + "ReplaceRoute", + "ReplaceRouteTableAssociation", + "DeleteRouteTable", + "DeleteRoute", + "DisassociateRouteTable", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( 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 d7606647c4..be4fb0859d 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 @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,21 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_changes_to_vpcs_alarm_configured(Check): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?CreateVpc.+\$\.eventName\s*=\s*.?DeleteVpc.+\$\.eventName\s*=\s*.?ModifyVpcAttribute.+\$\.eventName\s*=\s*.?AcceptVpcPeeringConnection.+\$\.eventName\s*=\s*.?CreateVpcPeeringConnection.+\$\.eventName\s*=\s*.?DeleteVpcPeeringConnection.+\$\.eventName\s*=\s*.?RejectVpcPeeringConnection.+\$\.eventName\s*=\s*.?AttachClassicLinkVpc.+\$\.eventName\s*=\s*.?DetachClassicLinkVpc.+\$\.eventName\s*=\s*.?DisableVpcClassicLink.+\$\.eventName\s*=\s*.?EnableVpcClassicLink.?" + pattern = build_metric_filter_pattern( + event_names=[ + "CreateVpc", + "DeleteVpc", + "ModifyVpcAttribute", + "AcceptVpcPeeringConnection", + "CreateVpcPeeringConnection", + "DeleteVpcPeeringConnection", + "RejectVpcPeeringConnection", + "AttachClassicLinkVpc", + "DetachClassicLinkVpc", + "DisableVpcClassicLink", + "EnableVpcClassicLink", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( 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 11bf08d99d..49bf9a03a3 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 @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -15,7 +16,15 @@ class cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_change Check ): def execute(self): - pattern = r"\$\.eventSource\s*=\s*.?config.amazonaws.com.+\$\.eventName\s*=\s*.?StopConfigurationRecorder.+\$\.eventName\s*=\s*.?DeleteDeliveryChannel.+\$\.eventName\s*=\s*.?PutDeliveryChannel.+\$\.eventName\s*=\s*.?PutConfigurationRecorder.?" + pattern = build_metric_filter_pattern( + event_source="config.amazonaws.com", + event_names=[ + "StopConfigurationRecorder", + "DeleteDeliveryChannel", + "PutDeliveryChannel", + "PutConfigurationRecorder", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( 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 eb272ecfb1..e9567315f4 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 @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -15,7 +16,15 @@ class cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_change Check ): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?CreateTrail.+\$\.eventName\s*=\s*.?UpdateTrail.+\$\.eventName\s*=\s*.?DeleteTrail.+\$\.eventName\s*=\s*.?StartLogging.+\$\.eventName\s*=\s*.?StopLogging.?" + pattern = build_metric_filter_pattern( + event_names=[ + "CreateTrail", + "UpdateTrail", + "DeleteTrail", + "StartLogging", + "StopLogging", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( 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 c217cbbaf6..1b2e5173bb 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 @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,10 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_authentication_failures(Check): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?ConsoleLogin.+\$\.errorMessage\s*=\s*.?Failed authentication.?" + pattern = build_metric_filter_pattern( + event_names=["ConsoleLogin"], + extra_clauses=[("errorMessage", "=", "Failed authentication")], + ) findings = [] report = check_cloudwatch_log_metric_filter( 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 af7ec82119..9976a885bb 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 @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,32 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_aws_organizations_changes(Check): def execute(self): - pattern = r"\$\.eventSource\s*=\s*.?organizations\.amazonaws\.com.+\$\.eventName\s*=\s*.?AcceptHandshake.+\$\.eventName\s*=\s*.?AttachPolicy.+\$\.eventName\s*=\s*.?CancelHandshake.+\$\.eventName\s*=\s*.?CreateAccount.+\$\.eventName\s*=\s*.?CreateOrganization.+\$\.eventName\s*=\s*.?CreateOrganizationalUnit.+\$\.eventName\s*=\s*.?CreatePolicy.+\$\.eventName\s*=\s*.?DeclineHandshake.+\$\.eventName\s*=\s*.?DeleteOrganization.+\$\.eventName\s*=\s*.?DeleteOrganizationalUnit.+\$\.eventName\s*=\s*.?DeletePolicy.+\$\.eventName\s*=\s*.?EnableAllFeatures.+\$\.eventName\s*=\s*.?EnablePolicyType.+\$\.eventName\s*=\s*.?InviteAccountToOrganization.+\$\.eventName\s*=\s*.?LeaveOrganization.+\$\.eventName\s*=\s*.?DetachPolicy.+\$\.eventName\s*=\s*.?DisablePolicyType.+\$\.eventName\s*=\s*.?MoveAccount.+\$\.eventName\s*=\s*.?RemoveAccountFromOrganization.+\$\.eventName\s*=\s*.?UpdateOrganizationalUnit.+\$\.eventName\s*=\s*.?UpdatePolicy.?" + pattern = build_metric_filter_pattern( + event_source="organizations.amazonaws.com", + event_names=[ + "AcceptHandshake", + "AttachPolicy", + "CancelHandshake", + "CreateAccount", + "CreateOrganization", + "CreateOrganizationalUnit", + "CreatePolicy", + "DeclineHandshake", + "DeleteOrganization", + "DeleteOrganizationalUnit", + "DeletePolicy", + "EnableAllFeatures", + "EnablePolicyType", + "InviteAccountToOrganization", + "LeaveOrganization", + "DetachPolicy", + "DisablePolicyType", + "MoveAccount", + "RemoveAccountFromOrganization", + "UpdateOrganizationalUnit", + "UpdatePolicy", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( 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 4cfed985f7..20d1d62a5a 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 @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,10 @@ 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): - pattern = r"\$\.eventSource\s*=\s*.?kms.amazonaws.com.+\$\.eventName\s*=\s*.?DisableKey.+\$\.eventName\s*=\s*.?ScheduleKeyDeletion.?" + pattern = build_metric_filter_pattern( + event_source="kms.amazonaws.com", + event_names=["DisableKey", "ScheduleKeyDeletion"], + ) findings = [] report = check_cloudwatch_log_metric_filter( 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.metadata.json b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.metadata.json index 2d77c42704..6292de6071 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.metadata.json +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.metadata.json @@ -17,8 +17,7 @@ "RelatedUrl": "", "AdditionalURLs": [ "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html", - "https://support.icompaas.com/support/solutions/articles/62000086674-ensure-a-log-metric-filter-and-alarm-exist-for-s3-bucket-policy-changes", - "https://www.tenable.com/audits/items/CIS_Amazon_Web_Services_Foundations_v5.0.0_L1.audit:8101350d6907e07863ac6748689b3e12" + "https://support.icompaas.com/support/solutions/articles/62000086674-ensure-a-log-metric-filter-and-alarm-exist-for-s3-bucket-policy-changes" ], "Remediation": { "Code": { 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 45d09b3528..d5fe1ef994 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 @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,20 @@ 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): - pattern = r"\$\.eventSource\s*=\s*.?s3.amazonaws.com.+\$\.eventName\s*=\s*.?PutBucketAcl.+\$\.eventName\s*=\s*.?PutBucketPolicy.+\$\.eventName\s*=\s*.?PutBucketCors.+\$\.eventName\s*=\s*.?PutBucketLifecycle.+\$\.eventName\s*=\s*.?PutBucketReplication.+\$\.eventName\s*=\s*.?DeleteBucketPolicy.+\$\.eventName\s*=\s*.?DeleteBucketCors.+\$\.eventName\s*=\s*.?DeleteBucketLifecycle.+\$\.eventName\s*=\s*.?DeleteBucketReplication.?" + pattern = build_metric_filter_pattern( + event_source="s3.amazonaws.com", + event_names=[ + "PutBucketAcl", + "PutBucketPolicy", + "PutBucketCors", + "PutBucketLifecycle", + "PutBucketReplication", + "DeleteBucketPolicy", + "DeleteBucketCors", + "DeleteBucketLifecycle", + "DeleteBucketReplication", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.metadata.json b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.metadata.json index 75cba1ee62..b1c36be2d8 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.metadata.json +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.metadata.json @@ -17,7 +17,6 @@ "RelatedUrl": "", "AdditionalURLs": [ "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html", - "https://www.clouddefense.ai/compliance-rules/cis-v140/monitoring/cis-v140-4-4", "https://www.intelligentdiscovery.io/controls/cloudwatch/cloudwatch-alarm-iam-policy-change" ], "Remediation": { 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 f5efd04dde..4347a9b3aa 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 @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,26 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_policy_changes(Check): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?DeleteGroupPolicy.+\$\.eventName\s*=\s*.?DeleteRolePolicy.+\$\.eventName\s*=\s*.?DeleteUserPolicy.+\$\.eventName\s*=\s*.?PutGroupPolicy.+\$\.eventName\s*=\s*.?PutRolePolicy.+\$\.eventName\s*=\s*.?PutUserPolicy.+\$\.eventName\s*=\s*.?CreatePolicy.+\$\.eventName\s*=\s*.?DeletePolicy.+\$\.eventName\s*=\s*.?CreatePolicyVersion.+\$\.eventName\s*=\s*.?DeletePolicyVersion.+\$\.eventName\s*=\s*.?AttachRolePolicy.+\$\.eventName\s*=\s*.?DetachRolePolicy.+\$\.eventName\s*=\s*.?AttachUserPolicy.+\$\.eventName\s*=\s*.?DetachUserPolicy.+\$\.eventName\s*=\s*.?AttachGroupPolicy.+\$\.eventName\s*=\s*.?DetachGroupPolicy.?" + pattern = build_metric_filter_pattern( + event_names=[ + "DeleteGroupPolicy", + "DeleteRolePolicy", + "DeleteUserPolicy", + "PutGroupPolicy", + "PutRolePolicy", + "PutUserPolicy", + "CreatePolicy", + "DeletePolicy", + "CreatePolicyVersion", + "DeletePolicyVersion", + "AttachRolePolicy", + "DetachRolePolicy", + "AttachUserPolicy", + "DetachUserPolicy", + "AttachGroupPolicy", + "DetachGroupPolicy", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( 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 a7972e420c..3557632904 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 @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,16 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_security_group_changes(Check): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?AuthorizeSecurityGroupIngress.+\$\.eventName\s*=\s*.?AuthorizeSecurityGroupEgress.+\$\.eventName\s*=\s*.?RevokeSecurityGroupIngress.+\$\.eventName\s*=\s*.?RevokeSecurityGroupEgress.+\$\.eventName\s*=\s*.?CreateSecurityGroup.+\$\.eventName\s*=\s*.?DeleteSecurityGroup.?" + pattern = build_metric_filter_pattern( + event_names=[ + "AuthorizeSecurityGroupIngress", + "AuthorizeSecurityGroupEgress", + "RevokeSecurityGroupIngress", + "RevokeSecurityGroupEgress", + "CreateSecurityGroup", + "DeleteSecurityGroup", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.metadata.json b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.metadata.json index 20058c1ed8..76ffd832ff 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.metadata.json +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.metadata.json @@ -21,7 +21,6 @@ "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html", "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/aws/CloudWatchLogs/console-sign-in-without-mfa.html", "https://www.tenable.com/audits/items/CIS_Amazon_Web_Services_Foundations_v3.0.0_L1.audit:1957056ee174cc38502d5f5f1864333b", - "https://www.clouddefense.ai/compliance-rules/gdpr/data-protection/log-metric-filter-console-login-mfa", "https://www.intelligentdiscovery.io/controls/cloudwatch/cloudwatch-alarm-no-mfa", "https://support.icompaas.com/support/solutions/articles/62000083605-ensure-a-log-metric-filter-and-alarm-exist-for-management-console-sign-in-without-mfa" ], 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 8437600646..07475a6185 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 @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,10 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_sign_in_without_mfa(Check): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?ConsoleLogin.+\$\.additionalEventData\.MFAUsed\s*!=\s*.?Yes.?" + pattern = build_metric_filter_pattern( + event_names=["ConsoleLogin"], + extra_clauses=[("additionalEventData.MFAUsed", "!=", "Yes")], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/lib/metric_filters.py b/prowler/providers/aws/services/cloudwatch/lib/metric_filters.py index 84d70b4083..e5d104b840 100644 --- a/prowler/providers/aws/services/cloudwatch/lib/metric_filters.py +++ b/prowler/providers/aws/services/cloudwatch/lib/metric_filters.py @@ -3,6 +3,45 @@ import re from prowler.lib.check.models import Check_Report_AWS +def build_metric_filter_pattern( + *, + event_names: list[str] | None = None, + event_source: str | None = None, + extra_clauses: list[tuple[str, str, str]] | None = None, +) -> str: + """Build a regex pattern to match a CloudWatch Logs filterPattern string. + + All clauses must be present for the pattern to match, regardless of the + order in which AWS stores them. Event names are matched exactly, so a + short name like ``CreateRoute`` will not be satisfied by a longer one + like ``CreateRouteTable``. + + Pass the result directly to ``check_cloudwatch_log_metric_filter``. + + Args: + event_names: AWS API action names to require (``$.eventName``). + event_source: optional service principal to require (``$.eventSource``), + e.g. ``"ec2.amazonaws.com"``. + extra_clauses: additional conditions as ``(field, operator, value)`` + tuples, where ``operator`` is ``"="`` or ``"!="``. Example: + ``("additionalEventData.MFAUsed", "!=", "Yes")``. + + Returns: + A regex string for use with ``re.search(..., flags=re.DOTALL)``. + """ + parts: list[str] = [] + if event_source is not None: + parts.append(rf"(?=.*\$\.eventSource\s*=\s*.?{re.escape(event_source)})") + for name in event_names or []: + parts.append(rf"(?=.*\$\.eventName\s*=\s*.?{re.escape(name)}\b)") + for field, operator, value in extra_clauses or []: + if operator not in ("=", "!="): + raise ValueError(f"unsupported operator {operator!r}; expected '=' or '!='") + op = r"\s*!=\s*" if operator == "!=" else r"\s*=\s*" + parts.append(rf"(?=.*\$\.{re.escape(field)}{op}.?{re.escape(value)})") + return "".join(parts) + + def check_cloudwatch_log_metric_filter( metric_filter_pattern: str, trails: list, diff --git a/prowler/providers/aws/services/codepipeline/codepipeline_project_repo_private/codepipeline_project_repo_private.py b/prowler/providers/aws/services/codepipeline/codepipeline_project_repo_private/codepipeline_project_repo_private.py index 0c745fc658..bc9f569f1b 100644 --- a/prowler/providers/aws/services/codepipeline/codepipeline_project_repo_private/codepipeline_project_repo_private.py +++ b/prowler/providers/aws/services/codepipeline/codepipeline_project_repo_private/codepipeline_project_repo_private.py @@ -7,6 +7,8 @@ from prowler.providers.aws.services.codepipeline.codepipeline_client import ( codepipeline_client, ) +HTTP_TIMEOUT = 30 + class codepipeline_project_repo_private(Check): """Checks if AWS CodePipeline source repositories are configured as private. @@ -87,9 +89,8 @@ class codepipeline_project_repo_private(Check): repo_url = repo_url[:-4] try: - context = ssl._create_unverified_context() req = urllib.request.Request(repo_url, method="HEAD") - response = urllib.request.urlopen(req, context=context) + response = urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) return not response.geturl().endswith("sign_in") - except (urllib.error.HTTPError, urllib.error.URLError): + except (urllib.error.URLError, TimeoutError, ssl.SSLError): return False diff --git a/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/__init__.py b/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions.metadata.json b/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions.metadata.json new file mode 100644 index 0000000000..cbbd7a66dd --- /dev/null +++ b/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions.metadata.json @@ -0,0 +1,44 @@ +{ + "Provider": "aws", + "CheckID": "config_delegated_admin_and_org_aggregator_all_regions", + "CheckTitle": "AWS Config has a delegated administrator and an organization aggregator covering all AWS regions", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" + ], + "ServiceName": "config", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "AwsConfigConfigurationAggregator", + "ResourceGroup": "governance", + "Description": "**AWS Config** has a delegated administrator registered via AWS Organizations and at least one Configuration Aggregator with an OrganizationAggregationSource that covers all AWS regions, ensuring centralized org-wide configuration visibility.", + "Risk": "Without an org-wide **AWS Config** aggregator and a delegated administrator, configuration data is fragmented across accounts and regions, **compliance reporting** is incomplete, and **drift detection** is delayed. Adversaries or misconfigurations can persist in unmonitored accounts, eroding **audit readiness** and **regulatory posture**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/config/latest/developerguide/aggregate-data.html", + "https://docs.aws.amazon.com/config/latest/developerguide/set-up-aggregator-cli.html", + "https://docs.aws.amazon.com/organizations/latest/userguide/services-that-can-integrate-config.html" + ], + "Remediation": { + "Code": { + "CLI": "aws organizations register-delegated-administrator --account-id --service-principal config.amazonaws.com && aws configservice put-configuration-aggregator --configuration-aggregator-name org-aggregator --organization-aggregation-source RoleArn=,AllAwsRegions=true", + "NativeIaC": "", + "Other": "1. From the AWS Organizations management account, register the delegated administrator for config.amazonaws.com\n2. In the delegated admin account, open AWS Config\n3. Create a Configuration Aggregator and select Add my organization as the source\n4. Enable Include all AWS Regions\n5. Confirm an IAM role with AWSConfigRoleForOrganizations is attached\n6. Verify the aggregator status reaches SUCCEEDED for all member accounts", + "Terraform": "" + }, + "Recommendation": { + "Text": "Register a **delegated administrator** for AWS Config via AWS Organizations and create at least one **Configuration Aggregator** with an OrganizationAggregationSource that covers **all AWS regions**. This centralizes configuration data across the organization for unified compliance and audit reporting.", + "Url": "https://hub.prowler.com/check/config_delegated_admin_and_org_aggregator_all_regions" + } + }, + "Categories": [ + "forensics-ready" + ], + "DependsOn": [], + "RelatedTo": [ + "config_recorder_all_regions_enabled", + "guardduty_delegated_admin_enabled_all_regions" + ], + "Notes": "This check requires execution from the organization management account or delegated administrator account to access organization-level APIs." +} diff --git a/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions.py b/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions.py new file mode 100644 index 0000000000..f56ea191f8 --- /dev/null +++ b/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions.py @@ -0,0 +1,115 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.config.config_client import config_client +from prowler.providers.aws.services.config.config_service import Aggregator + + +class config_delegated_admin_and_org_aggregator_all_regions(Check): + """Ensure AWS Config has a delegated admin and an org aggregator covering all regions. + + This check verifies that: + 1. A delegated administrator is registered for the config.amazonaws.com + service principal via AWS Organizations. + 2. At least one AWS Config Configuration Aggregator exists with an + OrganizationAggregationSource that covers all AWS regions + (AllAwsRegions=true). + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the check logic. + + Returns: + A list of reports containing the result of the check. One finding per + aggregator-region, or a single synthetic FAIL when no aggregators + exist in any region. + """ + findings = [] + + has_delegated_admin = ( + bool(config_client.delegated_administrators) + and not config_client.delegated_administrators_lookup_failed + ) + delegated_admin_unknown = config_client.delegated_administrators_lookup_failed + + # No aggregators in any region: emit one synthetic FAIL anchored to the + # audited account in the default region. + if not config_client.aggregators: + synthetic = Aggregator( + name="unknown", + arn=config_client.get_unknown_arn( + region=config_client.region, + resource_type="config-aggregator", + ), + region=config_client.region, + all_aws_regions=False, + aws_regions=None, + organization_aggregation_source_present=False, + ) + report = Check_Report_AWS(metadata=self.metadata(), resource=synthetic) + if delegated_admin_unknown: + delegated_state = ( + "delegated administrator status could not be determined" + ) + elif has_delegated_admin: + delegated_state = "delegated administrator configured" + else: + delegated_state = ( + "no delegated administrator registered for config.amazonaws.com" + ) + report.status = "FAIL" + report.status_extended = ( + f"AWS Config has no Organization Aggregator configured in any " + f"region ({delegated_state})." + ) + findings.append(report) + return findings + + for region, aggregators_in_region in config_client.aggregators.items(): + for aggregator in aggregators_in_region: + report = Check_Report_AWS(metadata=self.metadata(), resource=aggregator) + + org_aware = aggregator.organization_aggregation_source_present + covers_all = aggregator.all_aws_regions + + issues = [] + if delegated_admin_unknown: + issues.append( + "delegated administrator status for config.amazonaws.com " + "could not be determined" + ) + elif not has_delegated_admin: + issues.append( + "no delegated administrator registered for config.amazonaws.com" + ) + if not org_aware: + issues.append( + f"aggregator {aggregator.name} is not an organization aggregator" + ) + elif not covers_all: + issues.append( + f"aggregator {aggregator.name} does not cover all AWS regions" + ) + + if issues: + report.status = "FAIL" + report.status_extended = ( + f"AWS Config aggregator {aggregator.name} in region " + f"{region} has issues: {', '.join(issues)}." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"AWS Config aggregator {aggregator.name} in region " + f"{region} is an organization aggregator covering all " + f"AWS regions with delegated admin configured." + ) + + # Support muting non-default regions if configured + if report.status == "FAIL" and ( + config_client.audit_config.get("mute_non_default_regions", False) + and region != config_client.region + ): + report.muted = True + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/config/config_service.py b/prowler/providers/aws/services/config/config_service.py index 443cee2233..85ab22fd8e 100644 --- a/prowler/providers/aws/services/config/config_service.py +++ b/prowler/providers/aws/services/config/config_service.py @@ -1,5 +1,6 @@ from typing import Optional +from botocore.client import ClientError from pydantic.v1 import BaseModel from prowler.lib.logger import logger @@ -12,10 +13,16 @@ class Config(AWSService): # Call AWSService's __init__ super().__init__(__class__.__name__, provider) self.recorders = {} + self.aggregators: dict[str, list] = {} + self.delegated_administrators: list = [] + self.delegated_administrators_lookup_failed: bool = False self.__threading_call__(self.describe_configuration_recorders) self.__threading_call__( self._describe_configuration_recorder_status, self.recorders.values() ) + self.__threading_call__(self._describe_configuration_aggregators) + # Organizations API is not regional; single call. + self._list_config_delegated_administrators() def _get_recorder_arn_template(self, region): return f"arn:{self.audited_partition}:config:{region}:{self.audited_account}:recorder" @@ -73,6 +80,108 @@ class Config(AWSService): f"{recorder.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _describe_configuration_aggregators(self, regional_client): + """Describe AWS Config configuration aggregators per region. + + An aggregator counts as organization-aware when its + OrganizationAggregationSource key is present in the response. + """ + logger.info("Config - Describing Configuration Aggregators...") + try: + paginator = regional_client.get_paginator( + "describe_configuration_aggregators" + ) + region_aggregators: list = [] + for page in paginator.paginate(): + for aggregator in page.get("ConfigurationAggregators", []): + name = aggregator.get("ConfigurationAggregatorName", "") + arn = aggregator.get("ConfigurationAggregatorArn", "") + org_source = aggregator.get("OrganizationAggregationSource") + org_aware = org_source is not None + all_aws_regions = False + aws_regions: Optional[list] = None + if org_aware: + all_aws_regions = org_source.get("AllAwsRegions", False) + aws_regions = org_source.get("AwsRegions") + if not self.audit_resources or ( + is_resource_filtered(arn, self.audit_resources) + ): + region_aggregators.append( + Aggregator( + name=name, + arn=arn, + region=regional_client.region, + all_aws_regions=all_aws_regions, + aws_regions=aws_regions, + organization_aggregation_source_present=org_aware, + ) + ) + if region_aggregators: + self.aggregators[regional_client.region] = region_aggregators + except ClientError as error: + if error.response["Error"]["Code"] in ( + "AccessDeniedException", + "AccessDenied", + ): + 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: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _list_config_delegated_administrators(self): + """List delegated administrators for the AWS Config service principal. + + Uses the Organizations API directly (not regional). Sets + delegated_administrators_lookup_failed to True on AccessDenied so callers + can surface the unknown delegated-admin state in findings. + """ + logger.info( + "Config - Listing delegated administrators for config.amazonaws.com..." + ) + try: + org_client = self.session.client("organizations") + paginator = org_client.get_paginator("list_delegated_administrators") + for page in paginator.paginate(ServicePrincipal="config.amazonaws.com"): + for admin in page.get("DelegatedAdministrators", []): + self.delegated_administrators.append( + ConfigDelegatedAdministrator( + id=admin.get("Id", ""), + arn=admin.get("Arn", ""), + name=admin.get("Name", ""), + email=admin.get("Email", ""), + status=admin.get("Status", ""), + joined_method=admin.get("JoinedMethod", ""), + ) + ) + except ClientError as error: + error_code = error.response["Error"]["Code"] + if error_code in ( + "AccessDeniedException", + "AccessDenied", + "AWSOrganizationsNotInUseException", + ): + self.delegated_administrators_lookup_failed = True + logger.warning( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + else: + self.delegated_administrators_lookup_failed = True + logger.error( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + self.delegated_administrators_lookup_failed = True + logger.error( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + class Recorder(BaseModel): name: str @@ -80,3 +189,25 @@ class Recorder(BaseModel): recording: Optional[bool] last_status: Optional[str] region: str + + +class Aggregator(BaseModel): + """Represents an AWS Config Configuration Aggregator.""" + + name: str + arn: str + region: str + all_aws_regions: bool = False + aws_regions: Optional[list] = None + organization_aggregation_source_present: bool = False + + +class ConfigDelegatedAdministrator(BaseModel): + """Represents a delegated administrator registered for config.amazonaws.com.""" + + id: str + arn: str + name: str + email: str + status: str + joined_method: str diff --git a/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/__init__.py b/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.metadata.json b/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.metadata.json new file mode 100644 index 0000000000..0293501578 --- /dev/null +++ b/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "aws", + "CheckID": "elbv2_alb_drop_invalid_header_fields_enabled", + "CheckTitle": "Application Load Balancer should be configured to drop invalid HTTP header fields", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "TTPs/Initial Access", + "Effects/Data Exposure" + ], + "ServiceName": "elbv2", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "AwsElbv2LoadBalancer", + "ResourceGroup": "network", + "Description": "Ensure that Application Load Balancers (ALB) are configured to drop invalid HTTP header fields. The check fails when `routing.http.drop_invalid_header_fields.enabled` is not set to `true`. By default, ALBs do not remove HTTP headers that do not conform to RFC 7230.", + "Risk": "Forwarding non-RFC-compliant HTTP headers to backend targets enables HTTP desync (request smuggling):\n- **Confidentiality**: session/token theft, data exfiltration\n- **Integrity**: cache poisoning, request routing bypass, unauthorized actions\n- **Availability**: backend exhaustion.\nDropping invalid header fields removes a primary smuggling vector.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html#drop-invalid-header-fields", + "https://docs.aws.amazon.com/securityhub/latest/userguide/elb-controls.html#elb-4" + ], + "Remediation": { + "Code": { + "CLI": "aws elbv2 modify-load-balancer-attributes --load-balancer-arn --attributes Key=routing.http.drop_invalid_header_fields.enabled,Value=true", + "NativeIaC": "```yaml\n# CloudFormation: enable drop invalid header fields on an ALB\nResources:\n :\n Type: AWS::ElasticLoadBalancingV2::LoadBalancer\n Properties:\n Type: application\n Subnets:\n - \n - \n LoadBalancerAttributes:\n - Key: routing.http.drop_invalid_header_fields.enabled # Critical: drop non-RFC-compliant headers\n Value: true\n```", + "Other": "1. Open the Amazon EC2 console and choose Load Balancers.\n2. Select the Application Load Balancer.\n3. On the Attributes tab, choose Edit.\n4. Set 'Drop invalid header fields' to Enabled.\n5. Save changes.", + "Terraform": "```hcl\n# Terraform: enable drop invalid header fields on an ALB\nresource \"aws_lb\" \"\" {\n name = \"\"\n load_balancer_type = \"application\"\n subnets = [\"\", \"\"]\n drop_invalid_header_fields = true # Critical: drop non-RFC-compliant headers\n}\n```" + }, + "Recommendation": { + "Text": "Enable 'drop invalid header fields' on Application Load Balancers so non-RFC-compliant HTTP headers are removed before requests reach backend targets, reducing exposure to HTTP desync and request smuggling. Apply defense in depth and validate requests at the application layer as well.", + "Url": "https://hub.prowler.com/check/elbv2_alb_drop_invalid_header_fields_enabled" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.py b/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.py new file mode 100644 index 0000000000..86740a253a --- /dev/null +++ b/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.py @@ -0,0 +1,27 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.elbv2.elbv2_client import elbv2_client + + +class elbv2_alb_drop_invalid_header_fields_enabled(Check): + def execute(self): + findings = [] + for lb in elbv2_client.loadbalancersv2.values(): + if lb.type == "application": + report = Check_Report_AWS( + metadata=self.metadata(), + resource=lb, + ) + report.status = "PASS" + report.status_extended = ( + f"ELBv2 ALB {lb.name} is configured to drop invalid " + "header fields." + ) + if lb.drop_invalid_header_fields != "true": + report.status = "FAIL" + report.status_extended = ( + f"ELBv2 ALB {lb.name} is not configured to drop " + "invalid header fields." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/iam/iam_service.py b/prowler/providers/aws/services/iam/iam_service.py index d6cb150178..fa01550b11 100644 --- a/prowler/providers/aws/services/iam/iam_service.py +++ b/prowler/providers/aws/services/iam/iam_service.py @@ -103,6 +103,9 @@ class IAM(AWSService): self._get_user_temporary_credentials_usage() self.organization_features = [] self._list_organizations_features() + # ListRoles does not echo PermissionsBoundary; backfill via GetRole. + if self.roles: + self.__threading_call__(self._get_role_permissions_boundary, self.roles) # List missing tags self.__threading_call__(self._list_tags, self.users) self.__threading_call__(self._list_tags, self.roles) @@ -133,6 +136,7 @@ class IAM(AWSService): arn=role["Arn"], assume_role_policy=role["AssumeRolePolicyDocument"], is_service_role=is_service_role(role), + permissions_boundary=role.get("PermissionsBoundary"), ) ) except ClientError as error: @@ -460,6 +464,34 @@ class IAM(AWSService): f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _get_role_permissions_boundary(self, role): + """Backfill ``role.permissions_boundary`` via ``GetRole``. + + ``ListRoles`` does not return ``PermissionsBoundary`` in practice, so + the value is fetched per role and stored on the ``Role`` model. + + Args: + role: The ``Role`` instance to enrich. + """ + try: + response = self.client.get_role(RoleName=role.name) + role.permissions_boundary = response.get("Role", {}).get( + "PermissionsBoundary" + ) + except ClientError as error: + if error.response["Error"]["Code"] == "NoSuchEntity": + logger.warning( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + else: + logger.error( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + logger.error( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + def _list_attached_role_policies(self): logger.info("IAM - List Attached Role Policies...") try: @@ -1139,6 +1171,7 @@ class Role(BaseModel): is_service_role: bool attached_policies: list[dict] = [] inline_policies: list[str] = [] + permissions_boundary: Optional[dict] = None tags: Optional[list] diff --git a/prowler/providers/aws/services/route53/route53_dangling_ip_subdomain_takeover/route53_dangling_ip_subdomain_takeover.py b/prowler/providers/aws/services/route53/route53_dangling_ip_subdomain_takeover/route53_dangling_ip_subdomain_takeover.py index caf3629816..25703b979f 100644 --- a/prowler/providers/aws/services/route53/route53_dangling_ip_subdomain_takeover/route53_dangling_ip_subdomain_takeover.py +++ b/prowler/providers/aws/services/route53/route53_dangling_ip_subdomain_takeover/route53_dangling_ip_subdomain_takeover.py @@ -1,10 +1,9 @@ import re from ipaddress import ip_address -import awsipranges - from prowler.lib.check.models import Check, Check_Report_AWS from prowler.lib.utils.utils import validate_ip_address +from prowler.providers.aws.lib.ip_ranges.ip_ranges import get_public_ip_networks from prowler.providers.aws.services.ec2.ec2_client import ec2_client from prowler.providers.aws.services.route53.route53_client import route53_client from prowler.providers.aws.services.s3.s3_client import s3_client @@ -21,6 +20,10 @@ class route53_dangling_ip_subdomain_takeover(Check): def execute(self) -> Check_Report_AWS: findings = [] + # AWS public IP prefixes are fetched lazily, at most once per run, only + # when a dangling-candidate public IP is found. + aws_ip_networks = None + # When --region is used, Route53 service gathers EIPs from all regions # to avoid false positives. Otherwise, use ec2_client data directly. if route53_client.all_account_elastic_ips: @@ -42,6 +45,7 @@ class route53_dangling_ip_subdomain_takeover(Check): if record_set.type == "A" and not record_set.is_alias: for record in record_set.records: if validate_ip_address(record): + record_ip = ip_address(record) report = Check_Report_AWS( metadata=self.metadata(), resource=record_set ) @@ -53,14 +57,12 @@ class route53_dangling_ip_subdomain_takeover(Check): report.status = "PASS" report.status_extended = f"Route53 record {record} (name: {record_set.name}) in Hosted Zone {hosted_zone.name} is not a dangling IP." # If Public IP check if it is in the AWS Account - if ( - not ip_address(record).is_private - and record not in public_ips - ): + if not record_ip.is_private and record not in public_ips: report.status_extended = f"Route53 record {record} (name: {record_set.name}) in Hosted Zone {hosted_zone.name} does not belong to AWS and it is not a dangling IP." # Check if potential dangling IP is within AWS Ranges - aws_ip_ranges = awsipranges.get_ranges() - if aws_ip_ranges.get(record): + if aws_ip_networks is None: + aws_ip_networks = get_public_ip_networks() + if any(record_ip in network for network in aws_ip_networks): report.status = "FAIL" report.status_extended = f"Route53 record {record} (name: {record_set.name}) in Hosted Zone {hosted_zone.name} is a dangling IP which can lead to a subdomain takeover attack." findings.append(report) diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/__init__.py b/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists.metadata.json b/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists.metadata.json new file mode 100644 index 0000000000..0e127a7759 --- /dev/null +++ b/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "aws", + "CheckID": "sagemaker_clarify_exists", + "CheckTitle": "Amazon SageMaker Clarify processing jobs exist in the region", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "sagemaker", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "Other", + "ResourceGroup": "ai_ml", + "Description": "**SageMaker Clarify** provides bias detection and model explainability for ML workloads.\n\nThis check verifies that at least one SageMaker processing job using the AWS-managed Clarify container image exists in each successfully scanned region. The absence of Clarify jobs indicates that responsible-AI controls such as bias detection and explainability are not in place.", + "Risk": "Without **SageMaker Clarify** processing jobs, ML models may be deployed without bias analysis or explainability reports. This can lead to:\n- **Regulatory non-compliance** with AI governance frameworks\n- **Undetected bias** in model predictions affecting protected groups\n- **Lack of accountability** for ML model decisions in production", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/sagemaker/latest/dg/clarify-configure-processing-jobs.html", + "https://docs.aws.amazon.com/sagemaker/latest/dg-ecr-paths/sagemaker-algo-docker-registry-paths.html" + ], + "Remediation": { + "Code": { + "CLI": "aws sagemaker create-processing-job --processing-job-name clarify-bias-check --app-specification ImageUri= --role-arn --processing-resources 'ClusterConfig={InstanceCount=1,InstanceType=ml.m5.xlarge,VolumeSizeInGB=20}'", + "NativeIaC": "", + "Other": "1. Open the AWS Console and go to Amazon SageMaker\n2. Navigate to Processing > Processing jobs\n3. Click Create processing job\n4. Select the SageMaker Clarify container image for your region\n5. Configure input/output paths and the analysis configuration\n6. Click Create processing job", + "Terraform": "" + }, + "Recommendation": { + "Text": "Create SageMaker Clarify processing jobs to evaluate models for bias and explainability before deployment. Integrate Clarify into your ML pipeline to ensure responsible AI practices.", + "Url": "https://hub.prowler.com/check/sagemaker_clarify_exists" + } + }, + "Categories": [ + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Results are generated per scanned region. Regions where `ListProcessingJobs` cannot be queried are omitted from the findings." +} diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists.py b/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists.py new file mode 100644 index 0000000000..9c559613d3 --- /dev/null +++ b/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists.py @@ -0,0 +1,54 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.sagemaker.sagemaker_client import sagemaker_client + + +class sagemaker_clarify_exists(Check): + """Check whether at least one SageMaker Clarify processing job exists per region. + + A region is reported only when ListProcessingJobs succeeded for it; regions + where the API call failed (e.g. AccessDenied, unsupported region) are + skipped at the service layer and produce no finding. + + - PASS: At least one processing job uses the AWS-managed Clarify container + image in the region (one finding per job). + - FAIL: No processing job uses the Clarify container image in the region + (one finding per region). + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the SageMaker Clarify exists check. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + for region in sorted(sagemaker_client.processing_jobs_scanned_regions): + clarify_jobs = sorted( + ( + job + for job in sagemaker_client.sagemaker_processing_jobs + if job.region == region + and job.image_uri + and "sagemaker-clarify-processing" in job.image_uri + ), + key=lambda job: job.name, + ) + + if clarify_jobs: + for job in clarify_jobs: + report = Check_Report_AWS(metadata=self.metadata(), resource=job) + report.status = "PASS" + report.status_extended = f"SageMaker Clarify processing job {job.name} exists in region {region}." + findings.append(report) + else: + report = Check_Report_AWS(metadata=self.metadata(), resource={}) + report.region = region + report.resource_id = "sagemaker-clarify" + report.resource_arn = f"arn:{sagemaker_client.audited_partition}:sagemaker:{region}:{sagemaker_client.audited_account}:processing-job" + report.status = "FAIL" + report.status_extended = ( + f"No SageMaker Clarify processing jobs found in region {region}." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_models_monitor_enabled/__init__.py b/prowler/providers/aws/services/sagemaker/sagemaker_models_monitor_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_models_monitor_enabled/sagemaker_models_monitor_enabled.metadata.json b/prowler/providers/aws/services/sagemaker/sagemaker_models_monitor_enabled/sagemaker_models_monitor_enabled.metadata.json new file mode 100644 index 0000000000..d1d025fe7e --- /dev/null +++ b/prowler/providers/aws/services/sagemaker/sagemaker_models_monitor_enabled/sagemaker_models_monitor_enabled.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "aws", + "CheckID": "sagemaker_models_monitor_enabled", + "CheckTitle": "Amazon SageMaker has a monitoring schedule scheduled", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" + ], + "ServiceName": "sagemaker", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "Other", + "ResourceGroup": "ai_ml", + "Description": "**SageMaker Models Monitor** detects data drift, model quality issues, and bias drift in production.", + "Risk": "Without an **active monitoring schedule**, data drift, model quality issues, and bias drift go undetected, so **model quality degrades silently** while downstream decisions such as fraud detection, access control, and pricing keep relying on a degrading model.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/sagemaker/latest/dg/model-monitor.html", + "https://docs.aws.amazon.com/sagemaker/latest/dg/model-monitor-scheduling.html" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable **Amazon SageMaker Model Monitor** and keep at least one **monitoring schedule** in the `Scheduled` state so data quality, model quality, and bias drift are continuously evaluated against a baseline.", + "Url": "https://hub.prowler.com/check/sagemaker_models_monitor_enabled" + } + }, + "Categories": [ + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_models_monitor_enabled/sagemaker_models_monitor_enabled.py b/prowler/providers/aws/services/sagemaker/sagemaker_models_monitor_enabled/sagemaker_models_monitor_enabled.py new file mode 100644 index 0000000000..f9f893a895 --- /dev/null +++ b/prowler/providers/aws/services/sagemaker/sagemaker_models_monitor_enabled/sagemaker_models_monitor_enabled.py @@ -0,0 +1,22 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.sagemaker.sagemaker_client import sagemaker_client + + +class sagemaker_models_monitor_enabled(Check): + def execute(self): + findings = [] + for monitoring_schedule in sagemaker_client.sagemaker_monitoring_schedules: + report = Check_Report_AWS( + metadata=self.metadata(), resource=monitoring_schedule + ) + if monitoring_schedule.is_scheduled: + report.status = "PASS" + report.status_extended = f"SageMaker monitoring schedule {monitoring_schedule.name} is enabled in region {monitoring_schedule.region}." + elif not monitoring_schedule.has_schedules: + report.status = "FAIL" + report.status_extended = f"No SageMaker monitoring schedules found in region {monitoring_schedule.region}." + else: + report.status = "FAIL" + report.status_extended = f"No active SageMaker monitoring schedule in region {monitoring_schedule.region}; existing schedules are not in Scheduled status." + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_service.py b/prowler/providers/aws/services/sagemaker/sagemaker_service.py index 0f73062452..20ea4c0280 100644 --- a/prowler/providers/aws/services/sagemaker/sagemaker_service.py +++ b/prowler/providers/aws/services/sagemaker/sagemaker_service.py @@ -15,17 +15,22 @@ class SageMaker(AWSService): self.sagemaker_notebook_instances = [] self.sagemaker_models = [] self.sagemaker_training_jobs = [] + self.sagemaker_processing_jobs = [] + self.processing_jobs_scanned_regions = set() self.sagemaker_domains = [] self.endpoint_configs = {} self.sagemaker_model_registries = [] + self.sagemaker_monitoring_schedules = [] # Retrieve resources concurrently self.__threading_call__(self._list_notebook_instances) self.__threading_call__(self._list_models) self.__threading_call__(self._list_training_jobs) + self.__threading_call__(self._list_processing_jobs) self.__threading_call__(self._list_endpoint_configs) self.__threading_call__(self._list_domains) self.__threading_call__(self._list_model_package_groups) + self.__threading_call__(self._list_monitoring_schedules) # Describe resources concurrently self.__threading_call__(self._describe_model, self.sagemaker_models) @@ -35,6 +40,9 @@ class SageMaker(AWSService): self.__threading_call__( self._describe_training_job, self.sagemaker_training_jobs ) + self.__threading_call__( + self._describe_processing_job, self.sagemaker_processing_jobs + ) self.__threading_call__( self._describe_endpoint_config, list(self.endpoint_configs.values()) ) @@ -49,6 +57,9 @@ class SageMaker(AWSService): self.__threading_call__( self._list_tags_for_resource, self.sagemaker_training_jobs ) + self.__threading_call__( + self._list_tags_for_resource, self.sagemaker_processing_jobs + ) self.__threading_call__( self._list_tags_for_resource, list(self.endpoint_configs.values()) ) @@ -126,6 +137,66 @@ class SageMaker(AWSService): f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _list_processing_jobs(self, regional_client): + """List SageMaker processing jobs in a region. + + Populates ``self.sagemaker_processing_jobs`` with `ProcessingJob` + entries and adds ``regional_client.region`` to + ``self.processing_jobs_scanned_regions`` once pagination succeeds, so + regions where ``ListProcessingJobs`` fails are skipped by checks that + consume that set. + + Args: + regional_client: Regional SageMaker boto3 client. + """ + logger.info("SageMaker - listing processing jobs...") + try: + list_processing_jobs_paginator = regional_client.get_paginator( + "list_processing_jobs" + ) + for page in list_processing_jobs_paginator.paginate(): + for processing_job in page["ProcessingJobSummaries"]: + if not self.audit_resources or ( + is_resource_filtered( + processing_job["ProcessingJobArn"], self.audit_resources + ) + ): + self.sagemaker_processing_jobs.append( + ProcessingJob( + name=processing_job["ProcessingJobName"], + region=regional_client.region, + arn=processing_job["ProcessingJobArn"], + ) + ) + self.processing_jobs_scanned_regions.add(regional_client.region) + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _describe_processing_job(self, processing_job): + """Describe a SageMaker processing job and enrich its image metadata. + + Reads ``AppSpecification.ImageUri`` from ``DescribeProcessingJob`` and + stores it on ``processing_job.image_uri``. Errors are logged and + swallowed so a failure in one job does not abort the scan. + + Args: + processing_job: ProcessingJob model to enrich in-place. + """ + logger.info("SageMaker - describing processing job...") + try: + regional_client = self.regional_clients[processing_job.region] + describe_processing_job = regional_client.describe_processing_job( + ProcessingJobName=processing_job.name + ) + app_spec = describe_processing_job.get("AppSpecification", {}) + processing_job.image_uri = app_spec.get("ImageUri") + except Exception as error: + logger.error( + f"{processing_job.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + def _describe_notebook_instance(self, notebook_instance): logger.info("SageMaker - describing notebook instances...") try: @@ -377,6 +448,46 @@ class SageMaker(AWSService): f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _list_monitoring_schedules(self, regional_client): + logger.info("SageMaker - listing monitoring schedules...") + name = "SageMaker Monitoring Schedules" + arn = self.get_unknown_arn( + region=regional_client.region, + resource_type="monitoring-schedule", + ) + has_schedules = False + is_scheduled = False + try: + paginator = regional_client.get_paginator("list_monitoring_schedules") + for page in paginator.paginate(): + for schedule in page["MonitoringScheduleSummaries"]: + if not self.audit_resources or ( + is_resource_filtered( + schedule["MonitoringScheduleArn"], self.audit_resources + ) + ): + has_schedules = True + if schedule["MonitoringScheduleStatus"] == "Scheduled": + is_scheduled = True + name = schedule["MonitoringScheduleName"] + arn = schedule["MonitoringScheduleArn"] + break + if is_scheduled: + break + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + self.sagemaker_monitoring_schedules.append( + MonitoringSchedule( + name=name, + region=regional_client.region, + arn=arn, + has_schedules=has_schedules, + is_scheduled=is_scheduled, + ) + ) + class NotebookInstance(BaseModel): name: str @@ -409,6 +520,25 @@ class TrainingJob(BaseModel): tags: Optional[list] = [] +class ProcessingJob(BaseModel): + """Represents a SageMaker processing job. + + Attributes: + name: Processing job name. + region: AWS region where the job lives. + arn: Processing job ARN. + image_uri: Container image URI from `AppSpecification.ImageUri`, + populated by `_describe_processing_job`. + tags: Resource tags, populated by `_list_tags_for_resource`. + """ + + name: str + region: str + arn: str + image_uri: Optional[str] = None + tags: Optional[list] = [] + + class ProductionVariant(BaseModel): name: str initial_instance_count: int @@ -441,3 +571,11 @@ class ModelRegistry(BaseModel): region: str has_groups: bool = False has_approved_packages: bool = False + + +class MonitoringSchedule(BaseModel): + name: str + region: str + arn: str + has_schedules: bool = False + is_scheduled: bool = False diff --git a/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/__init__.py b/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions.metadata.json b/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions.metadata.json new file mode 100644 index 0000000000..99748671ae --- /dev/null +++ b/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions.metadata.json @@ -0,0 +1,44 @@ +{ + "Provider": "aws", + "CheckID": "securityhub_delegated_admin_enabled_all_regions", + "CheckTitle": "Security Hub has delegated admin configured and is enabled in all regions with organization auto-enable", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" + ], + "ServiceName": "securityhub", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "AwsSecurityHubHub", + "ResourceGroup": "security", + "Description": "**AWS Security Hub** has a delegated administrator configured at the organization level, hubs are active in all opted-in regions, and organization auto-enable is active so that new member accounts are automatically enrolled.", + "Risk": "Without org-wide **AWS Security Hub** configuration, findings can be aggregated inconsistently, delegated admin may be missing in some regions, and new accounts will not be auto-enrolled. This fragments **security posture visibility**, delays **incident response**, and lets misconfigurations and compliance drift go undetected across the organization.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/securityhub/latest/userguide/designate-orgs-admin-account.html", + "https://docs.aws.amazon.com/securityhub/latest/userguide/accounts-orgs-auto-enable.html", + "https://docs.aws.amazon.com/securityhub/latest/userguide/securityhub-regions.html" + ], + "Remediation": { + "Code": { + "CLI": "aws securityhub enable-organization-admin-account --admin-account-id && aws securityhub update-organization-configuration --auto-enable --auto-enable-standards DEFAULT", + "NativeIaC": "", + "Other": "1. Sign in to the AWS Organizations management account\n2. Open the AWS Organizations console\n3. Navigate to Services > AWS Security Hub\n4. Click Register delegated administrator and enter the security account ID\n5. Switch to the delegated admin account\n6. In Security Hub console, go to Settings > Accounts\n7. Enable auto-enable for new organization accounts\n8. Repeat hub enablement for all opted-in regions", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure a **delegated administrator** for AWS Security Hub via AWS Organizations. Enable Security Hub in **all opted-in regions** and turn on **auto-enable** so new member accounts are automatically enrolled. This ensures uniform security posture monitoring across the entire organization.", + "Url": "https://hub.prowler.com/check/securityhub_delegated_admin_enabled_all_regions" + } + }, + "Categories": [ + "forensics-ready" + ], + "DependsOn": [], + "RelatedTo": [ + "securityhub_enabled", + "guardduty_delegated_admin_enabled_all_regions" + ], + "Notes": "This check requires execution from the organization management account or delegated administrator account to access organization-level APIs." +} diff --git a/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions.py b/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions.py new file mode 100644 index 0000000000..4828752183 --- /dev/null +++ b/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions.py @@ -0,0 +1,84 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.securityhub.securityhub_client import ( + securityhub_client, +) + + +class securityhub_delegated_admin_enabled_all_regions(Check): + """Ensure Security Hub has a delegated admin and is enabled in all regions. + + This check verifies that: + 1. A delegated administrator account is configured for Security Hub + 2. Security Hub is active (ACTIVE status) in each region + 3. Organization auto-enable is configured for new member accounts + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the check logic. + + Returns: + A list of reports containing the result of the check for each region. + """ + findings = [] + + # Build a set of regions that have an organization admin account configured + regions_with_admin = { + admin.region + for admin in securityhub_client.organization_admin_accounts + if admin.admin_status == "ENABLED" + } + admin_lookup_failed = securityhub_client.organization_admin_lookup_failed + + for securityhub in securityhub_client.securityhubs: + report = Check_Report_AWS(metadata=self.metadata(), resource=securityhub) + + # Check if this region has a delegated admin + has_delegated_admin = securityhub.region in regions_with_admin + + # Check if hub is active + hub_active = securityhub.status == "ACTIVE" + + # Check if auto-enable is configured for organization members + auto_enable_on = securityhub.organization_auto_enable + + # Determine overall status + issues = [] + if admin_lookup_failed: + issues.append("delegated administrator status could not be determined") + elif not has_delegated_admin: + issues.append("no delegated administrator configured") + if not hub_active: + issues.append("Security Hub not enabled") + if ( + hub_active + and securityhub.organization_config_available + and not auto_enable_on + ): + # Only report auto-enable issue if hub is active and org config data + # is available (i.e., we could actually read AutoEnable from the API). + issues.append("organization auto-enable not configured") + + if issues: + report.status = "FAIL" + report.status_extended = ( + f"Security Hub in region {securityhub.region} has issues: " + f"{', '.join(issues)}." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Security Hub in region {securityhub.region} has delegated " + f"admin configured with hub active and organization auto-enable " + f"enabled." + ) + + # Support muting non-default regions if configured + if report.status == "FAIL" and ( + securityhub_client.audit_config.get("mute_non_default_regions", False) + and securityhub.region != securityhub_client.region + ): + report.muted = True + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/securityhub/securityhub_service.py b/prowler/providers/aws/services/securityhub/securityhub_service.py index 0799c6e048..5e2d445742 100644 --- a/prowler/providers/aws/services/securityhub/securityhub_service.py +++ b/prowler/providers/aws/services/securityhub/securityhub_service.py @@ -13,8 +13,14 @@ class SecurityHub(AWSService): # Call AWSService's __init__ super().__init__(__class__.__name__, provider) self.securityhubs = [] + self.organization_admin_accounts = [] + self.organization_admin_lookup_failed: bool = False self.__threading_call__(self._describe_hub) self.__threading_call__(self._list_tags, self.securityhubs) + self.__threading_call__(self._list_organization_admin_accounts) + self.__threading_call__( + self._describe_organization_configuration, self.securityhubs + ) def _describe_hub(self, regional_client): logger.info("SecurityHub - Describing Hub...") @@ -104,6 +110,95 @@ class SecurityHub(AWSService): f"{resource.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _list_organization_admin_accounts(self, regional_client): + """List Security Hub delegated administrator accounts for the organization. + + This API is only available to the organization management account or + a delegated administrator account. + """ + logger.info("SecurityHub - listing organization admin accounts...") + try: + paginator = regional_client.get_paginator( + "list_organization_admin_accounts" + ) + for page in paginator.paginate(): + for admin in page.get("AdminAccounts", []): + admin_account = OrganizationAdminAccount( + admin_account_id=admin.get("AdminAccountId"), + admin_status=admin.get("AdminStatus"), + region=regional_client.region, + ) + # Avoid duplicates across regions for the same admin account + if not any( + existing.admin_account_id == admin_account.admin_account_id + and existing.region == admin_account.region + for existing in self.organization_admin_accounts + ): + self.organization_admin_accounts.append(admin_account) + except ClientError as error: + self.organization_admin_lookup_failed = True + if error.response["Error"]["Code"] in ( + "AccessDeniedException", + "InvalidAccessException", + "BadRequestException", + ): + 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.organization_admin_lookup_failed = True + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _describe_organization_configuration(self, securityhub): + """Describe the organization configuration for a Security Hub instance. + + This provides information about auto-enable settings for the organization. + Only invoked for hubs in ACTIVE status. + """ + logger.info("SecurityHub - describing organization configuration...") + try: + if securityhub.status != "ACTIVE": + return + regional_client = self.regional_clients[securityhub.region] + org_config = regional_client.describe_organization_configuration() + securityhub.organization_auto_enable = org_config.get("AutoEnable", False) + securityhub.auto_enable_standards = org_config.get( + "AutoEnableStandards", "NONE" + ) + securityhub.organization_config_available = True + except ClientError as error: + if error.response["Error"]["Code"] in ( + "AccessDeniedException", + "InvalidAccessException", + "BadRequestException", + ): + # Expected when not running from management or delegated admin account + logger.warning( + f"{securityhub.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + else: + logger.error( + f"{securityhub.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + logger.error( + f"{securityhub.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class OrganizationAdminAccount(BaseModel): + """Represents a Security Hub delegated administrator account.""" + + admin_account_id: str + admin_status: str # ENABLED or DISABLE_IN_PROGRESS + region: str + class SecurityHubHub(BaseModel): arn: str @@ -112,4 +207,8 @@ class SecurityHubHub(BaseModel): standards: str integrations: str region: str - tags: Optional[list] + tags: Optional[list] = [] + # Organization configuration fields + organization_auto_enable: bool = False + auto_enable_standards: str = "NONE" + organization_config_available: bool = False diff --git a/prowler/providers/azure/services/aks/aks_cluster_auto_upgrade_enabled/__init__.py b/prowler/providers/azure/services/aks/aks_cluster_auto_upgrade_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/aks/aks_cluster_auto_upgrade_enabled/aks_cluster_auto_upgrade_enabled.metadata.json b/prowler/providers/azure/services/aks/aks_cluster_auto_upgrade_enabled/aks_cluster_auto_upgrade_enabled.metadata.json new file mode 100644 index 0000000000..77096d6a20 --- /dev/null +++ b/prowler/providers/azure/services/aks/aks_cluster_auto_upgrade_enabled/aks_cluster_auto_upgrade_enabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "aks_cluster_auto_upgrade_enabled", + "CheckTitle": "AKS cluster has automatic upgrade enabled", + "CheckType": [], + "ServiceName": "aks", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.containerservice/managedclusters", + "ResourceGroup": "container", + "Description": "**AKS clusters** are evaluated for an **automatic upgrade channel** other than `none`. Automatic upgrades keep the Kubernetes control plane and node pools on supported patch/minor versions and reduce version drift across clusters.", + "Risk": "Without automatic upgrades, AKS clusters can remain on unsupported or vulnerable Kubernetes versions. Delayed patching increases exposure to **known CVEs**, control plane/node defects, and exploit chains that can affect workload **confidentiality**, **integrity**, and **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/aks/auto-upgrade-cluster", + "https://learn.microsoft.com/en-us/azure/aks/supported-kubernetes-versions" + ], + "Remediation": { + "Code": { + "CLI": "az aks update --resource-group --name --auto-upgrade-channel patch", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure an AKS automatic upgrade channel so clusters receive Kubernetes version updates and security patches without relying only on manual upgrade processes. Use `patch` or `stable` for conservative production upgrades, reserve faster channels such as `rapid` for environments that can absorb quicker version changes, and avoid `none` unless a documented exception and manual patching process exists.", + "Url": "https://hub.prowler.com/check/aks_cluster_auto_upgrade_enabled" + } + }, + "Categories": [ + "vulnerabilities", + "cluster-security" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Automatic upgrade channels can change cluster versions during Azure-managed upgrade windows. Select a channel that matches the workload change tolerance and use planned maintenance windows, staging validation, and documented rollback procedures for production clusters." +} diff --git a/prowler/providers/azure/services/aks/aks_cluster_auto_upgrade_enabled/aks_cluster_auto_upgrade_enabled.py b/prowler/providers/azure/services/aks/aks_cluster_auto_upgrade_enabled/aks_cluster_auto_upgrade_enabled.py new file mode 100644 index 0000000000..9d1f8b48a1 --- /dev/null +++ b/prowler/providers/azure/services/aks/aks_cluster_auto_upgrade_enabled/aks_cluster_auto_upgrade_enabled.py @@ -0,0 +1,28 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.aks.aks_client import aks_client + + +class aks_cluster_auto_upgrade_enabled(Check): + def execute(self) -> list[Check_Report_Azure]: + findings = [] + + for subscription_name, clusters in aks_client.clusters.items(): + for cluster in clusters.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=cluster) + report.subscription = subscription_name + + auto_upgrade_channel = ( + (cluster.auto_upgrade_channel or "").strip().lower() + ) + if auto_upgrade_channel and auto_upgrade_channel != "none": + report.status = "PASS" + report.status_extended = ( + f"Cluster '{cluster.name}' has auto-upgrade channel." + ) + else: + report.status = "FAIL" + report.status_extended = f"Cluster '{cluster.name}' does not have auto-upgrade configured." + + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/__init__.py b/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled.metadata.json b/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled.metadata.json new file mode 100644 index 0000000000..90bbd45f3b --- /dev/null +++ b/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "azure", + "CheckID": "aks_cluster_defender_enabled", + "CheckTitle": "AKS cluster has Microsoft Defender security profile enabled", + "CheckType": [], + "ServiceName": "aks", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "microsoft.containerservice/managedclusters", + "ResourceGroup": "container", + "Description": "**AKS clusters** are evaluated for the **Microsoft Defender security profile** (`securityProfile.defender.securityMonitoring.enabled=true`). Defender for Containers extends security monitoring, threat detection, vulnerability assessment, and security posture insights to Azure Kubernetes Service workloads.", + "Risk": "Without the Microsoft Defender security profile, AKS runtime threats such as **cryptomining**, suspicious Kubernetes API activity, vulnerable container images, and malicious workload behavior may go undetected. Compromised containers can pivot to cluster resources and cloud APIs, impacting **confidentiality**, **integrity**, and **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/defender-for-cloud/defender-for-containers-deployment-overview", + "https://learn.microsoft.com/en-us/cli/azure/aks?view=azure-cli-latest#az-aks-update", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.containerservice/managedclusters" + ], + "Remediation": { + "Code": { + "CLI": "az aks update --resource-group --name --enable-defender", + "NativeIaC": "```bicep\n// AKS cluster with Microsoft Defender security monitoring enabled\nresource aks 'Microsoft.ContainerService/managedClusters@2024-05-01' = {\n name: ''\n location: ''\n identity: {\n type: 'SystemAssigned'\n }\n properties: {\n dnsPrefix: ''\n securityProfile: {\n defender: {\n logAnalyticsWorkspaceResourceId: ''\n securityMonitoring: {\n enabled: true // Critical: enables Defender security monitoring for AKS\n }\n }\n }\n agentPoolProfiles: [\n {\n name: 'system'\n count: 1\n vmSize: 'Standard_DS2_v2'\n mode: 'System'\n }\n ]\n }\n}\n```", + "Other": "1. In Microsoft Defender for Cloud, enable the Containers plan for the subscription that contains the AKS cluster\n2. In the AKS cluster configuration, enable the Microsoft Defender security profile\n3. Verify that Defender security monitoring is enabled for the managed cluster", + "Terraform": "```hcl\nresource \"azurerm_log_analytics_workspace\" \"example\" {\n name = \"\"\n location = \"\"\n resource_group_name = \"\"\n sku = \"PerGB2018\"\n}\n\nresource \"azurerm_kubernetes_cluster\" \"example\" {\n name = \"\"\n location = \"\"\n resource_group_name = \"\"\n dns_prefix = \"\"\n\n default_node_pool {\n name = \"system\"\n node_count = 1\n vm_size = \"Standard_DS2_v2\"\n }\n\n identity {\n type = \"SystemAssigned\"\n }\n\n microsoft_defender {\n log_analytics_workspace_id = azurerm_log_analytics_workspace.example.id\n }\n}\n```" + }, + "Recommendation": { + "Text": "Enable the Microsoft Defender security profile for AKS clusters and ensure the subscription has Defender for Containers enabled. Route security monitoring data to the appropriate workspace and review Defender alerts and recommendations as part of the cluster operations process.", + "Url": "https://hub.prowler.com/check/aks_cluster_defender_enabled" + } + }, + "Categories": [ + "threat-detection", + "cluster-security" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This check evaluates the AKS managed cluster Defender security monitoring flag. Defender for Containers plan availability, billing, workspace routing, and alert response processes should be validated separately at the subscription and security operations levels." +} diff --git a/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled.py b/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled.py new file mode 100644 index 0000000000..08074754a6 --- /dev/null +++ b/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled.py @@ -0,0 +1,29 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.aks.aks_client import aks_client + + +class aks_cluster_defender_enabled(Check): + def execute(self) -> list[Check_Report_Azure]: + findings = [] + + for subscription_name, clusters in aks_client.clusters.items(): + for cluster in clusters.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=cluster) + report.subscription = subscription_name + + if cluster.defender_enabled is True: + report.status = "PASS" + report.status_extended = ( + f"Cluster '{cluster.name}' has Defender for Containers " + "enabled." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Cluster '{cluster.name}' does not have Defender for " + "Containers enabled." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/aks/aks_service.py b/prowler/providers/azure/services/aks/aks_service.py index 3d158a2f70..081edd7b17 100644 --- a/prowler/providers/azure/services/aks/aks_service.py +++ b/prowler/providers/azure/services/aks/aks_service.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import List +from typing import List, Optional from azure.mgmt.containerservice import ContainerServiceClient @@ -55,6 +55,56 @@ class AKS(AzureService): ) ], rbac_enabled=getattr(cluster, "enable_rbac", False), + auto_upgrade_channel=getattr( + getattr(cluster, "auto_upgrade_profile", None), + "upgrade_channel", + None, + ), + defender_enabled=bool( + getattr( + getattr( + getattr( + getattr( + cluster, + "security_profile", + None, + ), + "defender", + None, + ), + "security_monitoring", + None, + ), + "enabled", + False, + ) + ), + azure_monitor_enabled=( + bool( + getattr( + getattr( + getattr( + cluster, + "azure_monitor_profile", + None, + ), + "metrics", + None, + ), + "enabled", + False, + ) + ) + if getattr( + cluster, "azure_monitor_profile", None + ) + else False + ), + local_accounts_disabled=bool( + getattr( + cluster, "disable_local_accounts", False + ) + ), ) } ) @@ -82,3 +132,7 @@ class Cluster: agent_pool_profiles: List[ManagedClusterAgentPoolProfile] rbac_enabled: bool location: str + auto_upgrade_channel: Optional[str] = None + defender_enabled: bool = False + azure_monitor_enabled: bool = False + local_accounts_disabled: bool = False diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_automatic_failover_enabled/__init__.py b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_automatic_failover_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_automatic_failover_enabled/cosmosdb_account_automatic_failover_enabled.metadata.json b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_automatic_failover_enabled/cosmosdb_account_automatic_failover_enabled.metadata.json new file mode 100644 index 0000000000..41246ff7a6 --- /dev/null +++ b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_automatic_failover_enabled/cosmosdb_account_automatic_failover_enabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "cosmosdb_account_automatic_failover_enabled", + "CheckTitle": "Cosmos DB account has automatic failover enabled", + "CheckType": [], + "ServiceName": "cosmosdb", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.documentdb/databaseaccounts", + "ResourceGroup": "database", + "Description": "**Azure Cosmos DB accounts** are evaluated for **automatic failover** configuration. When enabled, Cosmos DB automatically promotes a secondary region to primary during a regional outage, ensuring continuous availability without manual intervention.", + "Risk": "Without **automatic failover**, a regional outage requires **manual failover** which delays recovery and risks data unavailability. Applications dependent on the primary region experience downtime until an operator intervenes.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-manage-database-account#automatic-failover", + "https://learn.microsoft.com/en-us/azure/cosmos-db/high-availability", + "https://learn.microsoft.com/en-us/azure/cosmos-db/distribute-data-globally" + ], + "Remediation": { + "Code": { + "CLI": "az cosmosdb update --name --resource-group --enable-automatic-failover true", + "NativeIaC": "```bicep\n// Bicep: Enable automatic failover on a Cosmos DB account\nresource account 'Microsoft.DocumentDB/databaseAccounts@2025-10-15' = {\n name: ''\n location: resourceGroup().location\n kind: 'GlobalDocumentDB'\n properties: {\n databaseAccountOfferType: 'Standard'\n locations: [\n { locationName: '', failoverPriority: 0 }\n { locationName: '', failoverPriority: 1 }\n ]\n enableAutomaticFailover: true // Critical: Promotes a secondary region during a primary region outage\n }\n}\n```", + "Other": "1. Sign in to the Azure portal and open your Cosmos DB account\n2. In the left menu, select Replicate data globally\n3. Click Automatic Failover\n4. Toggle Enable Automatic Failover to On\n5. Set failover priorities for each region\n6. Click Save", + "Terraform": "```hcl\n# Terraform: Enable automatic failover on a Cosmos DB account\nresource \"azurerm_cosmosdb_account\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n offer_type = \"Standard\"\n kind = \"GlobalDocumentDB\"\n\n geo_location {\n location = \"\"\n failover_priority = 0\n }\n\n geo_location {\n location = \"\"\n failover_priority = 1\n }\n\n enable_automatic_failover = true # Critical: Promotes a secondary region during a primary region outage\n}\n```" + }, + "Recommendation": { + "Text": "Enable **automatic failover** on Cosmos DB accounts with **multi-region** deployments so a secondary region is promoted automatically when the primary region becomes unavailable. Configure **failover priorities** to reflect your recovery strategy, validate **RTO/RPO** expectations with periodic failover drills, and combine with **multi-region writes** where active-active is required.", + "Url": "https://hub.prowler.com/check/cosmosdb_account_automatic_failover_enabled" + } + }, + "Categories": [ + "forensics-ready" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_automatic_failover_enabled/cosmosdb_account_automatic_failover_enabled.py b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_automatic_failover_enabled/cosmosdb_account_automatic_failover_enabled.py new file mode 100644 index 0000000000..7d7d2e5023 --- /dev/null +++ b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_automatic_failover_enabled/cosmosdb_account_automatic_failover_enabled.py @@ -0,0 +1,29 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.cosmosdb.cosmosdb_client import cosmosdb_client + + +class cosmosdb_account_automatic_failover_enabled(Check): + """Ensure that Cosmos DB accounts have automatic failover enabled.""" + + def execute(self) -> Check_Report_Azure: + """Execute the Cosmos DB automatic failover check. + + Iterates over every Cosmos DB account fetched by the service and reports + PASS when `enableAutomaticFailover` is True, FAIL otherwise. + + Returns: + A list of Check_Report_Azure with one report per Cosmos DB account. + """ + findings = [] + for subscription, accounts in cosmosdb_client.accounts.items(): + for account in accounts: + report = Check_Report_Azure(metadata=self.metadata(), resource=account) + report.subscription = subscription + report.status = "FAIL" + report.status_extended = f"CosmosDB account {account.name} from subscription {subscription} does not have automatic failover enabled." + if account.enable_automatic_failover: + report.status = "PASS" + report.status_extended = f"CosmosDB account {account.name} from subscription {subscription} has automatic failover enabled." + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_backup_policy_continuous/__init__.py b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_backup_policy_continuous/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_backup_policy_continuous/cosmosdb_account_backup_policy_continuous.metadata.json b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_backup_policy_continuous/cosmosdb_account_backup_policy_continuous.metadata.json new file mode 100644 index 0000000000..1631aa5b3a --- /dev/null +++ b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_backup_policy_continuous/cosmosdb_account_backup_policy_continuous.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "cosmosdb_account_backup_policy_continuous", + "CheckTitle": "Cosmos DB account uses continuous backup policy", + "CheckType": [], + "ServiceName": "cosmosdb", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.documentdb/databaseaccounts", + "ResourceGroup": "database", + "Description": "**Azure Cosmos DB accounts** are evaluated for **continuous backup** policy. Continuous backup provides **point-in-time restore (PITR)** enabling recovery to any point within the retention window, unlike periodic backup which only supports full restores at fixed intervals.", + "Risk": "**Periodic backup** limits recovery to the last backup snapshot. Data changes between snapshots are lost during restore. **Continuous backup** enables **granular recovery** from accidental deletes, corruption, or ransomware.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/cosmos-db/continuous-backup-restore-introduction", + "https://learn.microsoft.com/en-us/azure/cosmos-db/migrate-continuous-backup", + "https://learn.microsoft.com/en-us/azure/cosmos-db/restore-account-continuous-backup" + ], + "Remediation": { + "Code": { + "CLI": "az cosmosdb update --name --resource-group --backup-policy-type Continuous --continuous-tier Continuous30Days", + "NativeIaC": "```bicep\n// Bicep: Switch a Cosmos DB account to Continuous backup (irreversible)\nresource account 'Microsoft.DocumentDB/databaseAccounts@2025-10-15' = {\n name: ''\n location: resourceGroup().location\n kind: 'GlobalDocumentDB'\n properties: {\n databaseAccountOfferType: 'Standard'\n locations: [{ locationName: resourceGroup().location }]\n backupPolicy: {\n type: 'Continuous' // Critical: Enables point-in-time restore. Migration from Periodic is one-way.\n continuousModeProperties: {\n tier: 'Continuous30Days' // or 'Continuous7Days' for the lower-cost tier\n }\n }\n }\n}\n```", + "Other": "1. Sign in to the Azure portal and open your Cosmos DB account\n2. In the left menu, select Backup & Restore\n3. Click Switch to Continuous backup\n4. Select the retention tier (Continuous30Days or Continuous7Days)\n5. Acknowledge that the migration from Periodic to Continuous is irreversible\n6. Click Save", + "Terraform": "```hcl\n# Terraform: Configure Cosmos DB account with Continuous backup (irreversible)\nresource \"azurerm_cosmosdb_account\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n offer_type = \"Standard\"\n kind = \"GlobalDocumentDB\"\n\n geo_location {\n location = \"\"\n failover_priority = 0\n }\n\n backup {\n type = \"Continuous\" # Critical: Enables point-in-time restore. One-way migration from Periodic.\n tier = \"Continuous30Days\" # or \"Continuous7Days\" for the lower-cost tier\n }\n}\n```" + }, + "Recommendation": { + "Text": "Use **Continuous backup** for Cosmos DB accounts that require **point-in-time restore (PITR)**. Pick the retention tier (`Continuous7Days` or `Continuous30Days`) based on recovery objectives, and validate restore procedures with periodic drills. Note that switching from **Periodic** to **Continuous** is a **one-way** migration; plan the change and review pricing before applying.", + "Url": "https://hub.prowler.com/check/cosmosdb_account_backup_policy_continuous" + } + }, + "Categories": [ + "forensics-ready" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_backup_policy_continuous/cosmosdb_account_backup_policy_continuous.py b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_backup_policy_continuous/cosmosdb_account_backup_policy_continuous.py new file mode 100644 index 0000000000..f1a00fc2e8 --- /dev/null +++ b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_backup_policy_continuous/cosmosdb_account_backup_policy_continuous.py @@ -0,0 +1,30 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.cosmosdb.cosmosdb_client import cosmosdb_client + + +class cosmosdb_account_backup_policy_continuous(Check): + """Ensure that Cosmos DB accounts use the continuous backup policy.""" + + def execute(self) -> Check_Report_Azure: + """Execute the Cosmos DB continuous-backup check. + + Iterates over every Cosmos DB account fetched by the service and reports + PASS when `backupPolicy.type` is `Continuous`, FAIL otherwise (including + when the property is missing). + + Returns: + A list of Check_Report_Azure with one report per Cosmos DB account. + """ + findings = [] + for subscription, accounts in cosmosdb_client.accounts.items(): + for account in accounts: + report = Check_Report_Azure(metadata=self.metadata(), resource=account) + report.subscription = subscription + report.status = "FAIL" + report.status_extended = f"CosmosDB account {account.name} from subscription {subscription} does not use continuous backup policy." + if account.backup_policy_type == "Continuous": + report.status = "PASS" + report.status_extended = f"CosmosDB account {account.name} from subscription {subscription} uses continuous backup policy." + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_minimum_tls_version/__init__.py b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_minimum_tls_version/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_minimum_tls_version/cosmosdb_account_minimum_tls_version.metadata.json b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_minimum_tls_version/cosmosdb_account_minimum_tls_version.metadata.json new file mode 100644 index 0000000000..f9ddb2baa0 --- /dev/null +++ b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_minimum_tls_version/cosmosdb_account_minimum_tls_version.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "cosmosdb_account_minimum_tls_version", + "CheckTitle": "Cosmos DB account enforces TLS 1.2 or higher", + "CheckType": [], + "ServiceName": "cosmosdb", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.documentdb/databaseaccounts", + "ResourceGroup": "database", + "Description": "**Azure Cosmos DB accounts** are evaluated for **minimum TLS version**. TLS 1.0 and 1.1 are deprecated and contain known weaknesses. Enforcing **TLS 1.2** ensures all client connections negotiate modern, secure encryption protocols.", + "Risk": "Allowing **TLS 1.0/1.1** exposes client connections to **POODLE**, **BEAST**, and other protocol downgrade attacks that can compromise the **confidentiality** and **integrity** of data in transit, and may enable credential interception via weakened cipher suites.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/cosmos-db/security", + "https://learn.microsoft.com/en-us/cli/azure/cosmosdb", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.documentdb/databaseaccounts" + ], + "Remediation": { + "Code": { + "CLI": "az cosmosdb update --name --resource-group --minimal-tls-version Tls12", + "NativeIaC": "```bicep\n// Bicep: Enforce minimum TLS 1.2 on a Cosmos DB account\nresource account 'Microsoft.DocumentDB/databaseAccounts@2025-10-15' = {\n name: ''\n location: resourceGroup().location\n kind: 'GlobalDocumentDB'\n properties: {\n databaseAccountOfferType: 'Standard'\n locations: [{ locationName: resourceGroup().location }]\n minimalTlsVersion: 'Tls12' // Critical: Rejects client connections negotiating TLS 1.0 or 1.1\n }\n}\n```", + "Other": "1. Sign in to the Azure portal and open your Cosmos DB account\n2. In the left menu, select Networking\n3. Locate the Minimum TLS version setting\n4. Select TLS 1.2\n5. Click Save\n6. Validate that all client applications support TLS 1.2 before enforcing the change", + "Terraform": "```hcl\n# Terraform: Enforce minimum TLS 1.2 on a Cosmos DB account\nresource \"azurerm_cosmosdb_account\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n offer_type = \"Standard\"\n kind = \"GlobalDocumentDB\"\n\n geo_location {\n location = \"\"\n failover_priority = 0\n }\n\n minimal_tls_version = \"Tls12\" # Critical: Rejects client connections negotiating TLS 1.0 or 1.1\n}\n```" + }, + "Recommendation": { + "Text": "Set the Cosmos DB account **minimum TLS version** to at least **1.2** to block legacy protocols (`TLS 1.0`/`1.1`) vulnerable to known downgrade and cipher attacks. Inventory and update **client SDKs** and **drivers** to support TLS 1.2 prior to enforcement, and pair this control with **private endpoints**, **AAD/RBAC authentication**, and **handshake failure monitoring** to identify outdated clients.", + "Url": "https://hub.prowler.com/check/cosmosdb_account_minimum_tls_version" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_minimum_tls_version/cosmosdb_account_minimum_tls_version.py b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_minimum_tls_version/cosmosdb_account_minimum_tls_version.py new file mode 100644 index 0000000000..5d85072367 --- /dev/null +++ b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_minimum_tls_version/cosmosdb_account_minimum_tls_version.py @@ -0,0 +1,30 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.cosmosdb.cosmosdb_client import cosmosdb_client + + +class cosmosdb_account_minimum_tls_version(Check): + """Ensure that Cosmos DB accounts enforce TLS 1.2 or higher.""" + + def execute(self) -> Check_Report_Azure: + """Execute the Cosmos DB minimum TLS version check. + + Iterates over every Cosmos DB account fetched by the service and reports + PASS when `minimalTlsVersion` is `Tls12` or higher, FAIL otherwise + (including when the property is missing or set to a legacy value). + + Returns: + A list of Check_Report_Azure with one report per Cosmos DB account. + """ + findings = [] + for subscription, accounts in cosmosdb_client.accounts.items(): + for account in accounts: + report = Check_Report_Azure(metadata=self.metadata(), resource=account) + report.subscription = subscription + report.status = "FAIL" + report.status_extended = f"CosmosDB account {account.name} from subscription {subscription} does not enforce TLS 1.2 or higher." + if account.minimal_tls_version in {"Tls12", "Tls13"}: + report.status = "PASS" + report.status_extended = f"CosmosDB account {account.name} from subscription {subscription} enforces TLS 1.2 or higher." + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_public_network_access_disabled/__init__.py b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_public_network_access_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_public_network_access_disabled/cosmosdb_account_public_network_access_disabled.metadata.json b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_public_network_access_disabled/cosmosdb_account_public_network_access_disabled.metadata.json new file mode 100644 index 0000000000..2d8a61a54e --- /dev/null +++ b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_public_network_access_disabled/cosmosdb_account_public_network_access_disabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "cosmosdb_account_public_network_access_disabled", + "CheckTitle": "Cosmos DB account has public network access disabled", + "CheckType": [], + "ServiceName": "cosmosdb", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.documentdb/databaseaccounts", + "ResourceGroup": "database", + "Description": "**Azure Cosmos DB accounts** are evaluated for **public network access**. Disabling public network access ensures the account is only reachable through **private endpoints** or **VNet service endpoints**, reducing the attack surface and preventing direct exposure of the data plane to the internet.", + "Risk": "Allowing **public network access** exposes the Cosmos DB data plane to the internet. Combined with leaked **connection strings**, weak **firewall rules**, or compromised **AAD tokens**, this enables **unauthorized data access**, **enumeration**, **brute force**, and **data exfiltration** from any source on the internet.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-configure-private-endpoints", + "https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-configure-firewall", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.documentdb/databaseaccounts" + ], + "Remediation": { + "Code": { + "CLI": "az cosmosdb update --name --resource-group --public-network-access Disabled", + "NativeIaC": "```bicep\n// Bicep: Disable public network access on a Cosmos DB account\nresource account 'Microsoft.DocumentDB/databaseAccounts@2025-10-15' = {\n name: ''\n location: resourceGroup().location\n kind: 'GlobalDocumentDB'\n properties: {\n databaseAccountOfferType: 'Standard'\n locations: [{ locationName: resourceGroup().location }]\n publicNetworkAccess: 'Disabled' // Critical: Blocks all public-internet traffic to the data plane\n }\n}\n```", + "Other": "1. Sign in to the Azure portal and open your Cosmos DB account\n2. In the left menu, select Networking\n3. Under Public network access, select Disabled\n4. Configure private endpoints or VNet service endpoints before saving so clients retain connectivity\n5. Click Save", + "Terraform": "```hcl\n# Terraform: Disable public network access on a Cosmos DB account\nresource \"azurerm_cosmosdb_account\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n offer_type = \"Standard\"\n kind = \"GlobalDocumentDB\"\n\n geo_location {\n location = \"\"\n failover_priority = 0\n }\n\n public_network_access_enabled = false # Critical: Blocks all public-internet traffic to the data plane\n}\n```" + }, + "Recommendation": { + "Text": "Disable **public network access** on Cosmos DB accounts and require connectivity via **private endpoints** or **VNet service endpoints**. Before enforcement, validate that all application workloads have private-network connectivity, and pair this control with **AAD/RBAC authentication**, **TLS 1.2+**, and **firewall rules** restricted to the minimum trusted ranges to uphold **defense in depth**.", + "Url": "https://hub.prowler.com/check/cosmosdb_account_public_network_access_disabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_public_network_access_disabled/cosmosdb_account_public_network_access_disabled.py b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_public_network_access_disabled/cosmosdb_account_public_network_access_disabled.py new file mode 100644 index 0000000000..ec2d29728d --- /dev/null +++ b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_public_network_access_disabled/cosmosdb_account_public_network_access_disabled.py @@ -0,0 +1,31 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.cosmosdb.cosmosdb_client import cosmosdb_client + + +class cosmosdb_account_public_network_access_disabled(Check): + """Ensure that Cosmos DB accounts have public network access disabled.""" + + def execute(self) -> Check_Report_Azure: + """Execute the Cosmos DB public network access check. + + Iterates over every Cosmos DB account fetched by the service and reports + PASS when `publicNetworkAccess` is `Disabled` or `SecuredByPerimeter` + (Microsoft Network Security Perimeter), FAIL otherwise (including when + the property is missing or set to `Enabled`). + + Returns: + A list of Check_Report_Azure with one report per Cosmos DB account. + """ + findings = [] + for subscription, accounts in cosmosdb_client.accounts.items(): + for account in accounts: + report = Check_Report_Azure(metadata=self.metadata(), resource=account) + report.subscription = subscription + report.status = "FAIL" + report.status_extended = f"CosmosDB account {account.name} from subscription {subscription} does not have public network access disabled (current value: {account.public_network_access!r})." + if account.public_network_access in {"Disabled", "SecuredByPerimeter"}: + report.status = "PASS" + report.status_extended = f"CosmosDB account {account.name} from subscription {subscription} has public network access disabled." + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_service.py b/prowler/providers/azure/services/cosmosdb/cosmosdb_service.py index c36af1d2e9..e7c53799a7 100644 --- a/prowler/providers/azure/services/cosmosdb/cosmosdb_service.py +++ b/prowler/providers/azure/services/cosmosdb/cosmosdb_service.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import List +from typing import List, Optional from azure.mgmt.cosmosdb import CosmosDBManagementClient @@ -36,14 +36,29 @@ class CosmosDB(AzureService): name=private_endpoint_connection.name, type=private_endpoint_connection.type, ) - for private_endpoint_connection in getattr( - account, "private_endpoint_connections", [] + for private_endpoint_connection in ( + getattr(account, "private_endpoint_connections", []) + or [] ) if private_endpoint_connection ], disable_local_auth=getattr( account, "disable_local_auth", False ), + enable_automatic_failover=getattr( + account, "enable_automatic_failover", False + ), + backup_policy_type=getattr( + getattr(account, "backup_policy", None), + "type", + None, + ), + public_network_access=getattr( + account, "public_network_access", None + ), + minimal_tls_version=getattr( + account, "minimal_tls_version", None + ), ) ) except Exception as error: @@ -71,3 +86,7 @@ class Account: location: str private_endpoint_connections: List[PrivateEndpointConnection] disable_local_auth: bool = False + enable_automatic_failover: bool = False + backup_policy_type: Optional[str] = None + public_network_access: Optional[str] = None + minimal_tls_version: Optional[str] = None diff --git a/prowler/providers/azure/services/databricks/databricks_service.py b/prowler/providers/azure/services/databricks/databricks_service.py index 7920500d88..b7367d3cbb 100644 --- a/prowler/providers/azure/services/databricks/databricks_service.py +++ b/prowler/providers/azure/services/databricks/databricks_service.py @@ -62,10 +62,21 @@ class Databricks(AzureService): else: managed_disk_encryption = None + enable_no_public_ip = getattr( + workspace_parameters, "enable_no_public_ip", None + ) workspaces[subscription][workspace.id] = DatabricksWorkspace( id=workspace.id, name=workspace.name, location=workspace.location, + public_network_access=getattr( + workspace, "public_network_access", None + ), + no_public_ip_enabled=( + getattr(enable_no_public_ip, "value", None) + if enable_no_public_ip + else None + ), custom_managed_vnet_id=( getattr( workspace_parameters, "custom_virtual_network_id", None @@ -107,6 +118,8 @@ class DatabricksWorkspace(BaseModel): id: The unique identifier of the workspace. name: The name of the workspace. location: The Azure region where the workspace is deployed. + public_network_access: Whether public network access is "Enabled" or "Disabled", if configured. + no_public_ip_enabled: Whether secure cluster connectivity (no public IP) is enabled. None when the workspace does not expose this classic-compute setting (e.g. serverless workspaces). custom_managed_vnet_id: The ID of the custom managed virtual network, if configured. managed_disk_encryption: The encryption settings for the workspace's managed disks. """ @@ -114,5 +127,7 @@ class DatabricksWorkspace(BaseModel): id: str name: str location: str + public_network_access: Optional[str] = None + no_public_ip_enabled: Optional[bool] = None custom_managed_vnet_id: Optional[str] = None managed_disk_encryption: Optional[ManagedDiskEncryption] = None diff --git a/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/__init__.py b/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled.metadata.json b/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled.metadata.json new file mode 100644 index 0000000000..4062199474 --- /dev/null +++ b/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "databricks_workspace_no_public_ip_enabled", + "CheckTitle": "Databricks workspace has secure cluster connectivity (no public IP)", + "CheckType": [], + "ServiceName": "databricks", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.databricks/workspaces", + "ResourceGroup": "ai_ml", + "Description": "**Azure Databricks workspaces** are evaluated for **secure cluster connectivity** (No Public IP / NPIP). When enabled, compute (cluster) nodes are deployed without public IP addresses and communicate with the control plane through a secure relay, reducing the workspace's exposure to the internet.", + "Risk": "Without **secure cluster connectivity**, Databricks compute nodes are assigned **public IP addresses** and are directly reachable from the internet. This enables **port scanning**, **lateral movement** from a compromised node, and **data exfiltration** that bypasses private-network controls.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/databricks/security/network/classic/secure-cluster-connectivity", + "https://learn.microsoft.com/en-us/azure/databricks/security/network/classic/vnet-inject", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.databricks/workspaces" + ], + "Remediation": { + "Code": { + "CLI": "az databricks workspace create --name --resource-group --location --sku premium --enable-no-public-ip", + "NativeIaC": "```bicep\n// Bicep: Deploy a Databricks workspace with secure cluster connectivity (No Public IP)\nresource workspace 'Microsoft.Databricks/workspaces@2023-02-01' = {\n name: ''\n location: resourceGroup().location\n sku: {\n name: 'premium'\n }\n properties: {\n managedResourceGroupId: '/subscriptions//resourceGroups/'\n parameters: {\n enableNoPublicIp: {\n value: true // CRITICAL: Deploys cluster nodes without public IP addresses\n }\n }\n }\n}\n```", + "Other": "1. Sign in to the Azure portal and start creating a new Azure Databricks workspace (No Public IP must be set at creation; it cannot be enabled on an existing workspace)\n2. On the Networking tab, set Deploy Azure Databricks workspace with Secure Cluster Connectivity (No Public IP) to Yes\n3. Complete the VNet injection configuration if required\n4. Review + create the workspace\n5. Migrate workloads to the new workspace and decommission the old one", + "Terraform": "```hcl\n# Terraform: Databricks workspace with secure cluster connectivity (No Public IP)\nresource \"azurerm_databricks_workspace\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n sku = \"premium\"\n\n custom_parameters {\n no_public_ip = true # CRITICAL: Deploys cluster nodes without public IP addresses\n }\n}\n```" + }, + "Recommendation": { + "Text": "Deploy Databricks workspaces with **secure cluster connectivity (No Public IP)** so compute nodes have no public IP addresses. Because this is set at creation, plan a migration for existing workspaces, and pair it with **VNet injection**, **private endpoints / disabled public network access**, and least-privilege NSG rules to uphold **defense in depth**.", + "Url": "https://hub.prowler.com/check/databricks_workspace_no_public_ip_enabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Secure cluster connectivity (No Public IP) applies to classic compute and is set at workspace creation. Serverless workspaces do not expose this setting (they have no customer-managed cluster nodes with public IPs) and are reported as MANUAL for verification." +} diff --git a/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled.py b/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled.py new file mode 100644 index 0000000000..13b7742ffa --- /dev/null +++ b/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled.py @@ -0,0 +1,41 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.databricks.databricks_client import ( + databricks_client, +) + + +class databricks_workspace_no_public_ip_enabled(Check): + """ + Ensure Azure Databricks workspaces have secure cluster connectivity (no public IP) enabled. + + This check evaluates whether each Azure Databricks workspace in the subscription is deployed with secure cluster connectivity (No Public IP / NPIP), so cluster nodes are not assigned public IP addresses. + + Secure cluster connectivity is a classic-compute setting. Serverless workspaces do not expose it (they have no customer-managed cluster nodes with public IPs), so the workspace is reported as MANUAL for verification rather than failed. + + - PASS: The workspace has secure cluster connectivity enabled (no_public_ip_enabled is True). + - FAIL: The workspace has secure cluster connectivity disabled (no_public_ip_enabled is False). + - MANUAL: The workspace does not expose the setting (no_public_ip_enabled is None, e.g. serverless workspaces). + """ + + def execute(self): + findings = [] + for subscription, workspaces in databricks_client.workspaces.items(): + subscription_name = databricks_client.subscriptions.get( + subscription, subscription + ) + for workspace in workspaces.values(): + report = Check_Report_Azure( + metadata=self.metadata(), resource=workspace + ) + report.subscription = subscription + if workspace.no_public_ip_enabled is None: + report.status = "MANUAL" + report.status_extended = f"Databricks workspace {workspace.name} in subscription {subscription_name} ({subscription}) does not expose secure cluster connectivity (no public IP) settings (for example, serverless workspaces have no public-IP cluster nodes); verify the network configuration manually." + elif workspace.no_public_ip_enabled: + report.status = "PASS" + report.status_extended = f"Databricks workspace {workspace.name} in subscription {subscription_name} ({subscription}) has secure cluster connectivity (no public IP) enabled." + else: + report.status = "FAIL" + report.status_extended = f"Databricks workspace {workspace.name} in subscription {subscription_name} ({subscription}) does not have secure cluster connectivity (no public IP) enabled." + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/databricks/databricks_workspace_public_network_access_disabled/__init__.py b/prowler/providers/azure/services/databricks/databricks_workspace_public_network_access_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/databricks/databricks_workspace_public_network_access_disabled/databricks_workspace_public_network_access_disabled.metadata.json b/prowler/providers/azure/services/databricks/databricks_workspace_public_network_access_disabled/databricks_workspace_public_network_access_disabled.metadata.json new file mode 100644 index 0000000000..cb92d6df0f --- /dev/null +++ b/prowler/providers/azure/services/databricks/databricks_workspace_public_network_access_disabled/databricks_workspace_public_network_access_disabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "databricks_workspace_public_network_access_disabled", + "CheckTitle": "Databricks workspace has public network access disabled", + "CheckType": [], + "ServiceName": "databricks", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "microsoft.databricks/workspaces", + "ResourceGroup": "ai_ml", + "Description": "**Azure Databricks workspaces** are evaluated for **public network access**. Disabling public network access ensures the workspace is only reachable through **private endpoints**, reducing the attack surface and preventing direct exposure of the control plane and data plane to the internet.", + "Risk": "Allowing **public network access** exposes the Databricks workspace control plane and data plane to the internet. Combined with leaked **credentials**, **personal access tokens**, or unpatched vulnerabilities, this enables **unauthorized access**, **brute force**, and **data exfiltration** from any source on the internet.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/databricks/security/network/classic/private-link", + "https://learn.microsoft.com/en-us/azure/databricks/security/network/classic/private-link#step-4-disable-public-network-access", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.databricks/workspaces" + ], + "Remediation": { + "Code": { + "CLI": "az databricks workspace update --name --resource-group --public-network-access Disabled", + "NativeIaC": "```bicep\n// Bicep: Disable public network access on a Databricks workspace\nresource workspace 'Microsoft.Databricks/workspaces@2023-02-01' = {\n name: ''\n location: resourceGroup().location\n sku: {\n name: 'premium'\n }\n properties: {\n managedResourceGroupId: '/subscriptions//resourceGroups/'\n publicNetworkAccess: 'Disabled' // CRITICAL: Blocks all public-internet traffic to the workspace\n }\n}\n```", + "Other": "1. Sign in to the Azure portal and open your Databricks workspace\n2. In the left menu, select Networking\n3. Under Public network access, select Disabled\n4. Configure Azure Private Link private endpoints before saving so clients retain connectivity\n5. Click Save", + "Terraform": "```hcl\n# Terraform: Disable public network access on a Databricks workspace\nresource \"azurerm_databricks_workspace\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n sku = \"premium\"\n\n public_network_access_enabled = false # CRITICAL: Blocks all public-internet traffic to the workspace\n}\n```" + }, + "Recommendation": { + "Text": "Disable **public network access** on Databricks workspaces and require connectivity via **Azure Private Link** private endpoints. Before enforcement, validate that all application workloads have private-network connectivity, and pair this control with **VNet injection**, **secure cluster connectivity (no public IP)**, and least-privilege access to uphold **defense in depth**.", + "Url": "https://hub.prowler.com/check/databricks_workspace_public_network_access_disabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/databricks/databricks_workspace_public_network_access_disabled/databricks_workspace_public_network_access_disabled.py b/prowler/providers/azure/services/databricks/databricks_workspace_public_network_access_disabled/databricks_workspace_public_network_access_disabled.py new file mode 100644 index 0000000000..e8193cedb6 --- /dev/null +++ b/prowler/providers/azure/services/databricks/databricks_workspace_public_network_access_disabled/databricks_workspace_public_network_access_disabled.py @@ -0,0 +1,35 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.databricks.databricks_client import ( + databricks_client, +) + + +class databricks_workspace_public_network_access_disabled(Check): + """ + Ensure Azure Databricks workspaces have public network access disabled. + + This check evaluates whether each Azure Databricks workspace in the subscription restricts connectivity to private endpoints by disabling public network access. + + - PASS: The workspace has public network access disabled (public_network_access is "Disabled"). + - FAIL: The workspace has public network access enabled (or the value is not set). + """ + + def execute(self): + findings = [] + for subscription, workspaces in databricks_client.workspaces.items(): + subscription_name = databricks_client.subscriptions.get( + subscription, subscription + ) + for workspace in workspaces.values(): + report = Check_Report_Azure( + metadata=self.metadata(), resource=workspace + ) + report.subscription = subscription + if workspace.public_network_access == "Disabled": + report.status = "PASS" + report.status_extended = f"Databricks workspace {workspace.name} in subscription {subscription_name} ({subscription}) has public network access disabled." + else: + report.status = "FAIL" + report.status_extended = f"Databricks workspace {workspace.name} in subscription {subscription_name} ({subscription}) has public network access enabled." + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/__init__.py b/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on.metadata.json b/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on.metadata.json new file mode 100644 index 0000000000..cae92490af --- /dev/null +++ b/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "defender_ensure_defender_cspm_is_on", + "CheckTitle": "Microsoft Defender CSPM is set to On", + "CheckType": [], + "ServiceName": "defender", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.security/pricings", + "ResourceGroup": "security", + "Description": "**Microsoft Defender for Cloud** Cloud Security Posture Management (CSPM) plan is evaluated for **standard tier** activation. Defender CSPM provides advanced posture management capabilities including attack path analysis, cloud security explorer, agentless scanning, and governance rules.", + "Risk": "Without Defender CSPM, the subscription relies on **foundational CSPM** (free tier) which lacks attack path analysis, agentless vulnerability scanning, and security governance. Advanced threats exploiting misconfiguration chains go undetected.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/defender-for-cloud/concept-cloud-security-posture-management", + "https://learn.microsoft.com/en-us/azure/defender-for-cloud/enable-enhanced-security" + ], + "Remediation": { + "Code": { + "CLI": "az security pricing create -n CloudPosture --tier Standard", + "NativeIaC": "```bicep\ntargetScope = 'subscription'\n\nresource defenderCSPM 'Microsoft.Security/pricings@2024-01-01' = {\n name: 'CloudPosture'\n properties: {\n pricingTier: 'Standard' // Critical: enables Defender CSPM\n }\n}\n```", + "Other": "1. Sign in to Azure portal\n2. Go to Microsoft Defender for Cloud\n3. Select Environment Settings\n4. Click on the subscription\n5. Set Cloud Security Posture Management (CSPM) to On\n6. Click Save", + "Terraform": "```hcl\nresource \"azurerm_security_center_subscription_pricing\" \"\" {\n tier = \"Standard\" # Critical: enables Defender CSPM\n resource_type = \"CloudPosture\"\n}\n```" + }, + "Recommendation": { + "Text": "Enable **Defender CSPM** standard tier for advanced cloud security posture management. Evaluate the cost against the security benefits \u2014 CSPM provides attack path analysis and agentless scanning.", + "Url": "https://hub.prowler.com/check/defender_ensure_defender_cspm_is_on" + } + }, + "Categories": [ + "threat-detection" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on.py b/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on.py new file mode 100644 index 0000000000..cf7957400c --- /dev/null +++ b/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on.py @@ -0,0 +1,35 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.defender.defender_client import defender_client + + +class defender_ensure_defender_cspm_is_on(Check): + """ + Ensure Microsoft Defender Cloud Security Posture Management (CSPM) is set to On. + + This check evaluates whether the Defender CSPM plan (CloudPosture pricing) is enabled with the Standard tier for each subscription. + + - PASS: The CloudPosture pricing tier is "Standard" (Defender CSPM is on). + - FAIL: The CloudPosture pricing tier is not "Standard" (Defender CSPM is off). + """ + + def execute(self) -> Check_Report_Azure: + findings = [] + for subscription, pricings in defender_client.pricings.items(): + subscription_name = defender_client.subscriptions.get( + subscription, subscription + ) + if "CloudPosture" in pricings: + report = Check_Report_Azure( + metadata=self.metadata(), + resource=pricings["CloudPosture"], + ) + report.subscription = subscription + report.resource_name = "Defender plan CSPM" + report.status = "PASS" + report.status_extended = f"Defender plan CSPM from subscription {subscription_name} ({subscription}) is set to ON (pricing tier standard)." + if pricings["CloudPosture"].pricing_tier != "Standard": + report.status = "FAIL" + report.status_extended = f"Defender plan CSPM from subscription {subscription_name} ({subscription}) is set to OFF (pricing tier not standard)." + + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/__init__.py b/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.metadata.json b/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.metadata.json new file mode 100644 index 0000000000..1dac541283 --- /dev/null +++ b/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "mysql_flexible_server_geo_redundant_backup_enabled", + "CheckTitle": "MySQL flexible server has geo-redundant backup enabled", + "CheckType": [], + "ServiceName": "mysql", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.dbformysql/flexibleservers", + "ResourceGroup": "database", + "Description": "**Azure MySQL Flexible Server** is evaluated for **geo-redundant backup**. Geo-redundant backup stores backup copies in a paired Azure region, enabling restore and recovery from a full regional outage.", + "Risk": "Without **geo-redundant backup**, a regional disaster can cause **permanent data loss**. Locally redundant backups only protect against storage hardware failures within the same region and cannot be restored if the primary region becomes unavailable.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/mysql/flexible-server/concepts-backup-restore", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.dbformysql/flexibleservers" + ], + "Remediation": { + "Code": { + "CLI": "az mysql flexible-server create --name --resource-group --location --geo-redundant-backup Enabled", + "NativeIaC": "```bicep\n// Bicep: MySQL Flexible Server with geo-redundant backup (set at creation)\nresource mysql 'Microsoft.DBforMySQL/flexibleServers@2023-12-30' = {\n name: ''\n location: ''\n properties: {\n backup: {\n geoRedundantBackup: 'Enabled' // CRITICAL: stores backups in the paired region\n }\n }\n}\n```", + "Other": "1. Geo-redundant backup must be configured when the server is created; it cannot be enabled on an existing server\n2. In the Azure portal, start creating a new Azure Database for MySQL flexible server\n3. On the Basics tab, under Compute + storage, open Configure server\n4. Set Geographically redundant backup to Enabled and save\n5. Finish creating the server and migrate workloads to it", + "Terraform": "```hcl\n# Terraform: MySQL Flexible Server with geo-redundant backup (set at creation)\nresource \"azurerm_mysql_flexible_server\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n geo_redundant_backup_enabled = true # CRITICAL: stores backups in the paired region\n}\n```" + }, + "Recommendation": { + "Text": "Enable **geo-redundant backup** on MySQL Flexible Servers so backups are replicated to the paired Azure region and can be restored during a regional outage. Because this is set at server creation, plan a migration for existing servers, and pair it with an appropriate backup retention period and periodic restore testing.", + "Url": "https://hub.prowler.com/check/mysql_flexible_server_geo_redundant_backup_enabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Geo-redundant backup for Azure MySQL Flexible Server can only be configured at server creation time and cannot be changed afterwards." +} diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.py b/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.py new file mode 100644 index 0000000000..5b1685354c --- /dev/null +++ b/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.py @@ -0,0 +1,31 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.mysql.mysql_client import mysql_client + + +class mysql_flexible_server_geo_redundant_backup_enabled(Check): + """ + Ensure Azure MySQL Flexible Servers have geo-redundant backup enabled. + + This check evaluates whether each Azure MySQL Flexible Server stores backups in a paired Azure region, enabling recovery from a full regional outage. + + - PASS: The server has geo-redundant backup enabled (geo_redundant_backup is "Enabled"). + - FAIL: The server does not have geo-redundant backup enabled. + """ + + def execute(self) -> Check_Report_Azure: + findings = [] + for subscription_id, servers in mysql_client.flexible_servers.items(): + subscription_name = mysql_client.subscriptions.get( + subscription_id, subscription_id + ) + for server in servers.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=server) + report.subscription = subscription_id + if server.geo_redundant_backup == "Enabled": + report.status = "PASS" + report.status_extended = f"Geo-redundant backup is enabled for server {server.name} in subscription {subscription_name} ({subscription_id})." + else: + report.status = "FAIL" + report.status_extended = f"Geo-redundant backup is disabled for server {server.name} in subscription {subscription_name} ({subscription_id})." + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/__init__.py b/prowler/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/mysql_flexible_server_high_availability_enabled.metadata.json b/prowler/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/mysql_flexible_server_high_availability_enabled.metadata.json new file mode 100644 index 0000000000..22a9b93401 --- /dev/null +++ b/prowler/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/mysql_flexible_server_high_availability_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "mysql_flexible_server_high_availability_enabled", + "CheckTitle": "MySQL flexible server has high availability enabled", + "CheckType": [], + "ServiceName": "mysql", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.dbformysql/flexibleservers", + "ResourceGroup": "database", + "Description": "**Azure MySQL Flexible Server** is evaluated for **high availability** mode. Zone-redundant or same-zone HA provisions a standby replica and provides automatic failover during planned and unplanned outages.", + "Risk": "Without **high availability**, a server or zone failure causes **downtime** until manual recovery or redeployment. Applications experience extended unavailability and potential data loss for in-flight transactions during unplanned outages.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/mysql/flexible-server/concepts-high-availability", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.dbformysql/flexibleservers" + ], + "Remediation": { + "Code": { + "CLI": "az mysql flexible-server update --name --resource-group --high-availability ZoneRedundant", + "NativeIaC": "```bicep\n// Bicep: MySQL Flexible Server with zone-redundant high availability\nresource mysql 'Microsoft.DBforMySQL/flexibleServers@2023-12-30' = {\n name: ''\n location: ''\n sku: {\n name: 'Standard_D2ds_v4'\n tier: 'GeneralPurpose'\n }\n properties: {\n highAvailability: {\n mode: 'ZoneRedundant' // CRITICAL: provisions a standby replica with automatic failover\n }\n }\n}\n```", + "Other": "1. In the Azure portal, open your Azure Database for MySQL flexible server\n2. Under Settings, select High availability\n3. Enable High availability and choose Zone redundant (or Same zone)\n4. Save (high availability requires the General Purpose or Business Critical tier)", + "Terraform": "```hcl\n# Terraform: MySQL Flexible Server with zone-redundant high availability\nresource \"azurerm_mysql_flexible_server\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n sku_name = \"GP_Standard_D2ds_v4\"\n\n high_availability {\n mode = \"ZoneRedundant\" # CRITICAL: provisions a standby replica with automatic failover\n }\n}\n```" + }, + "Recommendation": { + "Text": "Enable **high availability** (zone-redundant where supported, otherwise same-zone) on production MySQL Flexible Servers so a standby replica provides automatic failover. High availability requires the General Purpose or Business Critical tier; pair it with geo-redundant backup and periodic failover testing for full resilience.", + "Url": "https://hub.prowler.com/check/mysql_flexible_server_high_availability_enabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "High availability for Azure MySQL Flexible Server requires the General Purpose or Business Critical compute tier and is not available on the Burstable tier." +} diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/mysql_flexible_server_high_availability_enabled.py b/prowler/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/mysql_flexible_server_high_availability_enabled.py new file mode 100644 index 0000000000..e1f3054baf --- /dev/null +++ b/prowler/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/mysql_flexible_server_high_availability_enabled.py @@ -0,0 +1,34 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.mysql.mysql_client import mysql_client + + +class mysql_flexible_server_high_availability_enabled(Check): + """ + Ensure Azure MySQL Flexible Servers have high availability enabled. + + This check evaluates whether each Azure MySQL Flexible Server is configured with high availability (zone-redundant or same-zone), providing automatic failover to a standby replica during outages. + + - PASS: The server has high availability enabled (high_availability_mode is set and not "Disabled"). + - FAIL: The server does not have high availability enabled. + """ + + def execute(self) -> Check_Report_Azure: + findings = [] + for subscription_id, servers in mysql_client.flexible_servers.items(): + subscription_name = mysql_client.subscriptions.get( + subscription_id, subscription_id + ) + for server in servers.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=server) + report.subscription = subscription_id + if ( + server.high_availability_mode is not None + and server.high_availability_mode != "Disabled" + ): + report.status = "PASS" + report.status_extended = f"High availability is enabled for server {server.name} in subscription {subscription_name} ({subscription_id})." + else: + report.status = "FAIL" + report.status_extended = f"High availability is disabled for server {server.name} in subscription {subscription_name} ({subscription_id})." + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/mysql/mysql_service.py b/prowler/providers/azure/services/mysql/mysql_service.py index 565e14da01..b3a386a193 100644 --- a/prowler/providers/azure/services/mysql/mysql_service.py +++ b/prowler/providers/azure/services/mysql/mysql_service.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import Optional from azure.mgmt.rdbms.mysql_flexibleservers import MySQLManagementClient @@ -21,6 +22,8 @@ class MySQL(AzureService): servers_list = client.servers.list() servers.update({subscription_id: {}}) for server in servers_list: + backup = getattr(server, "backup", None) + ha = getattr(server, "high_availability", None) servers[subscription_id].update( { server.id: FlexibleServer( @@ -31,6 +34,10 @@ class MySQL(AzureService): configurations=self._get_configurations( client, server.id.split("/")[4], server.name ), + geo_redundant_backup=getattr( + backup, "geo_redundant_backup", None + ), + high_availability_mode=getattr(ha, "mode", None), ) } ) @@ -78,3 +85,5 @@ class FlexibleServer: location: str version: str configurations: dict[Configuration] + geo_redundant_backup: Optional[str] = None + high_availability_mode: Optional[str] = None diff --git a/prowler/providers/common/arguments.py b/prowler/providers/common/arguments.py index 9745bab2ca..2e4d9c8896 100644 --- a/prowler/providers/common/arguments.py +++ b/prowler/providers/common/arguments.py @@ -10,24 +10,107 @@ provider_arguments_lib_path = "lib.arguments.arguments" validate_provider_arguments_function = "validate_arguments" init_provider_arguments_function = "init_parser" +# Kept in sync with parser.py's argv normalisation; both consumers import this. +PROVIDER_ALIASES = { + "microsoft365": "m365", + "oci": "oraclecloud", +} + + +def _invoked_provider_from_argv(available_providers: Sequence[str]) -> Optional[str]: + """Return the provider name the user invoked, or None. + + Mirrors `ProwlerArgumentParser.parse()` resolution: only inspects + `sys.argv[1]`. Scanning the whole argv would misclassify + `prowler --output-directory stackit` as `stackit`. + """ + available = set(available_providers) + if len(sys.argv) < 2: + return "aws" if "aws" in available else None + first = sys.argv[1] + if first in ("-h", "--help", "-v", "--version"): + return None + if first.startswith("-"): + return "aws" if "aws" in available else None + normalized = PROVIDER_ALIASES.get(first, first) + return normalized if normalized in available else None + def init_providers_parser(self): - """init_providers_parser calls the provider init_parser function to load all the arguments and flags. Receives a ProwlerArgumentParser object""" - # We need to call the arguments parser for each provider + """Build the subparser of each available provider. + + Built-in load failures are captured silently on + `self._builtin_load_failures`; the warn/exit decision is deferred to + `enforce_invoked_provider_loaded()` because `parse(args=...)` can + override `sys.argv` after this function ran. + """ + self._builtin_load_failures = {} providers = Provider.get_available_providers() for provider in providers: - try: - getattr( - import_module( - f"{providers_path}.{provider}.{provider_arguments_lib_path}" - ), - init_provider_arguments_function, - )(self) - except Exception as error: - logger.critical( + if Provider.is_builtin(provider): + try: + getattr( + import_module( + f"{providers_path}.{provider}.{provider_arguments_lib_path}" + ), + init_provider_arguments_function, + )(self) + except Exception as error: + self._builtin_load_failures[provider] = error + else: + cls = Provider._load_ep_provider(provider) + if cls and hasattr(cls, "init_parser"): + try: + cls.init_parser(self) + except Exception as error: + logger.warning( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +def enforce_invoked_provider_loaded(self): + """Apply selective fail-loud over the failures captured at init time. + + Called by `ProwlerArgumentParser.parse()` AFTER argv normalisation so + the invoked provider matches what argparse will dispatch to — including + the case where `parse(args=...)` overrode the ambient `sys.argv`. + + Invoked + failed → critical + `sys.exit(1)`. Others → warning. + """ + failures = getattr(self, "_builtin_load_failures", {}) + if not failures: + return + invoked = _invoked_provider_from_argv(Provider.get_available_providers()) + for provider, error in failures.items(): + if provider == invoked: + continue + if isinstance(error, ImportError): + logger.warning( + f"Skipping built-in provider '{provider}' due to missing " + f"dependency: {error}. It will be unavailable in this " + f"invocation, but the CLI continues because you invoked a " + f"different provider." + ) + else: + logger.warning( + f"Skipping built-in provider '{provider}': " f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) - sys.exit(1) + if invoked is None or invoked not in failures: + return + error = failures[invoked] + if isinstance(error, ImportError): + logger.critical( + f"Failed to load arguments for built-in provider '{invoked}'. " + f"Missing dependency: {error}. " + f"Ensure all required dependencies are installed." + ) + logger.debug("Full traceback:", exc_info=True) + else: + logger.critical( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + sys.exit(1) def validate_provider_arguments(arguments: Namespace) -> tuple[bool, str]: diff --git a/prowler/providers/common/builtin.py b/prowler/providers/common/builtin.py new file mode 100644 index 0000000000..d60b5483d9 --- /dev/null +++ b/prowler/providers/common/builtin.py @@ -0,0 +1,29 @@ +"""Leaf helper for built-in provider detection. + +Lives in its own module — with no imports back into `prowler.lib.check` — so +that callers in `prowler.lib.check.*` can ask "is this provider built-in?" +without creating an import cycle through `prowler.providers.common.provider` +(which transitively imports `prowler.config.config` and from there +`prowler.lib.check.compliance_models` / `prowler.lib.check.external_tool_providers`). + +Same rationale as `prowler.lib.check.tool_wrapper`: extracting the predicate +to a leaf module is the canonical way to break the cycle in this codebase. +""" + +import importlib.util + + +def is_builtin_provider(provider: str) -> bool: + """Return True if the provider's own package ships with the SDK. + + Wraps `importlib.util.find_spec` in `try/except (ImportError, ValueError)` + because `find_spec` propagates `ModuleNotFoundError` when a parent package + in the dotted path does not exist (instead of returning `None`). The + try/except is what makes the call safe for external providers, whose + package does not live under `prowler.providers.{provider}`. + """ + try: + spec = importlib.util.find_spec(f"prowler.providers.{provider}") + return spec is not None + except (ImportError, ValueError): + return False diff --git a/prowler/providers/common/models.py b/prowler/providers/common/models.py index ea70252f0a..120cc1a374 100644 --- a/prowler/providers/common/models.py +++ b/prowler/providers/common/models.py @@ -4,6 +4,7 @@ from os.path import isdir from pydantic.v1 import BaseModel +from prowler.config.config import output_file_timestamp from prowler.providers.common.provider import Provider @@ -69,3 +70,15 @@ class Connection: is_connected: bool = False error: Exception = None + + +def default_output_options(provider, arguments, bulk_checks_metadata): + """Generic OutputOptions fallback for external providers that do not + implement get_output_options, so the run still produces output instead of + aborting. Honors arguments.output_filename and otherwise derives a name + from the provider type.""" + output_options = ProviderOutputOptions(arguments, bulk_checks_metadata) + output_options.output_filename = getattr(arguments, "output_filename", None) or ( + f"prowler-output-{provider.type}-{output_file_timestamp}" + ) + return output_options diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py index d11b015d54..77ae9180a3 100644 --- a/prowler/providers/common/provider.py +++ b/prowler/providers/common/provider.py @@ -1,4 +1,6 @@ import importlib +import importlib.metadata +import importlib.util import os import pkgutil import sys @@ -136,6 +138,106 @@ class Provider(ABC): """ return set() + # --- Dynamic provider contract methods (not @abstractmethod for incremental migration) --- + + _cli_help_text: str = "" + + @classmethod + def from_cli_args(cls, arguments: Namespace, fixer_config: dict) -> "Provider": + """Instantiate the provider from CLI arguments and return the instance. + + The caller wires the returned instance into the global provider slot + via Provider.set_global_provider(). Implementations that already call + set_global_provider(self) from __init__ are also supported — the call + site tolerates a None return in that case. + """ + raise NotImplementedError(f"{cls.__name__} has not implemented from_cli_args()") + + def get_output_options(self, arguments, _bulk_checks_metadata): + """Create the provider-specific OutputOptions.""" + raise NotImplementedError( + f"{self.__class__.__name__} has not implemented get_output_options()" + ) + + def get_stdout_detail(self, _finding) -> str: + """Return the detail string for stdout reporting (region, location, etc.).""" + raise NotImplementedError( + f"{self.__class__.__name__} has not implemented get_stdout_detail()" + ) + + def get_finding_sort_key(self) -> Optional[str]: + """Return the attribute name to sort findings by, or None for no sorting.""" + return None + + def get_summary_entity(self) -> tuple: + """Return (entity_type, audited_entities) for the summary table.""" + return (self.type, getattr(self.identity, "account_id", "")) + + def get_finding_output_data(self, _check_output) -> dict: + """Return provider-specific fields for Finding.generate_output().""" + raise NotImplementedError( + f"{self.__class__.__name__} has not implemented get_finding_output_data()" + ) + + def get_html_assessment_summary(self) -> str: + """Return the HTML assessment summary card for this provider.""" + raise NotImplementedError( + f"{self.__class__.__name__} has not implemented get_html_assessment_summary()" + ) + + def generate_compliance_output( + self, + _findings, + _bulk_compliance_frameworks, + _input_compliance_frameworks, + _output_options, + _generated_outputs, + ) -> None: + """Generate compliance CSV output for this provider's frameworks.""" + raise NotImplementedError( + f"{self.__class__.__name__} has not implemented generate_compliance_output()" + ) + + def get_mutelist_finding_args(self) -> dict: + """Return extra kwargs for mutelist.is_finding_muted() besides 'finding'. + + External providers must return a dict with the identity key their + Mutelist subclass expects, e.g. ``{"account_id": self.identity.account_id}``. + The ``finding`` kwarg is added automatically by the caller. + """ + raise NotImplementedError( + f"{self.__class__.__name__} has not implemented get_mutelist_finding_args()" + ) + + def display_compliance_table( + self, + _findings: list, + _bulk_checks_metadata: dict, + _compliance_framework: str, + _output_filename: str, + _output_directory: str, + _compliance_overview: bool, + ) -> bool: + """Render a custom compliance table in the terminal. + + External providers can override this to display a detailed + compliance table (e.g., per-section breakdown). Return True + if the table was rendered, False to fall back to the generic table. + """ + raise NotImplementedError( + f"{self.__class__.__name__} has not implemented display_compliance_table()" + ) + + # Class-level flag: True for providers that delegate scanning to an external + # tool (e.g. Trivy, promptfoo) and bypass standard check/service loading and + # metadata validation. Subclasses override as `is_external_tool_provider = True`. + # Kept as a class attribute (not a property) so it can be read from the class + # without instantiation — the metadata validators in lib.check.models need to + # decide whether to relax validation before any provider instance exists. + is_external_tool_provider: bool = False + + # --- End dynamic provider contract methods --- + @staticmethod def get_excluded_regions_from_env() -> set: """Parse the PROWLER_AWS_DISALLOWED_REGIONS environment variable. @@ -159,20 +261,57 @@ class Provider(ABC): @staticmethod def init_global_provider(arguments: Namespace) -> None: try: - provider_class_path = ( - f"{providers_path}.{arguments.provider}.{arguments.provider}_provider" - ) - provider_class_name = f"{arguments.provider.capitalize()}Provider" - provider_class = getattr( - import_module(provider_class_path), provider_class_name - ) + # Delegate class resolution to the public, side-effect-free + # resolver. init_global_provider owns the CLI-specific error + # handling: a missing transitive dep in a built-in becomes a + # logger.critical + sys.exit(1); a completely unknown provider + # re-raises so the outer try/except can sys.exit too. + try: + provider_class = Provider.get_class(arguments.provider) + except ImportError as e: + if Provider.is_builtin(arguments.provider): + # Built-in's transitive dependency is missing — loud CLI error. + logger.critical( + f"Failed to load built-in provider '{arguments.provider}'. " + f"Missing dependency: {e}. " + f"Ensure all required dependencies are installed." + ) + logger.debug("Full traceback:", exc_info=True) + sys.exit(1) + # Unknown or missing external provider — propagate so the + # outer try/except can handle it (sys.exit(1) via generic + # exception handler). + raise + + # Built-in wins on name collision — warn that a same-named + # plug-in is ignored. This lives here (not in get_class) so + # that `prowler --help` and API callers that resolve a class + # without initialising a global provider do not see spurious + # warnings. Match by name only — never ep.load() a shadowing + # plug-in, or its module code would run during a built-in run. + if Provider.is_builtin(arguments.provider) and any( + ep.name == arguments.provider + for ep in importlib.metadata.entry_points(group="prowler.providers") + ): + logger.warning( + f"Plug-in provider '{arguments.provider}' registered " + f"via entry points is being IGNORED — a built-in with " + f"the same name exists. To use your plug-in, register " + f"it under a different name." + ) fixer_config = load_and_validate_config_file( arguments.provider, arguments.fixer_config ) + # Dispatch by exact provider name (equality, not substring) so + # external plug-ins whose names contain a built-in substring + # (e.g. `awsx`, `azure_gov`, `iac_v2`) cannot be silently routed + # to the wrong built-in branch. Anything that doesn't match a + # built-in falls through to the dynamic else and uses the + # contract's `from_cli_args`. if not isinstance(Provider._global, provider_class): - if "aws" in provider_class_name.lower(): + if arguments.provider == "aws": excluded_regions = ( set(arguments.excluded_region) if getattr(arguments, "excluded_region", None) @@ -196,7 +335,7 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "azure" in provider_class_name.lower(): + elif arguments.provider == "azure": provider_class( az_cli_auth=arguments.az_cli_auth, sp_env_auth=arguments.sp_env_auth, @@ -209,7 +348,7 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "gcp" in provider_class_name.lower(): + elif arguments.provider == "gcp": provider_class( retries_max_attempts=arguments.gcp_retries_max_attempts, organization_id=arguments.organization_id, @@ -223,7 +362,7 @@ class Provider(ABC): fixer_config=fixer_config, skip_api_check=arguments.skip_api_check, ) - elif "kubernetes" in provider_class_name.lower(): + elif arguments.provider == "kubernetes": provider_class( kubeconfig_file=arguments.kubeconfig_file, context=arguments.context, @@ -233,7 +372,7 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "m365" in provider_class_name.lower(): + elif arguments.provider == "m365": provider_class( region=arguments.region, config_path=arguments.config_file, @@ -247,7 +386,7 @@ class Provider(ABC): init_modules=arguments.init_modules, fixer_config=fixer_config, ) - elif "nhn" in provider_class_name.lower(): + elif arguments.provider == "nhn": provider_class( username=arguments.nhn_username, password=arguments.nhn_password, @@ -256,7 +395,7 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "stackit" in provider_class_name.lower(): + elif arguments.provider == "stackit": provider_class( project_id=arguments.stackit_project_id, service_account_key_path=getattr( @@ -275,7 +414,7 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "github" in provider_class_name.lower(): + elif arguments.provider == "github": orgs = [] repos = [] @@ -307,13 +446,13 @@ class Provider(ABC): exclude_workflows=getattr(arguments, "exclude_workflows", []), fixer_config=fixer_config, ) - elif "googleworkspace" in provider_class_name.lower(): + elif arguments.provider == "googleworkspace": provider_class( config_path=arguments.config_file, mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "cloudflare" in provider_class_name.lower(): + elif arguments.provider == "cloudflare": provider_class( filter_zones=arguments.region, filter_accounts=arguments.account_id, @@ -321,7 +460,7 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "iac" in provider_class_name.lower(): + elif arguments.provider == "iac": provider_class( scan_path=arguments.scan_path, scan_repository_url=arguments.scan_repository_url, @@ -334,13 +473,13 @@ class Provider(ABC): oauth_app_token=arguments.oauth_app_token, provider_uid=arguments.provider_uid, ) - elif "llm" in provider_class_name.lower(): + elif arguments.provider == "llm": provider_class( max_concurrency=arguments.max_concurrency, config_path=arguments.config_file, fixer_config=fixer_config, ) - elif "image" in provider_class_name.lower(): + elif arguments.provider == "image": provider_class( images=arguments.images, image_list_file=arguments.image_list_file, @@ -358,7 +497,7 @@ class Provider(ABC): registry_insecure=arguments.registry_insecure, registry_list_images=arguments.registry_list_images, ) - elif "mongodbatlas" in provider_class_name.lower(): + elif arguments.provider == "mongodbatlas": provider_class( atlas_public_key=arguments.atlas_public_key, atlas_private_key=arguments.atlas_private_key, @@ -367,7 +506,7 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "oraclecloud" in provider_class_name.lower(): + elif arguments.provider == "oraclecloud": provider_class( oci_config_file=arguments.oci_config_file, profile=arguments.profile, @@ -378,7 +517,7 @@ class Provider(ABC): fixer_config=fixer_config, use_instance_principal=arguments.use_instance_principal, ) - elif "openstack" in provider_class_name.lower(): + elif arguments.provider == "openstack": provider_class( clouds_yaml_file=getattr(arguments, "clouds_yaml_file", None), clouds_yaml_content=getattr( @@ -403,7 +542,7 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "alibabacloud" in provider_class_name.lower(): + elif arguments.provider == "alibabacloud": provider_class( role_arn=arguments.role_arn, role_session_name=arguments.role_session_name, @@ -415,14 +554,14 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "vercel" in provider_class_name.lower(): + elif arguments.provider == "vercel": provider_class( projects=getattr(arguments, "project", None), config_path=arguments.config_file, mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "okta" in provider_class_name.lower(): + elif arguments.provider == "okta": provider_class( okta_org_domain=getattr(arguments, "okta_org_domain", ""), okta_client_id=getattr(arguments, "okta_client_id", ""), @@ -435,7 +574,7 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) - elif "scaleway" in provider_class_name.lower(): + elif arguments.provider == "scaleway": # Credentials are read from the SCW_ACCESS_KEY / # SCW_SECRET_KEY env vars by the provider itself; there # are no credential CLI flags to avoid leaking secrets. @@ -447,6 +586,18 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) + else: + # Dynamic fallback: any external/custom provider. + # Honor the from_cli_args type hint (-> Provider): if the + # implementation returns an instance, wire it as the global + # provider here. Implementations that call + # set_global_provider(self) from __init__ return None and + # remain supported (the condition below is a no-op for them). + provider_instance = provider_class.from_cli_args( + arguments, fixer_config + ) + if provider_instance is not None: + Provider.set_global_provider(provider_instance) except TypeError as error: logger.critical( @@ -459,17 +610,141 @@ class Provider(ABC): ) sys.exit(1) + # Cache for entry-point provider classes {name: class} + _ep_providers: dict = {} + @staticmethod def get_available_providers() -> list[str]: """get_available_providers returns a list of the available providers""" - providers = [] - # Dynamically import the package based on its string path + providers = set() + # Built-in providers from local package prowler_providers = importlib.import_module(providers_path) - # Iterate over all modules found in the prowler_providers package for _, provider, ispkg in pkgutil.iter_modules(prowler_providers.__path__): if provider != "common" and ispkg: - providers.append(provider) - return providers + providers.add(provider) + # External providers registered via entry points + for ep in importlib.metadata.entry_points(group="prowler.providers"): + providers.add(ep.name) + return sorted(providers) + + @staticmethod + def is_tool_wrapper_provider(provider: str) -> bool: + """Return True if the provider delegates scanning to an external tool. + + Delegates to `prowler.lib.check.tool_wrapper.is_tool_wrapper_provider`, + the leaf module that holds the actual logic. Kept on `Provider` as a + convenience entry point for callers that already import `Provider`. + """ + from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider as _impl + + return _impl(provider) + + @staticmethod + def is_builtin(provider: str) -> bool: + """Return True if the provider's own package is importable as a built-in. + + Delegates to `prowler.providers.common.builtin.is_builtin_provider`, + the leaf module that holds the actual check. Kept on `Provider` as a + convenience entry point for callers that already import `Provider`. + Call sites in `prowler.lib.check.*` should import from the leaf + directly to avoid the import cycle through this module. + """ + from prowler.providers.common.builtin import is_builtin_provider as _impl + + return _impl(provider) + + @staticmethod + def _load_ep_provider(name: str): + """Load an external provider class from entry points, with cache. + + Caches both hits and misses so repeated lookups for unknown names do + not re-iterate entry_points(). Symmetric with + tool_wrapper._ep_class_cache. + """ + if name in Provider._ep_providers: + return Provider._ep_providers[name] + for ep in importlib.metadata.entry_points(group="prowler.providers"): + if ep.name == name: + try: + cls = ep.load() + Provider._ep_providers[name] = cls + return cls + except Exception as error: + logger.warning( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + Provider._ep_providers[name] = None + return None + + @staticmethod + def get_class(provider: str) -> type: + """Resolve the provider class for a name (built-in or entry-point). + + Does not call ``sys.exit`` and does not initialize the global + provider (it may populate the ``_ep_providers`` memoization cache). + Collision warnings are emitted by ``init_global_provider``, not here. + The caller handles errors (CLI exits; the API can return HTTP 400). + + Args: + provider: Provider name, e.g. ``"aws"`` or an external plug-in. + + Returns: + The provider class (a subclass of :class:`Provider`). + + Raises: + ImportError: If not found as built-in or entry point, a built-in's + transitive dependency is missing, or an entry point resolves to + an object that is not a subclass of :class:`Provider`. + """ + if Provider.is_builtin(provider): + provider_class_path = f"{providers_path}.{provider}.{provider}_provider" + provider_class_name = f"{provider.capitalize()}Provider" + # Let ImportError propagate — the caller decides whether to + # sys.exit (CLI) or return HTTP 400 (API). + module = import_module(provider_class_path) + try: + return getattr(module, provider_class_name) + except AttributeError as error: + # is_builtin already confirmed this is a built-in, so the + # module MUST define the expected class. A missing class is a + # broken built-in contract — raise rather than fall back to a + # same-named external plug-in, which would contradict + # is_builtin and silently return a foreign class. + raise ImportError( + f"Built-in provider '{provider}' module " + f"'{provider_class_path}' does not define expected class " + f"'{provider_class_name}'" + ) from error + + cls = Provider._load_ep_provider(provider) + if cls is None: + raise ImportError( + f"Provider '{provider}' not found as built-in or entry point" + ) + # ep.load() can return any object; enforce the public contract that + # get_class returns a Provider subclass. isinstance(cls, type) guards + # issubclass against a TypeError when cls is not a class at all. + if not (isinstance(cls, type) and issubclass(cls, Provider)): + raise ImportError( + f"Entry-point provider '{provider}' resolved to {cls!r}, " + f"which is not a subclass of Provider" + ) + return cls + + @staticmethod + def get_providers_help_text() -> dict: + """Returns a dict of {provider_name: cli_help_text} for all available providers.""" + help_text = {} + for name in Provider.get_available_providers(): + try: + cls = Provider.get_class(name) + help_text[name] = getattr(cls, "_cli_help_text", "") + except Exception as error: + logger.warning( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + help_text[name] = "" + return help_text @staticmethod def update_provider_config(audit_config: dict, variable: str, value: str): diff --git a/prowler/providers/gcp/exceptions/exceptions.py b/prowler/providers/gcp/exceptions/exceptions.py index 5c5845951c..09cb642cba 100644 --- a/prowler/providers/gcp/exceptions/exceptions.py +++ b/prowler/providers/gcp/exceptions/exceptions.py @@ -34,11 +34,17 @@ class GCPBaseException(ProwlerException): "message": "Error loading Service Account Private Key credentials from dictionary", "remediation": "Check the dictionary and ensure it contains a Service Account Private Key.", }, + (3011, "GCPGetOrganizationProjectsError"): { + "message": "Error retrieving projects under the organization via the Cloud Asset API", + "remediation": "Ensure the Cloud Asset API is enabled in the credentials' project and that the principal has 'roles/cloudasset.viewer' bound at the organization level. See https://cloud.google.com/asset-inventory/docs/access-control.", + }, } def __init__(self, code, file=None, original_exception=None, message=None): provider = "GCP" - error_info = self.GCP_ERROR_CODES.get((code, self.__class__.__name__)) + # Copy the catalog entry so a custom message does not mutate the + # class-level GCP_ERROR_CODES shared across exception instances. + error_info = dict(self.GCP_ERROR_CODES.get((code, self.__class__.__name__))) if message: error_info["message"] = message super().__init__( @@ -104,3 +110,10 @@ class GCPLoadServiceAccountKeyFromDictError(GCPCredentialsError): super().__init__( 3010, file=file, original_exception=original_exception, message=message ) + + +class GCPGetOrganizationProjectsError(GCPBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 3011, file=file, original_exception=original_exception, message=message + ) diff --git a/prowler/providers/gcp/gcp_provider.py b/prowler/providers/gcp/gcp_provider.py index 5017b84c42..39b3392fdf 100644 --- a/prowler/providers/gcp/gcp_provider.py +++ b/prowler/providers/gcp/gcp_provider.py @@ -21,6 +21,8 @@ from prowler.providers.common.models import Audit_Metadata, Connection from prowler.providers.common.provider import Provider from prowler.providers.gcp.config import DEFAULT_RETRY_ATTEMPTS from prowler.providers.gcp.exceptions.exceptions import ( + GCPBaseException, + GCPGetOrganizationProjectsError, GCPInvalidProviderIdError, GCPLoadADCFromDictError, GCPLoadServiceAccountKeyFromDictError, @@ -621,10 +623,7 @@ class GcpProvider(Provider): credentials_file: str Returns: - dict[str, GCPProject] - - Usage: - >>> GcpProvider.get_projects(credentials=credentials, organization_id=organization_id) + dict of project_id and GCPProject object """ projects = {} try: @@ -632,7 +631,10 @@ class GcpProvider(Provider): try: # Initialize Cloud Asset Inventory API for recursive project retrieval asset_service = discovery.build( - "cloudasset", "v1", credentials=credentials + "cloudasset", + "v1", + credentials=credentials, + num_retries=DEFAULT_RETRY_ATTEMPTS, ) # Set the scope to the specified organization and filter for projects scope = f"organizations/{organization_id}" @@ -643,7 +645,7 @@ class GcpProvider(Provider): ) while request is not None: - response = request.execute() + response = request.execute(num_retries=DEFAULT_RETRY_ATTEMPTS) for asset in response.get("assets", []): # Extract labels and other project details @@ -688,13 +690,25 @@ class GcpProvider(Provider): ) except HttpError as http_error: if "Cloud Asset API has not been used" in str(http_error): - logger.error( - f"Projects cannot be retrieved from the Organization since Cloud Asset API has not been used before or it is disabled [{http_error.__traceback__.tb_lineno}]. Enable it by visiting https://console.developers.google.com/apis/api/cloudasset.googleapis.com/ then retry." + message = ( + "Projects cannot be retrieved from the Organization since the Cloud Asset API " + "has not been used before or it is disabled. Enable it by visiting " + "https://console.developers.google.com/apis/api/cloudasset.googleapis.com/ then retry." ) else: - logger.error( - f"{http_error.__class__.__name__}[{http_error.__traceback__.tb_lineno}]: {http_error}" + message = ( + f"Cloud Asset API call failed while listing projects under organization " + f"'{organization_id}': {http_error}. Ensure the credentials' principal has " + "'roles/cloudasset.viewer' bound at the organization level." ) + logger.critical( + f"{http_error.__class__.__name__}[{http_error.__traceback__.tb_lineno}]: {message}" + ) + raise GCPGetOrganizationProjectsError( + file=__file__, + original_exception=http_error, + message=message, + ) else: try: # Initialize Cloud Resource Manager API for simple project listing @@ -781,8 +795,10 @@ class GcpProvider(Provider): labels={}, lifecycle_state="ACTIVE", ) - # If no projects were able to be accessed via API, add them manually from the credentials file - elif credentials_file: + # If no projects were able to be accessed via API, add them manually from the credentials file. + # Skip this fallback when an organization scan was explicitly requested: silently + # downgrading scope to the service account's home project hides permission errors. + elif credentials_file and not organization_id: with open(credentials_file, "r", encoding="utf-8") as file: project_id = json.load(file)["project_id"] # Handle empty or null project names @@ -798,6 +814,8 @@ class GcpProvider(Provider): labels={}, lifecycle_state="ACTIVE", ) + except GCPBaseException as gcp_error: + raise gcp_error except Exception as error: logger.critical( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" diff --git a/prowler/providers/gcp/services/cloudfunction/__init__.py b/prowler/providers/gcp/services/cloudfunction/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/cloudfunction/cloudfunction_client.py b/prowler/providers/gcp/services/cloudfunction/cloudfunction_client.py new file mode 100644 index 0000000000..a252da1be7 --- /dev/null +++ b/prowler/providers/gcp/services/cloudfunction/cloudfunction_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + CloudFunction, +) + +cloudfunction_client = CloudFunction(Provider.get_global_provider()) diff --git a/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/__init__.py b/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc.metadata.json b/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc.metadata.json new file mode 100644 index 0000000000..72c908bf80 --- /dev/null +++ b/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "gcp", + "CheckID": "cloudfunction_function_inside_vpc", + "CheckTitle": "Cloud Function is connected to a VPC network", + "CheckType": [], + "ServiceName": "cloudfunction", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "cloudfunctions.googleapis.com/Function", + "Description": "Cloud Functions are attached to a **Serverless VPC Access connector** so egress traffic is routed through a private VPC network instead of the public internet.\n\nThe evaluation reviews each function's network configuration to confirm that a connector is configured.", + "Risk": "Without a VPC connector, Cloud Functions cannot privately reach internal resources such as `Cloud SQL`, `Memorystore`, or `GKE`, forcing those services to be exposed over public IPs. This expands the **attack surface**, weakens **confidentiality** of internal traffic, and breaks network segmentation controls required by most security frameworks.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://cloud.google.com/functions/docs/networking/connecting-vpc", + "https://cloud.google.com/vpc/docs/serverless-vpc-access" + ], + "Remediation": { + "Code": { + "CLI": "gcloud functions deploy --region= --vpc-connector=projects//locations//connectors/ --egress-settings=all-traffic", + "NativeIaC": "", + "Other": "1. In Google Cloud Console, go to Cloud Functions\n2. Select the function and click Edit\n3. Under Connections, select the VPC connector for your network\n4. Set Egress settings to route all traffic through the VPC connector\n5. Save and redeploy the function", + "Terraform": "```hcl\nresource \"google_cloudfunctions2_function\" \"\" {\n name = \"\"\n location = \"us-central1\"\n\n service_config {\n vpc_connector = \"\" # Critical: routes egress through the VPC\n vpc_connector_egress_settings = \"ALL_TRAFFIC\"\n }\n}\n```" + }, + "Recommendation": { + "Text": "Apply **defense in depth** by routing Cloud Function egress through a **Serverless VPC Access connector** when the function must reach internal resources.\n\nScope each connector to **least privilege** subnets so functions cannot reach unintended endpoints.", + "Url": "https://hub.prowler.com/check/cloudfunction_function_inside_vpc" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [ + "cloudfunction_function_not_publicly_accessible", + "cloudsql_instance_public_ip", + "compute_instance_public_ip" + ], + "Notes": "A VPC connector must be created in the same region as the Cloud Function. This check only verifies that a connector is attached; it does not validate egress settings or connector configuration." +} diff --git a/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc.py b/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc.py new file mode 100644 index 0000000000..46866b350d --- /dev/null +++ b/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc.py @@ -0,0 +1,39 @@ +from prowler.lib.check.models import Check, Check_Report_GCP +from prowler.providers.gcp.services.cloudfunction.cloudfunction_client import ( + cloudfunction_client, +) + + +class cloudfunction_function_inside_vpc(Check): + """Check that Cloud Functions are attached to a Serverless VPC Access connector. + + Verifies that each active Cloud Function has a `vpcConnector` configured so + egress traffic flows through a private VPC network instead of the public + internet. Functions in non-`ACTIVE` states are skipped because their network + configuration is transient. + """ + + def execute(self) -> list[Check_Report_GCP]: + """Execute the VPC-connector check across all Cloud Functions. + + Returns: + A list of `Check_Report_GCP` findings, one per active Cloud + Function. Status is `PASS` when a `vpc_connector` is set and `FAIL` + otherwise. + """ + findings = [] + for function in cloudfunction_client.functions: + if function.state != "ACTIVE": + continue + report = Check_Report_GCP(metadata=self.metadata(), resource=function) + if function.vpc_connector: + report.status = "PASS" + report.status_extended = ( + f"Cloud Function {function.name} is connected to a VPC via " + f"connector: {function.vpc_connector}." + ) + else: + report.status = "FAIL" + report.status_extended = f"Cloud Function {function.name} is not connected to any VPC network." + findings.append(report) + return findings diff --git a/prowler/providers/gcp/services/cloudfunction/cloudfunction_service.py b/prowler/providers/gcp/services/cloudfunction/cloudfunction_service.py new file mode 100644 index 0000000000..d1043151b7 --- /dev/null +++ b/prowler/providers/gcp/services/cloudfunction/cloudfunction_service.py @@ -0,0 +1,84 @@ +from typing import Optional + +from pydantic.v1 import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.gcp.config import DEFAULT_RETRY_ATTEMPTS +from prowler.providers.gcp.gcp_provider import GcpProvider +from prowler.providers.gcp.lib.service.service import GCPService + + +class CloudFunction(GCPService): + """Cloud Functions v2 service client. + + Enumerates Cloud Functions across every accessible project and region + using the `cloudfunctions.googleapis.com` v2 API and exposes them through + the `functions` attribute. + """ + + def __init__(self, provider: GcpProvider) -> None: + """Initialize the service and preload Cloud Functions.""" + super().__init__("cloudfunctions", provider, api_version="v2") + self.functions = [] + self._get_functions() + + def _get_functions(self) -> None: + """Fetch Cloud Functions for every project and location.""" + for project_id in self.project_ids: + try: + locations = self.client.projects().locations() + locations_request = locations.list(name=f"projects/{project_id}") + while locations_request is not None: + locations_response = locations_request.execute( + num_retries=DEFAULT_RETRY_ATTEMPTS + ) + for location in locations_response.get("locations", []): + location_id = location["locationId"] + try: + functions = locations.functions() + request = functions.list( + parent=f"projects/{project_id}/locations/{location_id}" + ) + while request is not None: + response = request.execute( + num_retries=DEFAULT_RETRY_ATTEMPTS + ) + for fn in response.get("functions", []): + service_config = fn.get("serviceConfig", {}) + self.functions.append( + Function( + name=fn["name"].split("/")[-1], + project_id=project_id, + location=location_id, + state=fn.get("state", "UNKNOWN"), + vpc_connector=service_config.get( + "vpcConnector" + ), + ) + ) + request = functions.list_next( + previous_request=request, + previous_response=response, + ) + except Exception as error: + logger.error( + f"{location_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + locations_request = locations.list_next( + previous_request=locations_request, + previous_response=locations_response, + ) + except Exception as error: + logger.error( + f"{project_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class Function(BaseModel): + """Cloud Function resource consumed by GCP checks.""" + + name: str + project_id: str + location: str + state: str + vpc_connector: Optional[str] = None diff --git a/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/__init__.py b/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled.metadata.json b/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled.metadata.json new file mode 100644 index 0000000000..3e66f9cdd3 --- /dev/null +++ b/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "gcp", + "CheckID": "cloudsql_instance_high_availability_enabled", + "CheckTitle": "Cloud SQL instance has high availability (REGIONAL) configured", + "CheckType": [], + "ServiceName": "cloudsql", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "sqladmin.googleapis.com/Instance", + "Description": "Ensures that Cloud SQL instances have high availability configured by setting availabilityType to REGIONAL. A REGIONAL instance maintains a standby replica in a different zone within the same region and automatically fails over on zone-level outages.", + "Risk": "Instances with ZONAL availability have no standby replica. A zone-level outage will cause database downtime until manual recovery, violating availability requirements and potentially breaching SLAs and ISMS-P 2.12.1 disaster preparedness controls.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://cloud.google.com/sql/docs/postgres/high-availability", + "https://cloud.google.com/sql/docs/sqlserver/high-availability" + ], + "Remediation": { + "Code": { + "CLI": "gcloud sql instances patch --availability-type=REGIONAL", + "NativeIaC": "", + "Other": "1. Go to Google Cloud Console > SQL > Instances.\n2. Click the instance name, then Edit.\n3. Under Availability, select Multiple zones (Highly available).\n4. Click Save.", + "Terraform": "```hcl\nresource \"google_sql_database_instance\" \"example\" {\n name = \"\"\n database_version = \"POSTGRES_15\"\n region = \"\"\n\n settings {\n tier = \"db-custom-2-7680\"\n\n availability_type = \"REGIONAL\" # Critical: enables HA standby replica\n\n backup_configuration {\n enabled = true\n start_time = \"02:00\"\n }\n }\n}\n```" + }, + "Recommendation": { + "Text": "Set availabilityType to REGIONAL for all production Cloud SQL instances. This creates a standby replica in a different zone and enables automatic failover, reducing RTO in the event of a zone outage.", + "Url": "https://hub.prowler.com/check/cloudsql_instance_high_availability_enabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [ + "cloudsql_instance_automated_backups" + ], + "Notes": "Enabling HA increases instance cost approximately 2x due to the standby replica. ZONAL instances are acceptable for non-production workloads where downtime is tolerable." +} diff --git a/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled.py b/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled.py new file mode 100644 index 0000000000..37bf576345 --- /dev/null +++ b/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled.py @@ -0,0 +1,41 @@ +from prowler.lib.check.models import Check, Check_Report_GCP +from prowler.providers.gcp.services.cloudsql.cloudsql_client import cloudsql_client + + +class cloudsql_instance_high_availability_enabled(Check): + """Check that Cloud SQL primary instances are configured for high availability. + + Verifies that each Cloud SQL primary instance has `availabilityType` set to + `REGIONAL`, which provisions a standby replica in a different zone within + the same region and enables automatic failover on zone-level outages. Read + replicas are skipped because they inherit availability from their primary. + """ + + def execute(self) -> list[Check_Report_GCP]: + """Execute the high availability check across all Cloud SQL instances. + + Returns: + A list of `Check_Report_GCP` findings, one per Cloud SQL primary + instance. Status is `PASS` when `availability_type == "REGIONAL"` + and `FAIL` otherwise. + """ + findings = [] + for instance in cloudsql_client.instances: + if instance.instance_type != "CLOUD_SQL_INSTANCE": + continue + report = Check_Report_GCP(metadata=self.metadata(), resource=instance) + if instance.availability_type == "REGIONAL": + report.status = "PASS" + report.status_extended = ( + f"Database instance {instance.name} has high availability " + f"(REGIONAL) configured." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Database instance {instance.name} does not have high " + f"availability configured (current: " + f"{instance.availability_type})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/gcp/services/cloudsql/cloudsql_service.py b/prowler/providers/gcp/services/cloudsql/cloudsql_service.py index 137169999a..1fe706bb02 100644 --- a/prowler/providers/gcp/services/cloudsql/cloudsql_service.py +++ b/prowler/providers/gcp/services/cloudsql/cloudsql_service.py @@ -46,6 +46,9 @@ class CloudSQL(GCPService): "authorizedNetworks", [] ), flags=settings.get("databaseFlags", []), + availability_type=settings.get( + "availabilityType", "ZONAL" + ), instance_type=instance.get( "instanceType", "CLOUD_SQL_INSTANCE" ), @@ -76,6 +79,7 @@ class Instance(BaseModel): ssl_mode: str automated_backups: bool flags: list + availability_type: str = "ZONAL" instance_type: str = "CLOUD_SQL_INSTANCE" cmek_key_name: Optional[str] = None project_id: str diff --git a/prowler/providers/gcp/services/iam/iam_service.py b/prowler/providers/gcp/services/iam/iam_service.py index 13e96276e7..987a86068c 100644 --- a/prowler/providers/gcp/services/iam/iam_service.py +++ b/prowler/providers/gcp/services/iam/iam_service.py @@ -37,6 +37,7 @@ class IAM(GCPService): display_name=account.get("displayName", ""), project_id=project_id, uniqueId=account.get("uniqueId", ""), + disabled=account.get("disabled", False), ) ) @@ -102,6 +103,7 @@ class ServiceAccount(BaseModel): keys: list[Key] = [] project_id: str uniqueId: str + disabled: bool = False class AccessApproval(GCPService): diff --git a/prowler/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused.py b/prowler/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused.py index 12440aff25..912237b9e5 100644 --- a/prowler/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused.py +++ b/prowler/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused.py @@ -19,7 +19,12 @@ class iam_service_account_unused(Check): resource_id=account.email, location=iam_client.region, ) - if account.uniqueId in sa_ids_used: + if account.disabled: + report.status = "PASS" + report.status_extended = ( + f"Service Account {account.email} is disabled and cannot be used." + ) + elif account.uniqueId in sa_ids_used: report.status = "PASS" report.status_extended = f"Service Account {account.email} was used over the last {max_unused_days} days." else: diff --git a/prowler/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled.metadata.json b/prowler/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled.metadata.json index 5efe894b04..7312ffefeb 100644 --- a/prowler/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled.metadata.json +++ b/prowler/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled.metadata.json @@ -1,14 +1,14 @@ { "Provider": "gcp", "CheckID": "kms_key_rotation_enabled", - "CheckTitle": "KMS key is rotated at least annually", + "CheckTitle": "KMS key has automatic rotation enabled", "CheckType": [], "ServiceName": "kms", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "low", "ResourceType": "cloudkms.googleapis.com/CryptoKey", - "Description": "Google Cloud KMS customer-managed keys have **automatic rotation** enabled or a rotation interval `365` days.\n\nThe evaluation reviews each key's rotation settings to confirm periodic creation of new key versions.", + "Description": "Google Cloud KMS customer-managed keys have **automatic rotation** enabled, regardless of the rotation interval.\n\nThe evaluation reviews each key's rotation settings to confirm that a rotation period is configured so new key versions are created periodically.", "Risk": "Without timely rotation, a stolen key can decrypt an expanding volume of data, eroding **confidentiality**. Prolonged key lifetimes widen windows for misuse, impact **integrity** of protected workloads, and make emergency rollover harder, risking **availability** disruptions.", "RelatedUrl": "", "AdditionalURLs": [ @@ -17,13 +17,13 @@ ], "Remediation": { "Code": { - "CLI": "gcloud kms keys update --keyring= --location= --rotation-period=365d --next-rotation-time=", + "CLI": "gcloud kms keys update --keyring= --location= --rotation-period= --next-rotation-time=", "NativeIaC": "", - "Other": "1. In Google Cloud Console, go to Security > Key Management > Key rings\n2. Open the key ring and select the key\n3. Click Edit rotation schedule (or Set rotation schedule)\n4. Set Rotation period to 365 days or less\n5. Set Next rotation date/time\n6. Click Save", - "Terraform": "```hcl\nresource \"google_kms_crypto_key\" \"\" {\n name = \"\"\n key_ring = \"\"\n purpose = \"ENCRYPT_DECRYPT\"\n\n rotation_period = \"31536000s\" # Critical: sets automatic rotation to 365 days (<= 365 ensures PASS)\n}\n```" + "Other": "1. In Google Cloud Console, go to Security > Key Management > Key rings\n2. Open the key ring and select the key\n3. Click Edit rotation schedule (or Set rotation schedule)\n4. Set a Rotation period\n5. Set Next rotation date/time\n6. Click Save", + "Terraform": "```hcl\nresource \"google_kms_crypto_key\" \"\" {\n name = \"\"\n key_ring = \"\"\n purpose = \"ENCRYPT_DECRYPT\"\n\n rotation_period = \"7776000s\" # Critical: enables automatic rotation (any period ensures PASS)\n}\n```" }, "Recommendation": { - "Text": "Enable **auto-rotation** for customer-managed keys with an interval `365` days.\n\nAdopt a **key lifecycle** policy: enforce **least privilege** on key usage, apply **separation of duties** between key admins and users, monitor key access, and rehearse emergency rotation to minimize blast radius.", + "Text": "Enable **auto-rotation** for customer-managed keys by configuring a rotation period.\n\nAdopt a **key lifecycle** policy: enforce **least privilege** on key usage, apply **separation of duties** between key admins and users, monitor key access, and rehearse emergency rotation to minimize blast radius.", "Url": "https://hub.prowler.com/check/kms_key_rotation_enabled" } }, @@ -31,6 +31,8 @@ "encryption" ], "DependsOn": [], - "RelatedTo": [], + "RelatedTo": [ + "kms_key_rotation_max_90_days" + ], "Notes": "" } diff --git a/prowler/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled.py b/prowler/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled.py index 577a7c9f99..ae924ca380 100644 --- a/prowler/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled.py +++ b/prowler/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled.py @@ -1,5 +1,3 @@ -import datetime - from prowler.lib.check.models import Check, Check_Report_GCP from prowler.providers.gcp.services.kms.kms_client import kms_client @@ -9,36 +7,16 @@ class kms_key_rotation_enabled(Check): findings = [] for key in kms_client.crypto_keys: report = Check_Report_GCP(metadata=self.metadata(), resource=key) - now = datetime.datetime.now() - condition_next_rotation_time = False - if key.next_rotation_time: - try: - next_rotation_time = datetime.datetime.strptime( - key.next_rotation_time, "%Y-%m-%dT%H:%M:%S.%fZ" - ) - except ValueError: - next_rotation_time = datetime.datetime.strptime( - key.next_rotation_time, "%Y-%m-%dT%H:%M:%SZ" - ) - condition_next_rotation_time = ( - abs((next_rotation_time - now).days) <= 90 - ) - condition_rotation_period = False if key.rotation_period: - condition_rotation_period = ( - int(key.rotation_period[:-1]) // (24 * 3600) <= 90 - ) - if condition_rotation_period and condition_next_rotation_time: report.status = "PASS" - report.status_extended = f"Key {key.name} is rotated every 90 days or less and the next rotation time is in less than 90 days." + report.status_extended = ( + f"Key {key.name} has automatic rotation enabled." + ) else: report.status = "FAIL" - if condition_rotation_period: - report.status_extended = f"Key {key.name} is rotated every 90 days or less but the next rotation time is in more than 90 days." - elif condition_next_rotation_time: - report.status_extended = f"Key {key.name} is not rotated every 90 days or less but the next rotation time is in less than 90 days." - else: - report.status_extended = f"Key {key.name} is not rotated every 90 days or less and the next rotation time is in more than 90 days." + report.status_extended = ( + f"Key {key.name} does not have automatic rotation enabled." + ) findings.append(report) return findings diff --git a/prowler/providers/gcp/services/kms/kms_key_rotation_max_90_days/__init__.py b/prowler/providers/gcp/services/kms/kms_key_rotation_max_90_days/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/kms/kms_key_rotation_max_90_days/kms_key_rotation_max_90_days.metadata.json b/prowler/providers/gcp/services/kms/kms_key_rotation_max_90_days/kms_key_rotation_max_90_days.metadata.json new file mode 100644 index 0000000000..f1597fee53 --- /dev/null +++ b/prowler/providers/gcp/services/kms/kms_key_rotation_max_90_days/kms_key_rotation_max_90_days.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "gcp", + "CheckID": "kms_key_rotation_max_90_days", + "CheckTitle": "KMS key is rotated every 90 days or less", + "CheckType": [], + "ServiceName": "kms", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "cloudkms.googleapis.com/CryptoKey", + "Description": "Google Cloud KMS customer-managed keys are rotated with an interval of `90` days or less, in line with the CIS Benchmark.\n\nThe evaluation reviews each key's rotation settings to confirm that both the rotation period and the next rotation time stay within 90 days.", + "Risk": "Without timely rotation, a stolen key can decrypt an expanding volume of data, eroding **confidentiality**. Prolonged key lifetimes widen windows for misuse, impact **integrity** of protected workloads, and make emergency rollover harder, risking **availability** disruptions.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/gcp/CloudKMS/rotate-kms-encryption-keys.html", + "https://cloud.google.com/iam/docs/manage-access-service-accounts" + ], + "Remediation": { + "Code": { + "CLI": "gcloud kms keys update --keyring= --location= --rotation-period=90d --next-rotation-time=", + "NativeIaC": "", + "Other": "1. In Google Cloud Console, go to Security > Key Management > Key rings\n2. Open the key ring and select the key\n3. Click Edit rotation schedule (or Set rotation schedule)\n4. Set Rotation period to 90 days or less\n5. Set Next rotation date/time\n6. Click Save", + "Terraform": "```hcl\nresource \"google_kms_crypto_key\" \"\" {\n name = \"\"\n key_ring = \"\"\n purpose = \"ENCRYPT_DECRYPT\"\n\n rotation_period = \"7776000s\" # Critical: sets automatic rotation to 90 days (<= 90 ensures PASS)\n}\n```" + }, + "Recommendation": { + "Text": "Enable **auto-rotation** for customer-managed keys with an interval of `90` days or less.\n\nAdopt a **key lifecycle** policy: enforce **least privilege** on key usage, apply **separation of duties** between key admins and users, monitor key access, and rehearse emergency rotation to minimize blast radius.", + "Url": "https://hub.prowler.com/check/kms_key_rotation_max_90_days" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [ + "kms_key_rotation_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/gcp/services/kms/kms_key_rotation_max_90_days/kms_key_rotation_max_90_days.py b/prowler/providers/gcp/services/kms/kms_key_rotation_max_90_days/kms_key_rotation_max_90_days.py new file mode 100644 index 0000000000..cbca4f0e91 --- /dev/null +++ b/prowler/providers/gcp/services/kms/kms_key_rotation_max_90_days/kms_key_rotation_max_90_days.py @@ -0,0 +1,44 @@ +import datetime + +from prowler.lib.check.models import Check, Check_Report_GCP +from prowler.providers.gcp.services.kms.kms_client import kms_client + + +class kms_key_rotation_max_90_days(Check): + def execute(self) -> Check_Report_GCP: + findings = [] + for key in kms_client.crypto_keys: + report = Check_Report_GCP(metadata=self.metadata(), resource=key) + now = datetime.datetime.now() + condition_next_rotation_time = False + if key.next_rotation_time: + try: + next_rotation_time = datetime.datetime.strptime( + key.next_rotation_time, "%Y-%m-%dT%H:%M:%S.%fZ" + ) + except ValueError: + next_rotation_time = datetime.datetime.strptime( + key.next_rotation_time, "%Y-%m-%dT%H:%M:%SZ" + ) + condition_next_rotation_time = ( + abs((next_rotation_time - now).days) <= 90 + ) + condition_rotation_period = False + if key.rotation_period: + condition_rotation_period = ( + int(key.rotation_period[:-1]) // (24 * 3600) <= 90 + ) + if condition_rotation_period and condition_next_rotation_time: + report.status = "PASS" + report.status_extended = f"Key {key.name} is rotated every 90 days or less and the next rotation time is in less than 90 days." + else: + report.status = "FAIL" + if condition_rotation_period: + report.status_extended = f"Key {key.name} is rotated every 90 days or less but the next rotation time is in more than 90 days." + elif condition_next_rotation_time: + report.status_extended = f"Key {key.name} is not rotated every 90 days or less but the next rotation time is in less than 90 days." + else: + report.status_extended = f"Key {key.name} is not rotated every 90 days or less and the next rotation time is in more than 90 days." + findings.append(report) + + return findings diff --git a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.py b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.py index 4654be4d29..84bf078dac 100644 --- a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.py +++ b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.py @@ -1,5 +1,8 @@ from prowler.lib.check.models import Check, Check_Report_GCP from prowler.providers.gcp.services.logging.logging_client import logging_client +from prowler.providers.gcp.services.logging.logging_service import ( + get_projects_covered_by_aggregated_metric, +) from prowler.providers.gcp.services.monitoring.monitoring_client import ( monitoring_client, ) @@ -10,12 +13,10 @@ class logging_log_metric_filter_and_alert_for_audit_configuration_changes_enable ): def execute(self) -> Check_Report_GCP: findings = [] + metric_filter = 'protoPayload.methodName="SetIamPolicy" AND protoPayload.serviceData.policyDelta.auditConfigDeltas:*' projects_with_metric = set() for metric in logging_client.metrics: - if ( - 'protoPayload.methodName="SetIamPolicy" AND protoPayload.serviceData.policyDelta.auditConfigDeltas:*' - in metric.filter - ): + if metric_filter in metric.filter: report = Check_Report_GCP( metadata=self.metadata(), resource=metric, @@ -33,6 +34,11 @@ class logging_log_metric_filter_and_alert_for_audit_configuration_changes_enable break findings.append(report) + # Credit projects whose logs are centrally monitored via an org-level + # aggregated sink to a bucket-scoped metric + alert (instead of failing them). + centrally_covered = get_projects_covered_by_aggregated_metric( + logging_client, monitoring_client, metric_filter + ) for project in logging_client.project_ids: if project not in projects_with_metric: report = Check_Report_GCP( @@ -46,8 +52,12 @@ class logging_log_metric_filter_and_alert_for_audit_configuration_changes_enable else "GCP Project" ), ) - report.status = "FAIL" - report.status_extended = f"There are no log metric filters or alerts associated in project {project}." + if project in centrally_covered: + report.status = "PASS" + report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink." + else: + report.status = "FAIL" + report.status_extended = f"There are no log metric filters or alerts associated in project {project}." findings.append(report) return findings diff --git a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.py b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.py index 166f7b7ee8..e7d74f3f8e 100644 --- a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.py +++ b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.py @@ -1,5 +1,8 @@ from prowler.lib.check.models import Check, Check_Report_GCP from prowler.providers.gcp.services.logging.logging_client import logging_client +from prowler.providers.gcp.services.logging.logging_service import ( + get_projects_covered_by_aggregated_metric, +) from prowler.providers.gcp.services.monitoring.monitoring_client import ( monitoring_client, ) @@ -8,12 +11,10 @@ from prowler.providers.gcp.services.monitoring.monitoring_client import ( class logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled(Check): def execute(self) -> Check_Report_GCP: findings = [] + metric_filter = 'resource.type="gcs_bucket" AND protoPayload.methodName="storage.setIamPermissions"' projects_with_metric = set() for metric in logging_client.metrics: - if ( - 'resource.type="gcs_bucket" AND protoPayload.methodName="storage.setIamPermissions"' - in metric.filter - ): + if metric_filter in metric.filter: metric_name = getattr(metric, "name", None) or "unknown" report = Check_Report_GCP( metadata=self.metadata(), @@ -36,6 +37,9 @@ class logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled( break findings.append(report) + centrally_covered = get_projects_covered_by_aggregated_metric( + logging_client, monitoring_client, metric_filter + ) for project in logging_client.project_ids: if project not in projects_with_metric: project_obj = logging_client.projects.get(project) @@ -46,8 +50,12 @@ class logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled( location=logging_client.region, resource_name=(getattr(project_obj, "name", None) or "GCP Project"), ) - report.status = "FAIL" - report.status_extended = f"There are no log metric filters or alerts associated in project {project}." + if project in centrally_covered: + report.status = "PASS" + report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink." + else: + report.status = "FAIL" + report.status_extended = f"There are no log metric filters or alerts associated in project {project}." findings.append(report) return findings diff --git a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.py b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.py index 7902f9ed72..cf7cdb1679 100644 --- a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.py +++ b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.py @@ -1,5 +1,8 @@ from prowler.lib.check.models import Check, Check_Report_GCP from prowler.providers.gcp.services.logging.logging_client import logging_client +from prowler.providers.gcp.services.logging.logging_service import ( + get_projects_covered_by_aggregated_metric, +) from prowler.providers.gcp.services.monitoring.monitoring_client import ( monitoring_client, ) @@ -10,9 +13,10 @@ class logging_log_metric_filter_and_alert_for_compute_configuration_changes_enab ): def execute(self) -> Check_Report_GCP: findings = [] + metric_filter = 'protoPayload.serviceName="compute.googleapis.com"' projects_with_metric = set() for metric in logging_client.metrics: - if 'protoPayload.serviceName="compute.googleapis.com"' in metric.filter: + if metric_filter in metric.filter: report = Check_Report_GCP( metadata=self.metadata(), resource=metric, @@ -30,6 +34,9 @@ class logging_log_metric_filter_and_alert_for_compute_configuration_changes_enab break findings.append(report) + centrally_covered = get_projects_covered_by_aggregated_metric( + logging_client, monitoring_client, metric_filter + ) for project in logging_client.project_ids: if project not in projects_with_metric: report = Check_Report_GCP( @@ -43,8 +50,12 @@ class logging_log_metric_filter_and_alert_for_compute_configuration_changes_enab else "GCP Project" ), ) - report.status = "FAIL" - report.status_extended = f"There are no log metric filters or alerts associated for Compute Engine configuration changes in project {project}." + if project in centrally_covered: + report.status = "PASS" + report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink." + else: + report.status = "FAIL" + report.status_extended = f"There are no log metric filters or alerts associated for Compute Engine configuration changes in project {project}." findings.append(report) return findings diff --git a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.py b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.py index 1e6584e5fb..f836dc25b2 100644 --- a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.py +++ b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.py @@ -1,5 +1,8 @@ from prowler.lib.check.models import Check, Check_Report_GCP from prowler.providers.gcp.services.logging.logging_client import logging_client +from prowler.providers.gcp.services.logging.logging_service import ( + get_projects_covered_by_aggregated_metric, +) from prowler.providers.gcp.services.monitoring.monitoring_client import ( monitoring_client, ) @@ -8,12 +11,10 @@ from prowler.providers.gcp.services.monitoring.monitoring_client import ( class logging_log_metric_filter_and_alert_for_custom_role_changes_enabled(Check): def execute(self) -> Check_Report_GCP: findings = [] + metric_filter = 'resource.type="iam_role" AND (protoPayload.methodName="google.iam.admin.v1.CreateRole" OR protoPayload.methodName="google.iam.admin.v1.DeleteRole" OR protoPayload.methodName="google.iam.admin.v1.UpdateRole")' projects_with_metric = set() for metric in logging_client.metrics: - if ( - 'resource.type="iam_role" AND (protoPayload.methodName="google.iam.admin.v1.CreateRole" OR protoPayload.methodName="google.iam.admin.v1.DeleteRole" OR protoPayload.methodName="google.iam.admin.v1.UpdateRole")' - in metric.filter - ): + if metric_filter in metric.filter: report = Check_Report_GCP( metadata=self.metadata(), resource=metric, @@ -31,6 +32,9 @@ class logging_log_metric_filter_and_alert_for_custom_role_changes_enabled(Check) break findings.append(report) + centrally_covered = get_projects_covered_by_aggregated_metric( + logging_client, monitoring_client, metric_filter + ) for project in logging_client.project_ids: if project not in projects_with_metric: report = Check_Report_GCP( @@ -44,8 +48,12 @@ class logging_log_metric_filter_and_alert_for_custom_role_changes_enabled(Check) else "GCP Project" ), ) - report.status = "FAIL" - report.status_extended = f"There are no log metric filters or alerts associated in project {project}." + if project in centrally_covered: + report.status = "PASS" + report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink." + else: + report.status = "FAIL" + report.status_extended = f"There are no log metric filters or alerts associated in project {project}." findings.append(report) return findings diff --git a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.py b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.py index 8c8927ec32..b7bc619ea4 100644 --- a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.py +++ b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.py @@ -1,5 +1,8 @@ from prowler.lib.check.models import Check, Check_Report_GCP from prowler.providers.gcp.services.logging.logging_client import logging_client +from prowler.providers.gcp.services.logging.logging_service import ( + get_projects_covered_by_aggregated_metric, +) from prowler.providers.gcp.services.monitoring.monitoring_client import ( monitoring_client, ) @@ -8,12 +11,10 @@ from prowler.providers.gcp.services.monitoring.monitoring_client import ( class logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled(Check): def execute(self) -> Check_Report_GCP: findings = [] + metric_filter = '(protoPayload.serviceName="cloudresourcemanager.googleapis.com") AND (ProjectOwnership OR projectOwnerInvitee) OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="REMOVE" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner") OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="ADD" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner")' projects_with_metric = set() for metric in logging_client.metrics: - if ( - '(protoPayload.serviceName="cloudresourcemanager.googleapis.com") AND (ProjectOwnership OR projectOwnerInvitee) OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="REMOVE" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner") OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="ADD" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner")' - in metric.filter - ): + if metric_filter in metric.filter: metric_name = getattr(metric, "name", None) or "unknown" report = Check_Report_GCP( metadata=self.metadata(), @@ -36,6 +37,9 @@ class logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled( break findings.append(report) + centrally_covered = get_projects_covered_by_aggregated_metric( + logging_client, monitoring_client, metric_filter + ) for project in logging_client.project_ids: if project not in projects_with_metric: project_obj = logging_client.projects.get(project) @@ -47,8 +51,12 @@ class logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled( location=logging_client.region, resource_name=(getattr(project_obj, "name", None) or "GCP Project"), ) - report.status = "FAIL" - report.status_extended = f"There are no log metric filters or alerts associated in project {project}." + if project in centrally_covered: + report.status = "PASS" + report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink." + else: + report.status = "FAIL" + report.status_extended = f"There are no log metric filters or alerts associated in project {project}." findings.append(report) return findings diff --git a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.py b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.py index 3e499db10a..3c03ab0fde 100644 --- a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.py +++ b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.py @@ -1,5 +1,8 @@ from prowler.lib.check.models import Check, Check_Report_GCP from prowler.providers.gcp.services.logging.logging_client import logging_client +from prowler.providers.gcp.services.logging.logging_service import ( + get_projects_covered_by_aggregated_metric, +) from prowler.providers.gcp.services.monitoring.monitoring_client import ( monitoring_client, ) @@ -10,9 +13,10 @@ class logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes ): def execute(self) -> Check_Report_GCP: findings = [] + metric_filter = 'protoPayload.methodName="cloudsql.instances.update"' projects_with_metric = set() for metric in logging_client.metrics: - if 'protoPayload.methodName="cloudsql.instances.update"' in metric.filter: + if metric_filter in metric.filter: report = Check_Report_GCP( metadata=self.metadata(), resource=metric, @@ -30,6 +34,9 @@ class logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes break findings.append(report) + centrally_covered = get_projects_covered_by_aggregated_metric( + logging_client, monitoring_client, metric_filter + ) for project in logging_client.project_ids: if project not in projects_with_metric: report = Check_Report_GCP( @@ -43,8 +50,12 @@ class logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes else "GCP Project" ), ) - report.status = "FAIL" - report.status_extended = f"There are no log metric filters or alerts associated in project {project}." + if project in centrally_covered: + report.status = "PASS" + report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink." + else: + report.status = "FAIL" + report.status_extended = f"There are no log metric filters or alerts associated in project {project}." findings.append(report) return findings diff --git a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.py b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.py index e2b7cdcc13..0e05838f05 100644 --- a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.py +++ b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.py @@ -1,5 +1,8 @@ from prowler.lib.check.models import Check, Check_Report_GCP from prowler.providers.gcp.services.logging.logging_client import logging_client +from prowler.providers.gcp.services.logging.logging_service import ( + get_projects_covered_by_aggregated_metric, +) from prowler.providers.gcp.services.monitoring.monitoring_client import ( monitoring_client, ) @@ -8,12 +11,10 @@ from prowler.providers.gcp.services.monitoring.monitoring_client import ( class logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled(Check): def execute(self) -> Check_Report_GCP: findings = [] + metric_filter = 'resource.type="gce_firewall_rule" AND (protoPayload.methodName:"compute.firewalls.patch" OR protoPayload.methodName:"compute.firewalls.insert" OR protoPayload.methodName:"compute.firewalls.delete")' projects_with_metric = set() for metric in logging_client.metrics: - if ( - 'resource.type="gce_firewall_rule" AND (protoPayload.methodName:"compute.firewalls.patch" OR protoPayload.methodName:"compute.firewalls.insert" OR protoPayload.methodName:"compute.firewalls.delete")' - in metric.filter - ): + if metric_filter in metric.filter: report = Check_Report_GCP( metadata=self.metadata(), resource=metric, @@ -31,6 +32,9 @@ class logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled( break findings.append(report) + centrally_covered = get_projects_covered_by_aggregated_metric( + logging_client, monitoring_client, metric_filter + ) for project in logging_client.project_ids: if project not in projects_with_metric: report = Check_Report_GCP( @@ -44,8 +48,12 @@ class logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled( else "GCP Project" ), ) - report.status = "FAIL" - report.status_extended = f"There are no log metric filters or alerts associated in project {project}." + if project in centrally_covered: + report.status = "PASS" + report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink." + else: + report.status = "FAIL" + report.status_extended = f"There are no log metric filters or alerts associated in project {project}." findings.append(report) return findings diff --git a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.py b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.py index c8b15ce1ee..1330ad7a9a 100644 --- a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.py +++ b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.py @@ -1,5 +1,8 @@ from prowler.lib.check.models import Check, Check_Report_GCP from prowler.providers.gcp.services.logging.logging_client import logging_client +from prowler.providers.gcp.services.logging.logging_service import ( + get_projects_covered_by_aggregated_metric, +) from prowler.providers.gcp.services.monitoring.monitoring_client import ( monitoring_client, ) @@ -8,12 +11,10 @@ from prowler.providers.gcp.services.monitoring.monitoring_client import ( class logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled(Check): def execute(self) -> Check_Report_GCP: findings = [] + metric_filter = 'resource.type="gce_network" AND (protoPayload.methodName:"compute.networks.insert" OR protoPayload.methodName:"compute.networks.patch" OR protoPayload.methodName:"compute.networks.delete" OR protoPayload.methodName:"compute.networks.removePeering" OR protoPayload.methodName:"compute.networks.addPeering")' projects_with_metric = set() for metric in logging_client.metrics: - if ( - 'resource.type="gce_network" AND (protoPayload.methodName:"compute.networks.insert" OR protoPayload.methodName:"compute.networks.patch" OR protoPayload.methodName:"compute.networks.delete" OR protoPayload.methodName:"compute.networks.removePeering" OR protoPayload.methodName:"compute.networks.addPeering")' - in metric.filter - ): + if metric_filter in metric.filter: report = Check_Report_GCP( metadata=self.metadata(), resource=metric, @@ -31,6 +32,9 @@ class logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled(Check) break findings.append(report) + centrally_covered = get_projects_covered_by_aggregated_metric( + logging_client, monitoring_client, metric_filter + ) for project in logging_client.project_ids: if project not in projects_with_metric: report = Check_Report_GCP( @@ -44,8 +48,12 @@ class logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled(Check) else "GCP Project" ), ) - report.status = "FAIL" - report.status_extended = f"There are no log metric filters or alerts associated in project {project}." + if project in centrally_covered: + report.status = "PASS" + report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink." + else: + report.status = "FAIL" + report.status_extended = f"There are no log metric filters or alerts associated in project {project}." findings.append(report) return findings diff --git a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.py b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.py index f840d75852..27f25879e8 100644 --- a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.py +++ b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.py @@ -1,5 +1,8 @@ from prowler.lib.check.models import Check, Check_Report_GCP from prowler.providers.gcp.services.logging.logging_client import logging_client +from prowler.providers.gcp.services.logging.logging_service import ( + get_projects_covered_by_aggregated_metric, +) from prowler.providers.gcp.services.monitoring.monitoring_client import ( monitoring_client, ) @@ -8,12 +11,10 @@ from prowler.providers.gcp.services.monitoring.monitoring_client import ( class logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled(Check): def execute(self) -> Check_Report_GCP: findings = [] + metric_filter = 'resource.type="gce_route" AND (protoPayload.methodName:"compute.routes.delete" OR protoPayload.methodName:"compute.routes.insert")' projects_with_metric = set() for metric in logging_client.metrics: - if ( - 'resource.type="gce_route" AND (protoPayload.methodName:"compute.routes.delete" OR protoPayload.methodName:"compute.routes.insert")' - in metric.filter - ): + if metric_filter in metric.filter: report = Check_Report_GCP( metadata=self.metadata(), resource=metric, @@ -31,6 +32,9 @@ class logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled( break findings.append(report) + centrally_covered = get_projects_covered_by_aggregated_metric( + logging_client, monitoring_client, metric_filter + ) for project in logging_client.project_ids: if project not in projects_with_metric: report = Check_Report_GCP( @@ -44,8 +48,12 @@ class logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled( else "GCP Project" ), ) - report.status = "FAIL" - report.status_extended = f"There are no log metric filters or alerts associated in project {project}." + if project in centrally_covered: + report.status = "PASS" + report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink." + else: + report.status = "FAIL" + report.status_extended = f"There are no log metric filters or alerts associated in project {project}." findings.append(report) return findings diff --git a/prowler/providers/gcp/services/logging/logging_service.py b/prowler/providers/gcp/services/logging/logging_service.py index 637c8782b2..7d8843584e 100644 --- a/prowler/providers/gcp/services/logging/logging_service.py +++ b/prowler/providers/gcp/services/logging/logging_service.py @@ -1,9 +1,12 @@ +import re + from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.providers.gcp.config import DEFAULT_RETRY_ATTEMPTS from prowler.providers.gcp.gcp_provider import GcpProvider from prowler.providers.gcp.lib.service.service import GCPService +from prowler.providers.gcp.services.monitoring.monitoring_service import Monitoring class Logging(GCPService): @@ -12,6 +15,7 @@ class Logging(GCPService): self.sinks = [] self.metrics = [] self._get_sinks() + self._get_org_sinks() self._get_metrics() def _get_sinks(self): @@ -39,6 +43,38 @@ class Logging(GCPService): f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _get_org_sinks(self): + """Fetch org-level sinks with includeChildren so child projects are not falsely failed.""" + org_ids = set() + for project in self.projects.values(): + if project.organization: + org_ids.add(project.organization.id) + + for org_id in org_ids: + try: + request = self.client.sinks().list(parent=f"organizations/{org_id}") + while request is not None: + response = request.execute(num_retries=DEFAULT_RETRY_ATTEMPTS) + + for sink in response.get("sinks", []): + self.sinks.append( + Sink( + name=sink["name"], + destination=sink["destination"], + filter=sink.get("filter", "all"), + project_id=f"organizations/{org_id}", + include_children=sink.get("includeChildren", False), + ) + ) + + request = self.client.sinks().list_next( + previous_request=request, previous_response=response + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + def _get_metrics(self): for project_id in self.project_ids: try: @@ -57,6 +93,7 @@ class Logging(GCPService): type=metric["metricDescriptor"]["type"], filter=metric["filter"], project_id=project_id, + bucket_name=metric.get("bucketName", ""), ) ) @@ -76,6 +113,7 @@ class Sink(BaseModel): destination: str filter: str project_id: str + include_children: bool = False class Metric(BaseModel): @@ -83,3 +121,140 @@ class Metric(BaseModel): type: str filter: str project_id: str + bucket_name: str = "" + + +# A positive selector of the Admin Activity stream: a ``logName`` predicate +# (``:`` has-substring or ``=`` equals) or a ``log_id()`` call. Written verbose +# so each fragment stays legible; ``(?![a-z_])`` keeps a longer stream name +# (``.../activity_v2``) from impersonating Admin Activity. +_ACTIVITY_SELECTOR = re.compile( + r""" + (?: logName \s* [:=] \s* | log_id \s* \( \s* ) # logName: / logName= / log_id( + ["']? [^"'\s)]* # optional quote, then path prefix + cloudaudit\.googleapis\.com/activity (?![a-z_]) # the Admin Activity stream itself + """, + re.IGNORECASE | re.VERBOSE, +) + +# The same selector for *any* Cloud Audit stream (activity, data_access, +# system_event, policy, access_transparency, …). Used to strip the OR-combined +# audit clauses so we can prove nothing restrictive is left over. +_CLOUDAUDIT_SELECTOR = re.compile( + r""" + (?: logName \s* [:=] \s* | log_id \s* \( \s* ) # logName: / logName= / log_id( + ["']? [^"'\s)]* # optional quote, then path prefix + cloudaudit\.googleapis\.com/[a-z_]+ # any cloudaudit stream + ["']? \s* \)? # optional closing quote / paren + """, + re.IGNORECASE | re.VERBOSE, +) + +# Operators that exclude or narrow coverage. Any of these means we cannot prove +# the sink delivers the *whole* Admin Activity stream, so it is not credited. +_NEGATION_OR_RESTRICTION = re.compile( + r""" + \bNOT\b # NOT exclusion + | \bAND\b # AND conjunction (restriction) + | != | !: # "!=" / "!:" inequality + | (?:^|[\s(]) -\s* [A-Za-z_] # leading "-" exclusion operator + """, + re.IGNORECASE | re.VERBOSE, +) + + +def _sink_delivers_activity_logs(sink_filter: str) -> bool: + """True only when a sink's filter *provably* exports the full Admin Activity + audit stream (or everything). + + Crediting flips a child project to PASS on a CIS security control, so the + match is deliberately conservative: a false FAIL is safe, a false PASS is + not. A non-``"all"`` filter is credited only when + + 1. it positively selects the Admin Activity stream + (``logName:.../activity``, ``logName="...activity"`` or + ``log_id("...activity")``); + 2. it carries no operator that excludes or narrows the stream — ``NOT`` / + ``-`` / ``!=`` (negation) or ``AND`` (restriction); and + 3. nothing but ``OR``-combined Cloud Audit selectors remains once those are + stripped — an ``OR`` only widens coverage, but any leftover predicate + (``severity>=ERROR``, ``resource.type=...``) could narrow it. + + Sink filters encode the stream URL-encoded (``...%2Factivity``) or as a path + — normalize before matching. + """ + if not sink_filter or sink_filter.strip().lower() == "all": + return True + normalized = sink_filter.replace("%2F", "/").replace("%2f", "/") + # 1. The Admin Activity stream must be positively selected. + if not _ACTIVITY_SELECTOR.search(normalized): + return False + # 2. No operator may exclude or narrow that coverage. + if _NEGATION_OR_RESTRICTION.search(normalized): + return False + # 3. Only OR-combined audit selectors may remain — strip them and the OR + # glue; anything left is a predicate we cannot prove is full-coverage. + remainder = _CLOUDAUDIT_SELECTOR.sub(" ", normalized) + remainder = re.sub(r"\bOR\b|[()\s]", " ", remainder, flags=re.IGNORECASE) + return remainder.strip() == "" + + +def get_projects_covered_by_aggregated_metric( + logging_client: Logging, + monitoring_client: Monitoring, + metric_filter: str, +) -> dict[str, str]: + """Return {project_id: metric_name} for scanned projects whose logs are routed, + via an organization-level sink with includeChildren=True, to a bucket that holds + a bucket-scoped log metric matching ``metric_filter`` that has an alert policy. + + The CIS GCP logging-metric checks are written per-project, but a common (and + recommended) topology centralizes monitoring: an org-level aggregated sink ships + every child project's logs into one bucket, where a single bucket-scoped metric + + alert covers them all. Without crediting that, those child projects are falsely + failed. Mirrors the org-sink handling already in ``logging_sink_created`` (#11355). + + A sink is credited when it exports everything (``filter == "all"``) or when its + filter carries the Admin Activity audit stream — the only stream the CIS metric + filters can match (see ``_sink_delivers_activity_logs``). + """ + # Buckets that hold a matching, alerted, bucket-scoped metric -> metric name. + bucket_to_metric = {} + for metric in logging_client.metrics: + if not getattr(metric, "bucket_name", ""): + continue + if metric_filter not in metric.filter: + continue + if any( + metric.name in policy_filter + for alert_policy in monitoring_client.alert_policies + for policy_filter in alert_policy.filters + ): + bucket_to_metric[metric.bucket_name] = metric.name + if not bucket_to_metric: + return {} + + # Org resources whose includeChildren sink targets one of those buckets. + org_to_metric = {} + for sink in logging_client.sinks: + if not getattr(sink, "include_children", False): + continue + if not _sink_delivers_activity_logs(getattr(sink, "filter", "all")): + continue + for bucket, metric_name in bucket_to_metric.items(): + # sink.destination e.g. "logging.googleapis.com/projects/.../buckets/X"; + # metric.bucket_name e.g. "projects/.../buckets/X". + if sink.destination.endswith(bucket): + org_to_metric[sink.project_id] = metric_name + break + if not org_to_metric: + return {} + + # Scanned projects sitting under a covering organization. + covered = {} + for project_id in logging_client.project_ids: + project = logging_client.projects.get(project_id) + organization = getattr(project, "organization", None) if project else None + if organization and f"organizations/{organization.id}" in org_to_metric: + covered[project_id] = org_to_metric[f"organizations/{organization.id}"] + return covered diff --git a/prowler/providers/gcp/services/logging/logging_sink_created/logging_sink_created.py b/prowler/providers/gcp/services/logging/logging_sink_created/logging_sink_created.py index 30104a050d..a7846e3dd8 100644 --- a/prowler/providers/gcp/services/logging/logging_sink_created/logging_sink_created.py +++ b/prowler/providers/gcp/services/logging/logging_sink_created/logging_sink_created.py @@ -5,26 +5,30 @@ from prowler.providers.gcp.services.logging.logging_client import logging_client class logging_sink_created(Check): def execute(self) -> Check_Report_GCP: findings = [] + + # Map project_id -> sink for direct project-level sinks projects_with_logging_sink = {} for sink in logging_client.sinks: - if sink.filter == "all": + if sink.filter == "all" and not sink.include_children: projects_with_logging_sink[sink.project_id] = sink + # Collect org resource names that have a covering sink (includeChildren=True) + covering_org_sinks = {} + for sink in logging_client.sinks: + if sink.filter == "all" and sink.include_children: + covering_org_sinks[sink.project_id] = sink + for project in logging_client.project_ids: - if project not in projects_with_logging_sink.keys(): - project_obj = logging_client.projects.get(project) - report = Check_Report_GCP( - metadata=self.metadata(), - resource=project_obj, - resource_id=project, - project_id=project, - location=logging_client.region, - resource_name=(getattr(project_obj, "name", None) or "GCP Project"), - ) - report.status = "FAIL" - report.status_extended = f"There are no logging sinks to export copies of all the log entries in project {project}." - findings.append(report) - else: + project_obj = logging_client.projects.get(project) + + # Determine whether this project is covered by an org-level sink + org = getattr(project_obj, "organization", None) if project_obj else None + org_resource = f"organizations/{org.id}" if org else None + covering_sink = ( + covering_org_sinks.get(org_resource) if org_resource else None + ) + + if project in projects_with_logging_sink: sink = projects_with_logging_sink[project] sink_name = getattr(sink, "name", None) or "unknown" report = Check_Report_GCP( @@ -40,4 +44,31 @@ class logging_sink_created(Check): report.status = "PASS" report.status_extended = f"Sink {sink_name} is enabled exporting copies of all the log entries in project {project}." findings.append(report) + elif covering_sink: + sink_name = getattr(covering_sink, "name", None) or "unknown" + report = Check_Report_GCP( + metadata=self.metadata(), + resource=covering_sink, + resource_id=sink_name, + project_id=project, + location=logging_client.region, + resource_name=( + sink_name if sink_name != "unknown" else "Logging Sink" + ), + ) + report.status = "PASS" + report.status_extended = f"Sink {sink_name} at organization level is exporting copies of all the log entries in project {project}." + findings.append(report) + else: + report = Check_Report_GCP( + metadata=self.metadata(), + resource=project_obj, + resource_id=project, + project_id=project, + location=logging_client.region, + resource_name=(getattr(project_obj, "name", None) or "GCP Project"), + ) + report.status = "FAIL" + report.status_extended = f"There are no logging sinks to export copies of all the log entries in project {project}." + findings.append(report) return findings diff --git a/prowler/providers/m365/lib/powershell/m365_powershell.py b/prowler/providers/m365/lib/powershell/m365_powershell.py index 1bc8aa4075..4dae094d90 100644 --- a/prowler/providers/m365/lib/powershell/m365_powershell.py +++ b/prowler/providers/m365/lib/powershell/m365_powershell.py @@ -950,6 +950,32 @@ class M365PowerShell(PowerShellSession): "Get-TeamsProtectionPolicy | ConvertTo-Json -Depth 10", json_parse=True ) + def get_mailboxes(self) -> dict: + """ + Get Exchange Online Recipient-Facing Mailboxes. + + Retrieves all recipient-facing mailboxes from Exchange Online with the + properties needed to evaluate primary SMTP domain policy. + + Returns: + dict: Mailbox information in JSON format. + + Example: + >>> get_mailboxes() + [ + { + "Identity": "user1@contoso.com", + "DisplayName": "User One", + "PrimarySmtpAddress": "user1@contoso.com", + "RecipientTypeDetails": "UserMailbox" + } + ] + """ + return self.execute( + "Get-EXOMailbox -ResultSize Unlimited | Select-Object Identity, DisplayName, PrimarySmtpAddress, RecipientTypeDetails | ConvertTo-Json -Depth 10", + json_parse=True, + ) + def get_shared_mailboxes(self) -> dict: """ Get Exchange Online Shared Mailboxes. diff --git a/prowler/providers/m365/services/admincenter/admincenter_service.py b/prowler/providers/m365/services/admincenter/admincenter_service.py index 45be16d11a..3899dced67 100644 --- a/prowler/providers/m365/services/admincenter/admincenter_service.py +++ b/prowler/providers/m365/services/admincenter/admincenter_service.py @@ -175,17 +175,23 @@ class AdminCenter(M365Service): try: groups_list = await self.client.groups.get() groups.update({}) - for group in groups_list.value: - groups.update( - { - group.id: Group( - id=group.id, - name=getattr(group, "display_name", ""), - visibility=getattr(group, "visibility", ""), - group_types=getattr(group, "group_types", []) or [], - ) - } - ) + while groups_list: + for group in getattr(groups_list, "value", []) or []: + groups.update( + { + group.id: Group( + id=group.id, + name=getattr(group, "display_name", ""), + visibility=getattr(group, "visibility", ""), + group_types=getattr(group, "group_types", []) or [], + ) + } + ) + + next_link = getattr(groups_list, "odata_next_link", None) + if not next_link: + break + groups_list = await self.client.groups.with_url(next_link).get() except Exception as error: logger.error( diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_deleted_object_references/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_deleted_object_references/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_deleted_object_references/entra_conditional_access_policy_no_deleted_object_references.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_deleted_object_references/entra_conditional_access_policy_no_deleted_object_references.metadata.json new file mode 100644 index 0000000000..42b3acaeb6 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_deleted_object_references/entra_conditional_access_policy_no_deleted_object_references.metadata.json @@ -0,0 +1,44 @@ +{ + "Provider": "m365", + "CheckID": "entra_conditional_access_policy_no_deleted_object_references", + "CheckTitle": "Conditional Access policies must not reference deleted users, groups, or roles", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every object identifier referenced by any Conditional Access policy under conditions.users (includeUsers, excludeUsers, includeGroups, excludeGroups, includeRoles, excludeRoles) must resolve to an existing Microsoft Entra object. This check audits all Conditional Access policies regardless of state and reports any whose user, group, or role references no longer resolve in the directory.", + "Risk": "When a user, group, or directory role referenced by a Conditional Access policy stops resolving (account or group deleted, role template removed), the reference becomes orphaned. include* references silently shrink the policy's enforcement scope; exclude* references can cause the policy to evaluate unexpectedly. This is a common root cause of MFA-not-applied incidents.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/graph/api/resources/conditionalaccesspolicy?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/graph/api/resources/conditionalaccessusers?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/graph/api/user-get?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/graph/api/group-get?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/graph/api/unifiedroledefinition-get?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-users-groups" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Microsoft Entra admin center (https://entra.microsoft.com)\n2. Navigate to Protection > Conditional Access > Policies\n3. Open each policy reported by this check\n4. Under Assignments > Users, remove every user, group, or role identifier reported as deleted\n5. Save the policy and re-run the audit\n6. For ongoing hygiene, audit Conditional Access policies after any bulk user/group/role cleanup", + "Terraform": "" + }, + "Recommendation": { + "Text": "Audit each Conditional Access policy quarterly and remove references to deleted users, groups, or directory roles. Stale references in include collections silently shrink enforcement scope; stale references in exclude collections can cause policies to behave unexpectedly. Treat both as misconfigurations regardless of policy state.", + "Url": "https://hub.prowler.com/check/entra_conditional_access_policy_no_deleted_object_references" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [ + "entra_conditional_access_policy_directory_sync_account_excluded" + ], + "Notes": "The check runs against all Conditional Access policies regardless of state (enabled, disabled, enabledForReportingButNotEnforced) — stale references in disabled policies are a misconfiguration that becomes live the moment the policy is re-enabled. Only HTTP 404 responses flag an identifier as deleted (FAIL). Transient Graph errors (5xx, throttling, insufficient permissions) are not treated as deletions; a policy whose references could not be resolved for those reasons is reported as MANUAL so it is not silently considered clean." +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_deleted_object_references/entra_conditional_access_policy_no_deleted_object_references.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_deleted_object_references/entra_conditional_access_policy_no_deleted_object_references.py new file mode 100644 index 0000000000..e5c5eff389 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_deleted_object_references/entra_conditional_access_policy_no_deleted_object_references.py @@ -0,0 +1,157 @@ +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 ( + CONDITIONAL_ACCESS_SENTINEL_IDS, + ConditionalAccessPolicyState, +) + + +class entra_conditional_access_policy_no_deleted_object_references(Check): + """ + Ensure Conditional Access policies do not reference deleted directory objects. + + Stale references to deleted users, groups, or directory roles silently change + the runtime behavior of a Conditional Access policy: include* references + shrink enforcement scope, exclude* references can change exemption logic. + Either way, the policy stops behaving the way the operator believes it does. + + The directory-object existence check runs once at service init time and is + cached on the entra client. This check reads from that cache and reports any + policy whose users/groups/roles inclusion or exclusion collections name an + identifier that no longer resolves in Microsoft Entra ID. + + Identifiers whose Graph lookup failed with a non-404 error (5xx, throttling, + insufficient permissions) are cached separately: they could not be verified + as present or deleted, so the policy is reported as MANUAL rather than being + silently treated as clean. + + - PASS: The policy references no deleted users, groups, or roles. + - FAIL: The policy references at least one deleted user, group, or role. + - MANUAL: At least one referenced identifier could not be resolved due to a + transient Graph error, so the policy could not be fully evaluated. + """ + + def execute(self) -> list[CheckReportM365]: + findings = [] + unresolved = entra_client.unresolved_directory_object_references + errored = entra_client.errored_directory_object_references + + for policy_id, policy in entra_client.conditional_access_policies.items(): + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name=policy.display_name, + resource_id=policy_id, + ) + + orphans = self._collect_references_in(policy, unresolved) + unverified = self._collect_references_in(policy, errored) + + if orphans: + # A confirmed deletion takes precedence over unverified ones. + report.status = "FAIL" + report.status_extended = self._format_failure( + policy.display_name, orphans, unverified, policy.state + ) + elif unverified: + # Nothing confirmed deleted, but we could not verify every + # reference — do not claim the policy is clean. + report.status = "MANUAL" + report.status_extended = self._format_manual( + policy.display_name, unverified + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Conditional Access policy {policy.display_name} references no " + f"deleted directory objects." + ) + + findings.append(report) + + return findings + + @staticmethod + def _collect_references_in(policy, id_set): + """Walk the six identifier collections and return references in ``id_set``. + + Args: + policy: The Conditional Access policy to inspect. + id_set: Set of ``(type, id)`` pairs to match references against. + + Returns: + list[tuple[str, str, str]]: ``(type, id, side)`` tuples where + ``type`` is one of ``user|group|role``, ``id`` is the Graph + identifier, and ``side`` is one of ``include|exclude``. + """ + if not policy.conditions or not policy.conditions.user_conditions: + return [] + + uc = policy.conditions.user_conditions + collections = ( + ("user", "include", uc.included_users), + ("user", "exclude", uc.excluded_users), + ("group", "include", uc.included_groups), + ("group", "exclude", uc.excluded_groups), + ("role", "include", uc.included_roles), + ("role", "exclude", uc.excluded_roles), + ) + + matches = [] + for type_, side, identifiers in collections: + for identifier in identifiers: + if identifier in CONDITIONAL_ACCESS_SENTINEL_IDS: + continue + if (type_, identifier) in id_set: + matches.append((type_, identifier, side)) + return matches + + @staticmethod + def _group_by_type(references): + """Group ``(type, id, side)`` references into a deterministic message part.""" + by_type = {"user": [], "group": [], "role": []} + for type_, identifier, side in references: + by_type[type_].append(f"{identifier} ({side})") + + parts = [] + for type_ in ("user", "group", "role"): + if by_type[type_]: + joined = ", ".join(sorted(by_type[type_])) + parts.append(f"{type_}s: {joined}") + return "; ".join(parts) + + @classmethod + def _format_failure(cls, display_name, orphans, unverified, state=None): + # Surface report-only mode explicitly: the stale references are not yet + # enforced, but become live the moment the policy is turned on. + report_only = ( + " The policy is in report-only mode, so these references are not " + "enforced yet but will take effect once it is enabled." + if state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING + else "" + ) + + # If some references also could not be resolved, say so rather than + # implying the rest of the policy is fully clean. + unverified_note = ( + f" Additionally, {len(unverified)} reference(s) could not be verified " + f"due to transient Microsoft Graph errors." + if unverified + else "" + ) + + return ( + f"Conditional Access policy {display_name} references " + f"{len(orphans)} deleted directory object(s) — " + f"{cls._group_by_type(orphans)}.{report_only}{unverified_note}" + ) + + @classmethod + def _format_manual(cls, display_name, unverified): + return ( + f"Conditional Access policy {display_name} could not be fully evaluated: " + f"{len(unverified)} reference(s) could not be resolved due to transient " + f"Microsoft Graph errors (5xx, throttling, or insufficient permissions) — " + f"{cls._group_by_type(unverified)}. Re-run the scan or review the policy " + f"manually." + ) diff --git a/prowler/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/__init__.py b/prowler/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked.metadata.json b/prowler/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked.metadata.json new file mode 100644 index 0000000000..0cc14e2965 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked.metadata.json @@ -0,0 +1,42 @@ +{ + "Provider": "m365", + "CheckID": "entra_directory_sync_object_takeover_blocked", + "CheckTitle": "Microsoft Entra directory sync must block object takeover (soft- and hard-matching)", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "When on-premises directory synchronization is enabled, both blockSoftMatchEnabled and blockCloudObjectTakeoverThroughHardMatchEnabled must be true. Without these blocks, an attacker who can write to on-premises AD can craft an object that matches a privileged cloud account and take it over.", + "Risk": "An attacker with write access to on-premises Active Directory can create an object whose UPN, SMTP address, or ImmutableID matches an existing cloud-only account (e.g. Global Administrator). When the sync engine processes this object, it merges the on-premises identity into the cloud account, effectively granting the attacker full control of that privileged account.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/graph/api/resources/onpremisesdirectorysynchronization?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/graph/api/resources/onpremisesdirectorysynchronizationfeature?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/entra/identity/hybrid/connect/how-to-connect-syncservice-features" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Open Microsoft Entra admin center\n2. Navigate to Identity > Hybrid management > Microsoft Entra Connect > Connect Sync\n3. Enable 'Block soft match' and 'Block cloud object takeover through hard match'\n4. Alternatively, use Microsoft Graph API to set both features to true on the onPremisesDirectorySynchronization resource", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable both blockSoftMatchEnabled and blockCloudObjectTakeoverThroughHardMatchEnabled on the on-premises directory synchronization configuration. These should remain enabled permanently except during time-boxed migration windows.", + "Url": "https://hub.prowler.com/check/entra_directory_sync_object_takeover_blocked" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [ + "entra_password_hash_sync_enabled", + "entra_seamless_sso_disabled" + ], + "Notes": "This check only applies to hybrid tenants with on-premises directory synchronization enabled. Cloud-only tenants receive a PASS since the attack path does not exist." +} diff --git a/prowler/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked.py b/prowler/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked.py new file mode 100644 index 0000000000..d1f00a70ff --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked.py @@ -0,0 +1,118 @@ +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_directory_sync_object_takeover_blocked(Check): + """Check that directory sync blocks object takeover via soft-match and hard-match. + + When on-premises directory synchronization is enabled, an attacker who can + write to on-premises AD can craft an object that matches a privileged cloud + account and take it over. Both blockSoftMatchEnabled and + blockCloudObjectTakeoverThroughHardMatchEnabled must be true to prevent this. + + The attack path only exists on hybrid tenants, so the tenant's + organization.onPremisesSyncEnabled is evaluated first. Microsoft Graph + returns an onPremisesSynchronization object (with all features disabled) even + for cloud-only tenants, so the directory sync features must not be evaluated + unless on-premises synchronization is actually enabled. + + - PASS: The tenant is cloud-only, or both block flags are enabled. + - FAIL: On-premises sync is enabled and either block flag is disabled. + - MANUAL: On-premises sync is enabled but the settings cannot be read + (insufficient permissions) or were not returned by Microsoft Graph. + """ + + def execute(self) -> List[CheckReportM365]: + findings = [] + + organizations = entra_client.organizations or [] + on_premises_sync_enabled = any( + organization.on_premises_sync_enabled for organization in organizations + ) + + # Cloud-only tenant: the object takeover attack path does not exist, so + # the directory sync features are not evaluated even if Microsoft Graph + # returns an (all-disabled) onPremisesSynchronization object. + if organizations and not on_premises_sync_enabled: + for organization in organizations: + report = CheckReportM365( + self.metadata(), + resource=organization, + 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), object takeover protection is not " + "applicable." + ) + findings.append(report) + return findings + + # Hybrid tenant but the directory sync settings could not be read. + if entra_client.directory_sync_error: + for organization in organizations: + report = CheckReportM365( + self.metadata(), + resource=organization, + resource_id=organization.id, + resource_name=organization.name, + ) + report.status = "MANUAL" + report.status_extended = ( + f"Cannot verify object takeover protection for " + f"{organization.name}: {entra_client.directory_sync_error}." + ) + findings.append(report) + return findings + + for sync_settings in entra_client.directory_sync_settings: + report = CheckReportM365( + self.metadata(), + resource=sync_settings, + resource_id=sync_settings.id, + resource_name=f"Directory Sync {sync_settings.id}", + ) + + disabled_flags = [] + if not sync_settings.block_soft_match_enabled: + disabled_flags.append("blockSoftMatchEnabled") + if not sync_settings.block_cloud_object_takeover_through_hard_match_enabled: + disabled_flags.append("blockCloudObjectTakeoverThroughHardMatchEnabled") + + if not disabled_flags: + report.status = "PASS" + report.status_extended = ( + f"Entra directory sync {sync_settings.id} blocks both soft-match " + "and hard-match object takeover." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Entra directory sync {sync_settings.id} does not block object " + f"takeover: {', '.join(disabled_flags)} disabled." + ) + + findings.append(report) + + # Hybrid tenant that reported on-premises sync but returned no settings. + if not entra_client.directory_sync_settings: + for organization in organizations: + report = CheckReportM365( + self.metadata(), + resource=organization, + resource_id=organization.id, + resource_name=organization.name, + ) + report.status = "MANUAL" + report.status_extended = ( + f"Entra organization {organization.name} has on-premises sync " + "enabled, but no directory sync settings were returned. Review " + "the tenant configuration manually." + ) + 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 a09ee32da0..bbaa80edcc 100644 --- a/prowler/providers/m365/services/entra/entra_service.py +++ b/prowler/providers/m365/services/entra/entra_service.py @@ -3,7 +3,7 @@ import json from asyncio import gather from datetime import datetime, timezone from enum import Enum -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Set, Tuple from uuid import UUID from kiota_abstractions.base_request_configuration import RequestConfiguration @@ -18,6 +18,12 @@ from prowler.lib.logger import logger from prowler.providers.m365.lib.service.service import M365Service from prowler.providers.m365.m365_provider import M365Provider +# Sentinel identifiers used in Conditional Access ``conditions.users`` +# collections that do not correspond to real directory objects and must not be +# resolved against Graph. Shared by the resolver below and the check that reads +# its result. +CONDITIONAL_ACCESS_SENTINEL_IDS = {"All", "None", "GuestsOrExternalUsers"} + class Entra(M365Service): """ @@ -110,6 +116,23 @@ class Entra(M365Service): self.app_registrations: Dict[str, "AppRegistration"] = attributes[11] self.user_accounts_status = {} + # Resolve directory-object identifiers referenced by Conditional Access + # policies. This runs as a separate phase because it depends on the + # main gather having populated ``conditional_access_policies`` first. + # The result is cached on the instance so sync checks can read it + # without issuing Graph calls of their own. ``unresolved`` holds ids + # confirmed deleted (HTTP 404); ``errored`` holds ids whose lookup + # failed for any other reason (5xx, throttling, permission) and could + # therefore be neither confirmed present nor confirmed deleted. + self.unresolved_directory_object_references: Set[Tuple[str, str]] + self.errored_directory_object_references: Set[Tuple[str, str]] + ( + self.unresolved_directory_object_references, + self.errored_directory_object_references, + ) = loop.run_until_complete( + self._resolve_directory_object_references(self.conditional_access_policies) + ) + if created_loop: asyncio.set_event_loop(None) loop.close() @@ -791,6 +814,16 @@ class Entra(M365Service): features, "seamless_sso_enabled", False ) or False, + block_soft_match_enabled=getattr( + features, "block_soft_match_enabled", False + ) + or False, + block_cloud_object_takeover_through_hard_match_enabled=getattr( + features, + "block_cloud_object_takeover_through_hard_match_enabled", + False, + ) + or False, ) ) except ODataError as error: @@ -830,6 +863,7 @@ class Entra(M365Service): "userType", "accountEnabled", "onPremisesSyncEnabled", + "employeeHireDate", ], ) ) @@ -890,6 +924,7 @@ class Entra(M365Service): "authentication_methods", [] ), user_type=getattr(user, "user_type", None), + employee_hire_date=getattr(user, "employee_hire_date", None), ) next_link = getattr(users_response, "odata_next_link", None) @@ -1197,6 +1232,10 @@ OAuthAppInfo service_principals_by_app_id = { sp.app_id: sp for sp in service_principals.values() if sp.app_id } + # Remember each SP's parent application object ID so the owner + # lookup below can address it directly without re-walking + # /applications. + application_object_id_by_sp_id: Dict[str, str] = {} app_response = await self.client.applications.get() while app_response: for app in getattr(app_response, "value", []) or []: @@ -1207,6 +1246,10 @@ OAuthAppInfo if target_sp is None: continue + app_object_id = getattr(app, "id", None) + if app_object_id: + application_object_id_by_sp_id[target_sp.id] = app_object_id + for cred in getattr(app, "password_credentials", []) or []: target_sp.password_credentials.append( PasswordCredential( @@ -1257,6 +1300,49 @@ OAuthAppInfo next_link ).get() + # Resolve owners only for service principals that hold a permanent + # Tier 0 directory role. Owner ownership of the SP object or its + # parent app registration is a credential-rotation escalation path + # outside PIM and Conditional Access; fetching owners for every + # consented SP would multiply Graph traffic for no benefit. + for sp in service_principals.values(): + if not sp.directory_role_template_ids: + continue + try: + sp_owners_response = ( + await self.client.service_principals.by_service_principal_id( + sp.id + ).owners.get() + ) + sp.sp_owner_ids = [ + getattr(owner, "id", None) + for owner in (getattr(sp_owners_response, "value", []) or []) + if getattr(owner, "id", None) + ] + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + app_object_id = application_object_id_by_sp_id.get(sp.id) + if not app_object_id: + continue + try: + app_owners_response = ( + await self.client.applications.by_application_id( + app_object_id + ).owners.get() + ) + sp.app_owner_ids = [ + getattr(owner, "id", None) + for owner in (getattr(app_owners_response, "value", []) or []) + if getattr(owner, "id", None) + ] + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" @@ -1316,6 +1402,130 @@ OAuthAppInfo ) return app_registrations + async def _resolve_directory_object_references( + self, + policies: Dict[str, "ConditionalAccessPolicy"], + ) -> Tuple[Set[Tuple[str, str]], Set[Tuple[str, str]]]: + """Resolve every user/group/role identifier referenced by CA policies. + + Walks the inclusion/exclusion collections of every loaded Conditional + Access policy, deduplicates the resulting identifiers per type, and + queries Microsoft Graph for each one. Identifiers that return HTTP 404 + are reported as deleted. Non-404 errors (5xx, throttling, permission, + transient network failures) are reported as unresolvable: they must not + be flagged as deletions, but they must also not be silently treated as + clean resolutions, so the downstream check surfaces them as MANUAL. + + The sentinel values ``All``, ``None``, and ``GuestsOrExternalUsers`` + are not directory identifiers and are excluded before any Graph call. + + Args: + policies: Conditional Access policies keyed by policy ID. + + Returns: + Tuple[Set[Tuple[str, str]], Set[Tuple[str, str]]]: A pair of + ``(type, identifier)`` sets. The first holds identifiers that + failed to resolve via Graph with HTTP 404 (deleted); the second + holds identifiers whose lookup failed for any other reason and + could be neither confirmed present nor confirmed deleted. + """ + logger.info( + "Entra - Resolving directory-object references in Conditional " + "Access policies..." + ) + + ids_by_type: Dict[str, Set[str]] = { + "user": set(), + "group": set(), + "role": set(), + } + + for policy in policies.values(): + if not getattr(policy, "conditions", None): + continue + user_conditions = getattr(policy.conditions, "user_conditions", None) + if user_conditions is None: + continue + for ident in (user_conditions.included_users or []) + ( + user_conditions.excluded_users or [] + ): + if ident and ident not in CONDITIONAL_ACCESS_SENTINEL_IDS: + ids_by_type["user"].add(ident) + for ident in (user_conditions.included_groups or []) + ( + user_conditions.excluded_groups or [] + ): + if ident and ident not in CONDITIONAL_ACCESS_SENTINEL_IDS: + ids_by_type["group"].add(ident) + for ident in (user_conditions.included_roles or []) + ( + user_conditions.excluded_roles or [] + ): + if ident and ident not in CONDITIONAL_ACCESS_SENTINEL_IDS: + ids_by_type["role"].add(ident) + + unresolved: Set[Tuple[str, str]] = set() + errored: Set[Tuple[str, str]] = set() + + # Resolve types in parallel; within a type, walk identifiers serially + # to keep concurrent Graph calls bounded and avoid throttling. + await gather( + self._resolve_identifiers_for_type( + "user", ids_by_type["user"], unresolved, errored + ), + self._resolve_identifiers_for_type( + "group", ids_by_type["group"], unresolved, errored + ), + self._resolve_identifiers_for_type( + "role", ids_by_type["role"], unresolved, errored + ), + ) + return unresolved, errored + + async def _resolve_identifiers_for_type( + self, + type_: str, + identifiers: Set[str], + unresolved: Set[Tuple[str, str]], + errored: Set[Tuple[str, str]], + ) -> None: + """Resolve a set of identifiers of a given type, mutating the result sets. + + Only HTTP 404 (or ``Request_ResourceNotFound``) responses add to + ``unresolved``. Every other error (5xx, throttling, permission, or an + unexpected exception) is logged and added to ``errored`` so the check + can report it as unverified instead of silently treating it as clean. + """ + for identifier in identifiers: + try: + if type_ == "user": + await self.client.users.by_user_id(identifier).get() + elif type_ == "group": + await self.client.groups.by_group_id(identifier).get() + elif type_ == "role": + await self.client.role_management.directory.role_definitions.by_unified_role_definition_id( + identifier + ).get() + else: + continue + except ODataError as error: + status_code = getattr(error, "response_status_code", None) + error_code = getattr(error.error, "code", None) if error.error else None + if status_code == 404 or error_code == "Request_ResourceNotFound": + unresolved.add((type_, identifier)) + else: + errored.add((type_, identifier)) + logger.warning( + f"Entra - Could not resolve {type_} '{identifier}' for " + f"Conditional Access reference check: " + f"{error.__class__.__name__}: {error}" + ) + except Exception as error: + errored.add((type_, identifier)) + logger.warning( + f"Entra - Unexpected error resolving {type_} '{identifier}' " + f"for Conditional Access reference check: " + f"{error.__class__.__name__}: {error}" + ) + class ConditionalAccessPolicyState(Enum): ENABLED = "enabled" @@ -1436,7 +1646,7 @@ class PlatformConditions(BaseModel): @validator("include_platforms", "exclude_platforms", pre=True) @classmethod - def normalize_platforms(cls, values): + def normalize_platforms(cls, values): # noqa: vulture if not values: return [] @@ -1584,6 +1794,8 @@ class DirectorySyncSettings(BaseModel): id: str password_sync_enabled: bool = False seamless_sso_enabled: bool = False + block_soft_match_enabled: bool = False + block_cloud_object_takeover_through_hard_match_enabled: bool = False class AuthenticationMethodConfiguration(BaseModel): @@ -1674,6 +1886,7 @@ class User(BaseModel): user_type: The user account type as reported by Microsoft Graph (typically 'Member' or 'Guest'). ``None`` when Microsoft Graph does not return the property; checks must not assume a default in that case. + employee_hire_date: The user's hire date as reported by Microsoft Graph. """ id: str @@ -1684,6 +1897,7 @@ class User(BaseModel): account_enabled: bool = True authentication_methods: List[str] = [] user_type: Optional[str] = None + employee_hire_date: Optional[datetime] = None class InvitationsFrom(Enum): @@ -1832,6 +2046,12 @@ class ServicePrincipal(BaseModel): key_credentials: List of key credentials (certificates). directory_role_template_ids: List of directory role template IDs permanently assigned to this service principal. + sp_owner_ids: Principal IDs that own the service principal object. + Populated only for service principals that hold a permanent Tier 0 + directory role assignment, to keep Graph traffic bounded. + app_owner_ids: Principal IDs that own the parent app registration. + Populated only for service principals that hold a permanent Tier 0 + directory role assignment. """ id: str @@ -1841,6 +2061,8 @@ class ServicePrincipal(BaseModel): password_credentials: List[PasswordCredential] = [] key_credentials: List[KeyCredential] = [] directory_role_template_ids: List[str] = [] + sp_owner_ids: List[str] = [] + app_owner_ids: List[str] = [] class AppRegistration(BaseModel): diff --git a/prowler/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/__init__.py b/prowler/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners.metadata.json b/prowler/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners.metadata.json new file mode 100644 index 0000000000..4822d8956d --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "m365", + "CheckID": "entra_service_principal_privileged_role_no_owners", + "CheckTitle": "Service principals with privileged Entra directory roles must have no owners", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "critical", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Microsoft Entra **service principals** holding permanent **Control Plane (Tier 0)** directory roles (such as **Global Administrator** or **Privileged Role Administrator**) are evaluated for the presence of **owners** on either the service principal itself or its parent **app registration**.", + "Risk": "An **owner** of a service principal or its parent app registration can **rotate credentials** and sign in as the service principal, inheriting its **Tier 0** role outside **PIM** and **Conditional Access** controls. This is a documented privilege escalation path impacting **confidentiality**, **integrity**, and **availability** of the tenant's control plane.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/graph/api/serviceprincipal-list-owners?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/graph/api/application-list-owners?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/graph/api/rbacapplication-list-roleassignments?view=graph-rest-1.0" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Microsoft Entra admin center (https://entra.microsoft.com)\n2. Go to Identity > Applications > Enterprise applications > select the service principal\n3. Under Owners, remove all owners\n4. Repeat for the parent App Registration under Identity > Applications > App registrations\n5. Use PIM eligible assignments instead of permanent role assignments where possible", + "Terraform": "" + }, + "Recommendation": { + "Text": "Remove all owners from service principals that hold privileged Entra directory roles. Manage privileged service principals exclusively via PIM-eligible role assignments and break-glass controls. Ensure no human account can silently inherit control-plane privileges through ownership.", + "Url": "https://hub.prowler.com/check/entra_service_principal_privileged_role_no_owners" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [ + "entra_service_principal_no_secrets_for_permanent_tier0_roles" + ], + "Notes": "Only service principals with permanent Tier 0 directory role assignments are evaluated. Microsoft first-party service principals and multi-tenant ISV apps consented from other publishers are excluded by the service layer." +} diff --git a/prowler/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners.py b/prowler/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners.py new file mode 100644 index 0000000000..f3af26e23f --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners.py @@ -0,0 +1,71 @@ +"""Check for service principals with privileged roles that have owners.""" + +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_service_principal_privileged_role_no_owners(Check): + """Service principal with a permanent Tier 0 directory role has no owners. + + Owners of a service principal or its parent app registration can rotate + credentials and sign in as the service principal, inheriting its privileged + directory role outside PIM approval flows and Conditional Access policies + targeting user accounts. + + - PASS: The service principal does not hold a permanent Tier 0 directory + role, or it does but has zero owners on both the service principal and + its parent app registration. + - FAIL: The service principal holds a permanent Tier 0 directory role and + has at least one owner on either the service principal or its parent + app registration. + """ + + def execute(self) -> List[CheckReportM365]: + """Execute the privileged service principal owner check. + + Returns: + A list of reports, one per service principal owned by the audited + tenant. + """ + findings = [] + for sp in entra_client.service_principals.values(): + report = CheckReportM365( + metadata=self.metadata(), + resource=sp, + resource_name=sp.name, + resource_id=sp.id, + ) + + if not sp.directory_role_template_ids: + report.status = "PASS" + report.status_extended = ( + f"Service principal '{sp.name}' has no permanent Tier 0 " + f"directory role assignments." + ) + findings.append(report) + continue + + unique_owners = set(sp.sp_owner_ids) | set(sp.app_owner_ids) + tier0_role_count = len(sp.directory_role_template_ids) + + if unique_owners: + report.status = "FAIL" + report.status_extended = ( + f"Service principal '{sp.name}' holds {tier0_role_count} " + f"permanent Tier 0 directory role(s) and has " + f"{len(unique_owners)} owner(s) " + f"({len(sp.sp_owner_ids)} on the service principal, " + f"{len(sp.app_owner_ids)} on the parent app registration)." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Service principal '{sp.name}' holds {tier0_role_count} " + f"permanent Tier 0 directory role(s) and has no owners on " + f"either the service principal or its parent app registration." + ) + + findings.append(report) + return findings 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 9485676d30..d3e75cef7f 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 @@ -1,3 +1,4 @@ +from datetime import datetime, timezone from typing import List from prowler.lib.check.models import Check, CheckReportM365 @@ -12,7 +13,8 @@ class entra_users_mfa_capable(Check): Microsoft 365 Foundations Benchmark recommendation 5.2.3.4 ("Ensure all member users are 'MFA capable'"). - Guest users and disabled accounts are excluded from the evaluation. + Guest users, disabled accounts, and future hires are excluded from the + evaluation. - PASS: The member user is MFA capable. - FAIL: The member user is not MFA capable, or MFA capability cannot be @@ -38,6 +40,15 @@ class entra_users_mfa_capable(Check): for user in entra_client.users.values(): if user.user_type == "Guest" or not user.account_enabled: continue + if user.employee_hire_date: + employee_hire_date = user.employee_hire_date + if ( + employee_hire_date.tzinfo is None + or employee_hire_date.utcoffset() is None + ): + employee_hire_date = employee_hire_date.replace(tzinfo=timezone.utc) + if employee_hire_date > datetime.now(timezone.utc): + continue report = CheckReportM365( metadata=self.metadata(), diff --git a/prowler/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/__init__.py b/prowler/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain.metadata.json b/prowler/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain.metadata.json new file mode 100644 index 0000000000..66ce055152 --- /dev/null +++ b/prowler/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "m365", + "CheckID": "exchange_mailbox_primary_smtp_uses_custom_domain", + "CheckTitle": "Mailbox primary SMTP address must use a custom domain", + "CheckType": [], + "ServiceName": "exchange", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "collaboration", + "Description": "**Exchange Online mailboxes** should use a custom domain as their primary SMTP address, not the default **\\*.onmicrosoft.com** routing domain assigned by Microsoft on tenant creation. This check verifies that the **PrimarySmtpAddress** of every user-facing mailbox does not end with `.onmicrosoft.com`.", + "Risk": "Mailboxes still using **.onmicrosoft.com** as their primary SMTP address leak the internal **tenant identifier** in every From: header, helping attackers fingerprint the tenant for spear-phishing. They also bypass **DMARC/DKIM** hardening that organisations deploy on their custom domains and are frequently treated as low-trust by recipient anti-phishing engines.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/microsoft-365/admin/setup/add-domain", + "https://learn.microsoft.com/en-us/microsoft-365/admin/setup/domains-faq", + "https://learn.microsoft.com/en-us/powershell/module/exchange/get-mailbox", + "https://learn.microsoft.com/en-us/exchange/recipients-in-exchange-online/manage-user-mailboxes/manage-user-mailboxes" + ], + "Remediation": { + "Code": { + "CLI": "Get-Mailbox -ResultSize Unlimited | Where-Object { $_.PrimarySmtpAddress -like '*.onmicrosoft.com' } | ForEach-Object { Set-Mailbox -Identity $_.Identity -PrimarySmtpAddress '@' }", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft 365 admin center (https://admin.microsoft.com/)\n2. Go to Users > Active users and select the affected user\n3. Under the Aliases section, add the custom domain email address\n4. Set the custom domain address as the primary SMTP address\n5. Save changes and repeat for all affected mailboxes", + "Terraform": "" + }, + "Recommendation": { + "Text": "Update the primary SMTP address of all affected mailboxes to use a custom domain. Ensure your custom domain is verified in the Microsoft 365 admin center before making this change.", + "Url": "https://hub.prowler.com/check/exchange_mailbox_primary_smtp_uses_custom_domain" + } + }, + "Categories": [ + "email-security" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain.py b/prowler/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain.py new file mode 100644 index 0000000000..d34d091145 --- /dev/null +++ b/prowler/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain.py @@ -0,0 +1,79 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.exchange.exchange_client import exchange_client + + +class exchange_mailbox_primary_smtp_uses_custom_domain(Check): + """ + Verify that every Exchange Online mailbox uses a custom domain as its + primary SMTP address, not the default .onmicrosoft.com routing domain. + + The .onmicrosoft.com domain is assigned by Microsoft on tenant creation + and is not intended for ongoing mail. Mailboxes still using it leak the + internal tenant identifier in every From: header (aiding spear-phishing), + bypass DMARC/DKIM hardening on custom domains and are often treated as + low-trust by recipient anti-phishing engines. + + - PASS: Primary SMTP address does not use the .onmicrosoft.com domain. + - FAIL: Primary SMTP address uses the .onmicrosoft.com domain. + - MANUAL: Exchange Online PowerShell unavailable; check cannot run. + """ + + def execute(self) -> List[CheckReportM365]: + """ + Execute the check against all recipient-facing Exchange Online mailboxes. + + Returns: + List[CheckReportM365]: A report for each mailbox with its SMTP + domain status, or a single MANUAL report if PowerShell was + unavailable. + """ + findings = [] + + # mailboxes is None when Exchange Online PowerShell could not be + # reached or the cmdlet raised. An empty list means PowerShell ran + # but the tenant has no recipient-facing mailboxes (no findings). + if exchange_client.mailboxes is None: + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Exchange Online Mailboxes", + resource_id="exchange_mailboxes", + ) + report.status = "MANUAL" + report.status_extended = ( + "Exchange Online PowerShell is unavailable. " + "Enable EXO PowerShell access to run this check." + ) + findings.append(report) + return findings + + for mailbox in exchange_client.mailboxes: + report = CheckReportM365( + metadata=self.metadata(), + resource=mailbox, + resource_name=mailbox.name or mailbox.identity, + resource_id=mailbox.identity, + ) + + if mailbox.primary_smtp_address.endswith(".onmicrosoft.com"): + report.status = "FAIL" + report.status_extended = ( + f"Mailbox {mailbox.identity} " + f"({mailbox.recipient_type_details}) has primary SMTP " + f"address {mailbox.primary_smtp_address} using the " + f".onmicrosoft.com domain instead of a custom domain." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Mailbox {mailbox.identity} " + f"({mailbox.recipient_type_details}) has primary SMTP " + f"address {mailbox.primary_smtp_address} using a " + f"custom domain." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/m365/services/exchange/exchange_service.py b/prowler/providers/m365/services/exchange/exchange_service.py index 9b3ee47da9..3ce6528aa9 100644 --- a/prowler/providers/m365/services/exchange/exchange_service.py +++ b/prowler/providers/m365/services/exchange/exchange_service.py @@ -8,6 +8,15 @@ from prowler.lib.logger import logger from prowler.providers.m365.lib.service.service import M365Service from prowler.providers.m365.m365_provider import M365Provider +SYSTEM_MAILBOX_TYPES = { + "DiscoveryMailbox", + "ArbitrationMailbox", + "AuditLogMailbox", + "MonitoringMailbox", + "AuxAuditLogMailbox", + "SystemMailbox", +} + class Exchange(M365Service): """ @@ -34,6 +43,7 @@ class Exchange(M365Service): self.role_assignment_policies = [] self.mailbox_audit_properties = [] self.shared_mailboxes = [] + self.mailboxes = None if self.powershell: if self.powershell.connect_exchange_online(): @@ -46,6 +56,7 @@ class Exchange(M365Service): self.role_assignment_policies = self._get_role_assignment_policies() self.mailbox_audit_properties = self._get_mailbox_audit_properties() self.shared_mailboxes = self._get_shared_mailboxes() + self.mailboxes = self._get_mailboxes() self.powershell.close() # Fetch license count via Graph API @@ -355,6 +366,50 @@ class Exchange(M365Service): ) return shared_mailboxes + def _get_mailboxes(self) -> Optional[list["Mailbox"]]: + """ + Get all recipient-facing mailboxes from Exchange Online. + + Retrieves mailboxes of types UserMailbox, SharedMailbox, RoomMailbox + and EquipmentMailbox. System-managed mailbox types are excluded as + they are controlled by Microsoft and are not subject to domain policy. + + Returns: + list[Mailbox]: List of mailboxes with their primary SMTP address + and recipient type details. Returns ``None`` when the + underlying PowerShell cmdlet raises, so callers can + distinguish "PowerShell unavailable" from "empty tenant". + """ + logger.info("Microsoft365 - Getting mailboxes...") + mailboxes = [] + try: + mailboxes_data = self.powershell.get_mailboxes() + if not mailboxes_data: + return mailboxes + # PowerShell can return a single dict instead of a list when only + # one result is returned; normalize to a list for uniform handling. + if isinstance(mailboxes_data, dict): + mailboxes_data = [mailboxes_data] + for mailbox in mailboxes_data: + if mailbox: + recipient_type = mailbox.get("RecipientTypeDetails", "") + if recipient_type in SYSTEM_MAILBOX_TYPES: + continue + mailboxes.append( + Mailbox( + identity=mailbox.get("Identity", ""), + name=mailbox.get("DisplayName", ""), + primary_smtp_address=mailbox.get("PrimarySmtpAddress", ""), + recipient_type_details=recipient_type, + ) + ) + return mailboxes + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return None + class Organization(BaseModel): """ @@ -497,3 +552,22 @@ class SharedMailbox(BaseModel): user_principal_name: str external_directory_object_id: str identity: str + + +class Mailbox(BaseModel): + """ + Model for an Exchange Online recipient-facing mailbox. + + Attributes: + identity: The unique identity of the mailbox in Exchange. + name: Display name of the mailbox. + primary_smtp_address: The primary SMTP address used for outbound mail + and the From: header. This is the address the check evaluates. + recipient_type_details: The mailbox type (e.g., UserMailbox, + SharedMailbox, RoomMailbox, EquipmentMailbox). + """ + + identity: str + name: str + primary_smtp_address: str + recipient_type_details: str diff --git a/prowler/providers/okta/lib/arguments/arguments.py b/prowler/providers/okta/lib/arguments/arguments.py index 287f786b0f..4ed5cfa187 100644 --- a/prowler/providers/okta/lib/arguments/arguments.py +++ b/prowler/providers/okta/lib/arguments/arguments.py @@ -35,7 +35,8 @@ def init_parser(self): nargs="+", help=( "OAuth scopes to request, space-separated " - "(e.g. okta.policies.read okta.brands.read okta.apps.read). " + "(e.g. okta.policies.read okta.brands.read okta.apps.read " + "okta.logStreams.read okta.idps.read). " "Defaults to the read scopes required by the bundled checks." ), default=None, diff --git a/prowler/providers/okta/lib/service/pagination.py b/prowler/providers/okta/lib/service/pagination.py new file mode 100644 index 0000000000..a30bbf9477 --- /dev/null +++ b/prowler/providers/okta/lib/service/pagination.py @@ -0,0 +1,69 @@ +"""Shared pagination helpers for Okta SDK list calls. + +The Okta SDK exposes paginated list endpoints (`list_applications`, +`list_policies`, `list_log_streams`, `list_identity_providers`, …) that +return a tuple `(items, response, error)`. The next page is signalled +through an RFC 5988 `Link: <…>; rel="next"` header carrying an opaque +`after` cursor. + +These helpers are used by every Okta service that needs to drain a +paginated endpoint. They live here so we don't keep copy-pasting them +into each service module. +""" + +from typing import Optional +from urllib.parse import parse_qs, urlparse + + +def next_after_cursor(resp) -> Optional[str]: + """Extract the `after` cursor from a `Link: ...; rel="next"` header. + + Returns None when there is no next page. Header format follows RFC + 5988 and Okta's pagination guide. + """ + if resp is None: + return None + headers = getattr(resp, "headers", None) or {} + link = headers.get("link") or headers.get("Link") or "" + if not link: + return None + for part in link.split(","): + if 'rel="next"' not in part: + continue + url_segment = part.split(";", 1)[0].strip().lstrip("<").rstrip(">") + cursor = parse_qs(urlparse(url_segment).query).get("after", [None])[0] + if cursor: + return cursor + return None + + +async def paginate(fetch): + """Drain all pages of an SDK list call. + + `fetch` is a callable that accepts the `after` cursor (or None for + the first page) and returns the SDK's standard `(items, resp, err)` + tuple — or the 2-tuple early-error shape `(items, err)`. Follows the + `Link: rel="next"` header until exhausted. The returned tuple is + `(all_items, error)` — error is non-None only when a page fails + to fetch. + """ + all_items = [] + result = await fetch(None) + err = result[-1] + if err is not None: + return [], err + items = result[0] + resp = result[1] if len(result) >= 3 else None + all_items.extend(items or []) + while True: + cursor = next_after_cursor(resp) + if not cursor: + break + result = await fetch(cursor) + err = result[-1] + if err is not None: + return all_items, err + items = result[0] + resp = result[1] if len(result) >= 3 else None + all_items.extend(items or []) + return all_items, None diff --git a/prowler/providers/okta/lib/service/raw_fetch.py b/prowler/providers/okta/lib/service/raw_fetch.py new file mode 100644 index 0000000000..42e8eede11 --- /dev/null +++ b/prowler/providers/okta/lib/service/raw_fetch.py @@ -0,0 +1,141 @@ +"""Raw-JSON HTTP fetch via the Okta SDK's request executor. + +Some Okta Management API endpoints are not yet exposed as typed methods +on the SDK client (e.g. `/api/v1/automations`), or the typed path's +pydantic deserialization rejects values the API actually returns (e.g. +the `KnowledgeConstraint.types` lowercase issue we hit on +`list_policy_rules`). In both cases we go around the typed layer: +construct the request via `client._request_executor.create_request`, +execute without a response type, and parse the body ourselves. + +`get_json` returns the parsed JSON payload (typically a list or dict) +or raises with a descriptive log line on any of the failure modes — +request build, transport, decode, parse. `get_json_paginated` drains +list endpoints by following the `Link: rel="next"` cursor — without it, +the raw fallback would silently truncate at the per-request `limit`. +Callers are expected to project the JSON onto their own pydantic snapshot. +""" + +import json +from typing import Any, Optional +from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse + +from prowler.lib.logger import logger +from prowler.providers.okta.lib.service.pagination import next_after_cursor + + +async def get_json( + client, + path: str, + *, + accept: str = "application/json", + context: Optional[str] = None, +) -> Optional[Any]: + """GET `path` via the SDK's request executor and return parsed JSON. + + Returns the decoded JSON payload on success, or None when the + request, transport, or decode steps fail. Each failure path emits a + `logger.error` line tagged with `context` so the caller can grep + for it. + """ + label = context or path + request, error = await client._request_executor.create_request( + method="GET", + url=path, + body=None, + headers={"Accept": accept}, + ) + if error is not None: + logger.error(f"Raw fetch (create_request) failed for {label}: {error}") + return None + + _response, response_body, error = await client._request_executor.execute(request) + if error is not None: + logger.error(f"Raw fetch (execute) failed for {label}: {error}") + return None + + if isinstance(response_body, (bytes, bytearray)): + try: + response_body = response_body.decode("utf-8") + except UnicodeDecodeError as decode_err: + logger.error(f"Could not decode response for {label}: {decode_err}") + return None + try: + return json.loads(response_body) if response_body else None + except json.JSONDecodeError as decode_err: + logger.error(f"Could not parse JSON for {label}: {decode_err}") + return None + + +async def get_json_paginated( + client, + path: str, + *, + page_size: int = 200, + accept: str = "application/json", + context: Optional[str] = None, +) -> Optional[list]: + """Drain all pages of a raw-JSON list endpoint. + + Mirrors the typed `pagination.paginate` shape but operates on the + SDK's request executor directly. Follows the `Link: rel="next"` + header until exhausted, accumulating items across pages. Returns + the concatenated list, or None if any page fails to fetch or the + response is not a JSON array. + + `page_size` is appended as `limit=N` to the first request; subsequent + requests use the URL Okta returns via the cursor. + """ + label = context or path + all_items: list = [] + current_path = _set_query(path, {"limit": str(page_size)}) + while True: + request, error = await client._request_executor.create_request( + method="GET", + url=current_path, + body=None, + headers={"Accept": accept}, + ) + if error is not None: + logger.error(f"Raw fetch (create_request) failed for {label}: {error}") + return None + + response, response_body, error = await client._request_executor.execute(request) + if error is not None: + logger.error(f"Raw fetch (execute) failed for {label}: {error}") + return None + + if isinstance(response_body, (bytes, bytearray)): + try: + response_body = response_body.decode("utf-8") + except UnicodeDecodeError as decode_err: + logger.error(f"Could not decode response for {label}: {decode_err}") + return None + if not response_body: + break + try: + page = json.loads(response_body) + except json.JSONDecodeError as decode_err: + logger.error(f"Could not parse JSON for {label}: {decode_err}") + return None + if not isinstance(page, list): + logger.error( + f"Unexpected raw payload shape for {label}: " + f"{type(page).__name__}; expected list" + ) + return None + all_items.extend(page) + + cursor = next_after_cursor(response) + if not cursor: + break + current_path = _set_query(path, {"limit": str(page_size), "after": cursor}) + return all_items + + +def _set_query(path: str, params: dict) -> str: + """Return `path` with the given query params merged in (overriding existing).""" + parsed = urlparse(path) + qs = dict(parse_qsl(parsed.query)) + qs.update({k: v for k, v in params.items() if v is not None}) + return urlunparse(parsed._replace(query=urlencode(qs))) diff --git a/prowler/providers/okta/okta_provider.py b/prowler/providers/okta/okta_provider.py index 0a7b0b8e9d..8859507ec7 100644 --- a/prowler/providers/okta/okta_provider.py +++ b/prowler/providers/okta/okta_provider.py @@ -32,7 +32,18 @@ from prowler.providers.okta.exceptions.exceptions import ( from prowler.providers.okta.lib.mutelist.mutelist import OktaMutelist from prowler.providers.okta.models import OktaIdentityInfo, OktaSession -DEFAULT_SCOPES = ["okta.policies.read", "okta.brands.read", "okta.apps.read"] +DEFAULT_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", + "okta.idps.read", +] # Accept only Okta-managed domains. Custom (vanity) domains are rejected on # purpose — they're a recurring source of typos and silent misconfig and # Prowler's audience overwhelmingly uses Okta-managed hosts. The TLDs below diff --git a/prowler/providers/okta/services/apitoken/__init__.py b/prowler/providers/okta/services/apitoken/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/apitoken/api_token_client.py b/prowler/providers/okta/services/apitoken/api_token_client.py new file mode 100644 index 0000000000..fbe10d7c7f --- /dev/null +++ b/prowler/providers/okta/services/apitoken/api_token_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.okta.services.apitoken.api_token_service import ApiToken + +api_token_client = ApiToken(Provider.get_global_provider()) diff --git a/prowler/providers/okta/services/apitoken/api_token_service.py b/prowler/providers/okta/services/apitoken/api_token_service.py new file mode 100644 index 0000000000..5fafd71c35 --- /dev/null +++ b/prowler/providers/okta/services/apitoken/api_token_service.py @@ -0,0 +1,327 @@ +from typing import Optional + +from pydantic import BaseModel, Field, ValidationError + +from prowler.lib.logger import logger +from prowler.providers.okta.lib.service.pagination import paginate as _paginate_shared +from prowler.providers.okta.lib.service.raw_fetch import ( + get_json_paginated as _raw_get_json_paginated, +) +from prowler.providers.okta.lib.service.service import OktaService + +REQUIRED_SCOPES: dict[str, str] = { + "api_tokens": "okta.apiTokens.read", + "network_zones": "okta.networkZones.read", + "user_roles": "okta.roles.read", + # Needed to resolve admin roles inherited via group membership. + # `/api/v1/users/{id}/roles` returns only direct role assignments; + # group-inherited Super Admin is invisible without `okta.groups.read` + # to enumerate the user's groups. + "user_groups": "okta.groups.read", +} + + +def _value(value) -> str: + """Return plain string values from Okta SDK enums and raw strings.""" + if value is None: + return "" + enum_value = getattr(value, "value", None) + if enum_value is not None: + return str(enum_value) + return str(value) + + +def _role_to_string(role) -> str: + """Pick the most specific role identifier from an SDK Role object. + + `list_assigned_roles_for_user` and `list_group_assigned_roles` return + `ListGroupAssignedRoles200ResponseInner` — a oneOf wrapper that holds + the real `StandardRole`/`CustomRole` on `.actual_instance`. Reading + `.type`/`.label` from the wrapper returns None and the role silently + disappears, so unwrap first. + """ + inner = getattr(role, "actual_instance", None) or role + return _value(getattr(inner, "type", None)) or _value(getattr(inner, "label", None)) + + +def _raw_value(item, key: str) -> str: + """Return a string value from an SDK model or raw dictionary.""" + if isinstance(item, dict): + return _value(item.get(key)) + return _value(getattr(item, key, None)) + + +class ApiToken(OktaService): + """Fetches Okta API token metadata, token owners' roles, and zones.""" + + def __init__(self, provider): + super().__init__(__class__.__name__, provider) + granted = set(getattr(provider.identity, "granted_scopes", None) or []) + self.missing_scope: dict[str, Optional[str]] = { + resource: (scope if granted and scope not in granted else None) + for resource, scope in REQUIRED_SCOPES.items() + } + # Per-resource caches keyed on the Okta resource id. API tokens + # commonly share owners (e.g. a service user holding multiple + # tokens) and admin groups frequently overlap across users, so we + # memoize the resolutions within a single service instance. + self._user_roles_cache: dict[str, list[str]] = {} + self._group_roles_cache: dict[str, list[str]] = {} + self.known_network_zone_ids: set[str] = ( + set() + if self.missing_scope["api_tokens"] or self.missing_scope["network_zones"] + else self._list_known_network_zone_ids() + ) + self.api_tokens: dict[str, OktaApiToken] = ( + {} if self.missing_scope["api_tokens"] else self._list_api_tokens() + ) + + def _list_api_tokens(self) -> dict[str, "OktaApiToken"]: + """List active API token metadata and owner roles.""" + logger.info("ApiToken - Listing Okta API tokens...") + try: + return self._run(self._fetch_api_tokens()) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return {} + + async def _fetch_api_tokens(self) -> dict[str, "OktaApiToken"]: + # `list_api_tokens` is non-paginated in the SDK (no `after` + # parameter); we inline the tuple unwrap rather than going + # through `paginate`. Same pattern application_service uses for + # `get_first_party_app_settings`. + result: dict[str, OktaApiToken] = {} + sdk_result = await self.client.list_api_tokens() + err = sdk_result[-1] + if err is not None: + logger.error(f"Error listing API tokens: {err}") + return result + items = sdk_result[0] or [] + + for token in items: + token_id = _value(getattr(token, "id", None)) + user_id = _value(getattr(token, "user_id", None)) + roles = ( + await self._fetch_effective_user_role_types(user_id) if user_id else [] + ) + network = getattr(token, "network", None) + token_obj = OktaApiToken( + id=token_id, + name=_value(getattr(token, "name", None)) or token_id, + client_name=_value(getattr(token, "client_name", None)), + user_id=user_id, + network_connection=_value(getattr(network, "connection", None)), + network_includes=list(getattr(network, "include", None) or []), + network_excludes=list(getattr(network, "exclude", None) or []), + owner_roles=roles, + ) + result[token_obj.id] = token_obj + return result + + async def _fetch_effective_user_role_types(self, user_id: str) -> list[str]: + """Return direct + group-inherited admin role types for `user_id`. + + Okta's `/api/v1/users/{userId}/roles` (the SDK's + `list_assigned_roles_for_user`) only returns roles assigned + *directly* to the user. Roles inherited via group membership are + invisible to that endpoint — but they are how Okta normally + grants Super Admin (e.g. the org creator joins the default + "Okta Super Admins" group). Without resolving group-inherited + roles, the Super Admin check would falsely PASS for any token + whose owner gets admin via a group. + + Results are memoized per `user_id` so multiple tokens with the + same owner cost a single resolution. + """ + if user_id in self._user_roles_cache: + return self._user_roles_cache[user_id] + direct = await self._fetch_direct_user_role_types(user_id) + inherited = await self._fetch_group_inherited_role_types(user_id) + # Dedupe while preserving first-seen order (direct first, then + # inherited) so the status_extended reads from most-specific. + seen: set[str] = set() + combined: list[str] = [] + for role in (*direct, *inherited): + if role and role not in seen: + combined.append(role) + seen.add(role) + self._user_roles_cache[user_id] = combined + return combined + + async def _fetch_direct_user_role_types(self, user_id: str) -> list[str]: + """Return roles assigned directly to the user (no group inheritance).""" + if self.missing_scope["user_roles"]: + return [] + # `list_assigned_roles_for_user` is non-paginated in the SDK + # (no `after` parameter); inline the tuple unwrap. + sdk_result = await self.client.list_assigned_roles_for_user(user_id) + err = sdk_result[-1] + if err is not None: + logger.error(f"Error listing roles for token owner {user_id}: {err}") + return [] + items = sdk_result[0] or [] + roles = [_role_to_string(role) for role in items if _role_to_string(role)] + if roles or not items: + return roles + + # Belt-and-suspenders: when the SDK's typed parse returns items + # but every projection ends up empty (a discriminator surface we + # don't yet handle, a future schema change, …), fall back to the + # raw JSON. The `_role_to_string` unwrap above already covers the + # known `ListGroupAssignedRoles200ResponseInner` oneOf wrapper + # bug — this fallback exists for whatever the next SDK quirk is. + return await self._fetch_user_role_types_raw(user_id) + + async def _fetch_user_role_types_raw(self, user_id: str) -> list[str]: + """Return user role types from the raw response when typed models are empty. + + Uses the shared `get_json_paginated` helper so any `Link: next` + header the API returns is followed (role lists are typically + small, but the SDK doesn't paginate this endpoint at all so the + only correct way to drain it lives here). + """ + raw_items = await _raw_get_json_paginated( + self.client, + f"/api/v1/users/{user_id}/roles", + context=f"user roles for {user_id}", + ) + if raw_items is None: + return [] + roles = [ + _value(role.get("type")) or _value(role.get("label")) + for role in raw_items + if isinstance(role, dict) + ] + return [role for role in roles if role] + + async def _fetch_group_inherited_role_types(self, user_id: str) -> list[str]: + """Return roles inherited via the user's group memberships. + + Each group's role list is itself memoized — admin groups are + commonly shared across many users. + """ + if self.missing_scope["user_roles"] or self.missing_scope["user_groups"]: + return [] + # Defensive try/except: tenants we've seen in the wild return 403 + # on `/api/v1/users/{id}/groups` even when `okta.groups.read` is + # granted (admin-role on the service app gates the response + # separately). Treat any failure as "no inherited roles" so the + # caller still surfaces direct roles cleanly. + try: + sdk_result = await self.client.list_user_groups(user_id) + except Exception as error: + logger.error( + f"Error listing groups for token owner {user_id}: " + f"{error.__class__.__name__}: {error}" + ) + return [] + err = sdk_result[-1] + if err is not None: + logger.error(f"Error listing groups for token owner {user_id}: {err}") + return [] + groups = sdk_result[0] or [] + roles: list[str] = [] + for group in groups: + group_id = _value(getattr(group, "id", None)) + if not group_id: + continue + if group_id in self._group_roles_cache: + roles.extend(self._group_roles_cache[group_id]) + continue + # Per-group try/except: one group's parse or auth failure + # must not erase admin-role coverage for other groups. + try: + group_roles = await self._fetch_group_role_types(group_id) + except Exception as error: + logger.error( + f"Error listing roles for group {group_id} " + f"(owner={user_id}): {error.__class__.__name__}: {error}" + ) + group_roles = [] + self._group_roles_cache[group_id] = group_roles + roles.extend(group_roles) + return roles + + async def _fetch_group_role_types(self, group_id: str) -> list[str]: + """Return role types assigned to `group_id`.""" + sdk_result = await self.client.list_group_assigned_roles(group_id) + err = sdk_result[-1] + if err is not None: + logger.error(f"Error listing roles for group {group_id}: {err}") + return [] + items = sdk_result[0] or [] + return [_role_to_string(role) for role in items if _role_to_string(role)] + + def _list_known_network_zone_ids(self) -> set[str]: + """List known Network Zone ids and names for token condition validation.""" + logger.info("ApiToken - Listing Network Zones for token restrictions...") + try: + return self._run(self._fetch_known_network_zone_ids()) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return set() + + async def _fetch_known_network_zone_ids(self) -> set[str]: + identifiers: set[str] = set() + items, err = await self._fetch_all_network_zones() + if err is not None: + logger.error(f"Error listing Network Zones for API token checks: {err}") + return identifiers + for zone in items: + zone_id = _raw_value(zone, "id") + zone_name = _raw_value(zone, "name") + if zone_id: + identifiers.add(zone_id) + if zone_name: + identifiers.add(zone_name) + return identifiers + + async def _fetch_all_network_zones(self) -> tuple[list, object]: + """Drain all Network Zone pages for API token reference validation. + + Catches the upstream Okta SDK ↔ Management API schema drift on + Enhanced Dynamic Zones (object-shaped pydantic model where the + API returns a JSON array) the same way `network_zone_service` + does. `(ValueError, ValidationError)` covers both discriminator + misses and model mismatches — matching the `user_service` + precedent. + """ + try: + return await _paginate_shared( + lambda after: self.client.list_network_zones(after=after, limit=200) + ) + except (ValueError, ValidationError) as ex: + logger.warning( + f"Okta SDK raised {type(ex).__name__} parsing Network Zones " + "for API token validation — falling back to raw-JSON parse." + ) + return await self._fetch_all_network_zones_raw() + + async def _fetch_all_network_zones_raw(self) -> tuple[list, object]: + """Drain Network Zone pages via the shared raw-JSON helper.""" + items = await _raw_get_json_paginated( + self.client, + "/api/v1/zones", + page_size=200, + context="Network Zones for API token validation", + ) + if items is None: + return [], Exception("raw Network Zones fetch failed; see logs") + return items, None + + +class OktaApiToken(BaseModel): + """Normalized Okta API token metadata used by checks.""" + + id: str + name: str + client_name: str = "" + user_id: str = "" + network_connection: str = "" + network_includes: list[str] = Field(default_factory=list) + network_excludes: list[str] = Field(default_factory=list) + owner_roles: list[str] = Field(default_factory=list) diff --git a/prowler/providers/okta/services/apitoken/apitoken_not_super_admin/__init__.py b/prowler/providers/okta/services/apitoken/apitoken_not_super_admin/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/apitoken/apitoken_not_super_admin/apitoken_not_super_admin.metadata.json b/prowler/providers/okta/services/apitoken/apitoken_not_super_admin/apitoken_not_super_admin.metadata.json new file mode 100644 index 0000000000..d15c5b4680 --- /dev/null +++ b/prowler/providers/okta/services/apitoken/apitoken_not_super_admin/apitoken_not_super_admin.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "apitoken_not_super_admin", + "CheckTitle": "Okta API tokens are not owned by Super Admin users", + "CheckType": [], + "ServiceName": "apitoken", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "**Okta API token ownership** should avoid Super Admin users because API tokens inherit the admin permissions of the user that created them.", + "Risk": "**Super Admin-owned API tokens** become high-impact secrets: if one is exposed, an attacker can perform broad organization administration with the token owner privileges.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/guides/roles", + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/apitoken" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "Create a dedicated service account, assign only required admin roles, rotate the API token, and revoke Super Admin-owned tokens.", + "Terraform": "" + }, + "Recommendation": { + "Text": "**Use dedicated Okta service accounts** for API tokens and assign only the least-privilege admin roles required; rotate and revoke tokens created by Super Admin users.", + "Url": "https://hub.prowler.com/check/apitoken_not_super_admin" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/okta/services/apitoken/apitoken_not_super_admin/apitoken_not_super_admin.py b/prowler/providers/okta/services/apitoken/apitoken_not_super_admin/apitoken_not_super_admin.py new file mode 100644 index 0000000000..0971af4aab --- /dev/null +++ b/prowler/providers/okta/services/apitoken/apitoken_not_super_admin/apitoken_not_super_admin.py @@ -0,0 +1,70 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.apitoken.api_token_client import api_token_client +from prowler.providers.okta.services.apitoken.lib.api_token_helpers import ( + missing_api_token_scope_finding, + missing_user_roles_scope_for_token_finding, + owner_has_super_admin, +) + + +class apitoken_not_super_admin(Check): + """Ensure Okta API tokens are not owned by Super Admin users.""" + + def execute(self) -> list[CheckReportOkta]: + """Evaluate every active API token owner's assigned admin roles.""" + org_domain = api_token_client.provider.identity.org_domain + missing_api_token_scope = api_token_client.missing_scope.get("api_tokens") + if missing_api_token_scope: + return [ + missing_api_token_scope_finding( + self.metadata(), + org_domain, + missing_api_token_scope, + additional_required=["okta.roles.read", "okta.groups.read"], + ) + ] + + missing_user_roles_scope = api_token_client.missing_scope.get("user_roles") + # `okta.groups.read` is needed to resolve admin roles inherited via + # group membership. Without it we fall back to direct-only role + # assignments, which Okta returns for `/api/v1/users/{id}/roles` — + # commonly empty for trial accounts where Super Admin is granted + # through the default admin group. The finding stays evaluable but + # is flagged as best-effort so operators know to grant the scope. + missing_user_groups_scope = api_token_client.missing_scope.get("user_groups") + findings: list[CheckReportOkta] = [] + for token in api_token_client.api_tokens.values(): + report = CheckReportOkta( + metadata=self.metadata(), resource=token, org_domain=org_domain + ) + if missing_user_roles_scope: + report = missing_user_roles_scope_for_token_finding( + self.metadata(), org_domain, token, missing_user_roles_scope + ) + elif owner_has_super_admin(token): + report.status = "FAIL" + report.status_extended = ( + f"API token {token.name} is owned by user {token.user_id} " + "with the Super Admin role. Use a dedicated service account " + "with least-privilege admin roles instead." + ) + else: + roles = ( + ", ".join(token.owner_roles) + if token.owner_roles + else "no admin roles returned" + ) + caveat = ( + " Group-inherited roles were not checked because the " + f"`{missing_user_groups_scope}` scope is missing — grant " + "it to detect Super Admin assigned via group membership." + if missing_user_groups_scope + else "" + ) + report.status = "PASS" + report.status_extended = ( + f"API token {token.name} owner {token.user_id} is not " + f"assigned Super Admin ({roles}).{caveat}" + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/apitoken/apitoken_restricted_to_network_zone/__init__.py b/prowler/providers/okta/services/apitoken/apitoken_restricted_to_network_zone/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/apitoken/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone.metadata.json b/prowler/providers/okta/services/apitoken/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone.metadata.json new file mode 100644 index 0000000000..4a088a0c1c --- /dev/null +++ b/prowler/providers/okta/services/apitoken/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "apitoken_restricted_to_network_zone", + "CheckTitle": "Okta API tokens are restricted to known Network Zones", + "CheckType": [], + "ServiceName": "apitoken", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "**Okta API token network restrictions** should prevent token use from Any IP by tying each token to known Okta Network Zones.", + "Risk": "**API tokens allowed from Any IP** can be replayed from attacker-controlled infrastructure if the secret is exposed, removing an important network boundary.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/apitoken", + "https://help.okta.com/oie/en-us/content/topics/security/api.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "Security > API > Tokens: edit each token Security section and select specific Network Zones instead of Any IP.", + "Terraform": "" + }, + "Recommendation": { + "Text": "**Restrict every Okta API token to trusted IP-based Network Zones** and review token network conditions whenever service locations change.", + "Url": "https://hub.prowler.com/check/apitoken_restricted_to_network_zone" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/okta/services/apitoken/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone.py b/prowler/providers/okta/services/apitoken/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone.py new file mode 100644 index 0000000000..04cfcf2df7 --- /dev/null +++ b/prowler/providers/okta/services/apitoken/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone.py @@ -0,0 +1,55 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.apitoken.api_token_client import api_token_client +from prowler.providers.okta.services.apitoken.lib.api_token_helpers import ( + definite_network_zone_restriction_failure, + missing_api_token_scope_finding, + missing_network_zone_scope_for_token_finding, + network_zone_restriction_status, +) + + +class apitoken_restricted_to_network_zone(Check): + """Ensure Okta API tokens are restricted to known Network Zones.""" + + def execute(self) -> list[CheckReportOkta]: + """Evaluate every active API token's network condition.""" + org_domain = api_token_client.provider.identity.org_domain + missing_api_token_scope = api_token_client.missing_scope.get("api_tokens") + if missing_api_token_scope: + return [ + missing_api_token_scope_finding( + self.metadata(), + org_domain, + missing_api_token_scope, + additional_required=["okta.networkZones.read"], + ) + ] + + missing_network_zone_scope = api_token_client.missing_scope.get("network_zones") + findings: list[CheckReportOkta] = [] + for token in api_token_client.api_tokens.values(): + if missing_network_zone_scope: + definite_failure = definite_network_zone_restriction_failure(token) + if definite_failure: + report = CheckReportOkta( + metadata=self.metadata(), + resource=token, + org_domain=org_domain, + ) + report.status, report.status_extended = definite_failure + else: + report = missing_network_zone_scope_for_token_finding( + self.metadata(), org_domain, token, missing_network_zone_scope + ) + else: + report = CheckReportOkta( + metadata=self.metadata(), resource=token, org_domain=org_domain + ) + ( + report.status, + report.status_extended, + ) = network_zone_restriction_status( + token, api_token_client.known_network_zone_ids + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/apitoken/lib/__init__.py b/prowler/providers/okta/services/apitoken/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/apitoken/lib/api_token_helpers.py b/prowler/providers/okta/services/apitoken/lib/api_token_helpers.py new file mode 100644 index 0000000000..3a871714ae --- /dev/null +++ b/prowler/providers/okta/services/apitoken/lib/api_token_helpers.py @@ -0,0 +1,140 @@ +from prowler.lib.check.models import CheckReportOkta +from prowler.providers.okta.services.apitoken.api_token_service import OktaApiToken + +ANYWHERE_CONNECTIONS = {"", "ANYWHERE", "ANY_IP"} +_SCOPE_ADVICE = ( + "Grant it on the Okta API Scopes tab of the service app in the Okta Admin " + "Console, then re-run the check." +) + + +def network_zone_restriction_status( + token: OktaApiToken, known_network_zone_ids: set[str] +) -> tuple[str, str]: + """Evaluate whether an API token is restricted to known Network Zones.""" + connection = token.network_connection.upper() + if connection in ANYWHERE_CONNECTIONS: + return ( + "FAIL", + f"API token {token.name} can be used from any IP address. " + "Restrict the token to one or more known Okta Network Zones.", + ) + + if not token.network_includes: + return ( + "FAIL", + f"API token {token.name} does not allowlist a specific Okta " + "Network Zone. Excluded zones do not restrict the token to trusted " + "source networks.", + ) + + unknown_zones = [ + zone for zone in token.network_includes if zone not in known_network_zone_ids + ] + if unknown_zones: + return ( + "FAIL", + f"API token {token.name} references unknown Network Zone(s): " + f"{', '.join(unknown_zones)}.", + ) + + return ( + "PASS", + f"API token {token.name} is restricted to known Okta Network Zone(s): " + f"{', '.join(token.network_includes)}.", + ) + + +def definite_network_zone_restriction_failure( + token: OktaApiToken, +) -> tuple[str, str] | None: + """Return a definite network restriction failure that does not need zone lookup.""" + connection = token.network_connection.upper() + if connection in ANYWHERE_CONNECTIONS or not token.network_includes: + return network_zone_restriction_status(token, set()) + return None + + +def owner_has_super_admin(token: OktaApiToken) -> bool: + """Return True when any token owner role is Super Admin.""" + for role in token.owner_roles: + normalized = role.strip().replace(" ", "_").upper() + if normalized in {"SUPER_ADMIN", "SUPER_ADMINISTRATOR"}: + return True + return False + + +def missing_api_token_scope_finding( + metadata, + org_domain: str, + scope: str, + additional_required: list[str] | None = None, +) -> CheckReportOkta: + """Build the MANUAL finding emitted when API tokens cannot be listed. + + `additional_required` lets the calling check name the secondary + scopes it also needs (e.g. `okta.roles.read` for the Super Admin + check, `okta.networkZones.read` for the zone-restriction check) so + the operator can grant everything in one go instead of re-running + once per missing scope. + """ + resource = OktaApiToken( + id="api-tokens-scope-missing", + name="(scope not granted)", + ) + report = CheckReportOkta( + metadata=metadata, resource=resource, org_domain=org_domain + ) + report.status = "MANUAL" + if additional_required: + extras = f" This check also requires {_format_scope_list(additional_required)}." + advice = ( + "Grant them on the service app's Okta API Scopes tab in the Okta " + "Admin Console, then re-run the check." + ) + else: + extras = "" + advice = _SCOPE_ADVICE + report.status_extended = ( + f"Could not retrieve Okta API token metadata: the Okta service app " + f"is missing the required `{scope}` API scope.{extras} {advice}" + ) + return report + + +def _format_scope_list(scopes: list[str]) -> str: + """Format a list of scope names as backticked, comma-joined text.""" + formatted = [f"`{scope}`" for scope in scopes] + if len(formatted) == 1: + return formatted[0] + if len(formatted) == 2: + return " and ".join(formatted) + return ", ".join(formatted[:-1]) + f", and {formatted[-1]}" + + +def missing_network_zone_scope_for_token_finding( + metadata, org_domain: str, token: OktaApiToken, scope: str +) -> CheckReportOkta: + """Build the MANUAL finding emitted when token zones cannot be validated.""" + report = CheckReportOkta(metadata=metadata, resource=token, org_domain=org_domain) + report.status = "MANUAL" + report.status_extended = ( + f"Could not validate Network Zone restrictions for API token " + f"{token.name}: the Okta service app is missing the required " + f"`{scope}` API scope. {_SCOPE_ADVICE}" + ) + return report + + +def missing_user_roles_scope_for_token_finding( + metadata, org_domain: str, token: OktaApiToken, scope: str +) -> CheckReportOkta: + """Build the MANUAL finding emitted when token owner roles cannot be listed.""" + report = CheckReportOkta(metadata=metadata, resource=token, org_domain=org_domain) + report.status = "MANUAL" + report.status_extended = ( + f"Could not retrieve admin roles for API token {token.name} owner " + f"{token.user_id}: the Okta service app is missing the required " + f"`{scope}` API scope. {_SCOPE_ADVICE}" + ) + return report diff --git a/prowler/providers/okta/services/application/application_service.py b/prowler/providers/okta/services/application/application_service.py index de2fe0a658..fa6f483021 100644 --- a/prowler/providers/okta/services/application/application_service.py +++ b/prowler/providers/okta/services/application/application_service.py @@ -1,10 +1,13 @@ -import json from typing import Optional -from urllib.parse import parse_qs, urlparse +from urllib.parse import urlparse from pydantic import BaseModel, ValidationError from prowler.lib.logger import logger +from prowler.providers.okta.lib.service.pagination import paginate as _paginate_shared +from prowler.providers.okta.lib.service.raw_fetch import ( + get_json_paginated as _raw_get_json_paginated, +) from prowler.providers.okta.lib.service.service import OktaService # These three keys are Okta-platform constants, not tenant-configurable: @@ -28,29 +31,6 @@ DASHBOARD_APP_NAME = "okta_enduser" ADMIN_CONSOLE_FIRST_PARTY_APP_KEY = "admin-console" -def _next_after_cursor(resp) -> Optional[str]: - """Extract the `after` cursor from a `Link: ...; rel="next"` header. - - Returns None when there is no next page. Header format follows RFC 5988 - and Okta's pagination guide. Mirrors the helper in `signon_service` — - duplicated rather than shared until a third Okta service appears. - """ - if resp is None: - return None - headers = getattr(resp, "headers", None) or {} - link = headers.get("link") or headers.get("Link") or "" - if not link: - return None - for part in link.split(","): - if 'rel="next"' not in part: - continue - url_segment = part.split(";", 1)[0].strip().lstrip("<").rstrip(">") - cursor = parse_qs(urlparse(url_segment).query).get("after", [None])[0] - if cursor: - return cursor - return None - - REQUIRED_SCOPES: dict[str, str] = { "admin_console_app_settings": "okta.apps.read", "built_in_apps": "okta.apps.read", @@ -321,69 +301,24 @@ class Application(OktaService): """Raw-JSON fallback for `list_policy_rules`. Bypasses the Okta SDK's typed deserialization by calling the - request executor directly without a response type. The response - body is then `json.loads`-ed and projected onto our own pydantic - snapshot, which only validates the fields the STIG checks - actually read. This keeps the checks evaluable on tenants where - the Management API returns values the SDK validators reject. + request executor directly via the shared `get_json_paginated` + helper, which follows `Link: rel=next` so policies with more + rules than `rule_fetch_limit` are not silently truncated. + Projects the response onto our own pydantic snapshot which only + validates the fields the STIG checks actually read. This keeps + the checks evaluable on tenants where the Management API returns + values the SDK validators reject. """ - request, error = await self.client._request_executor.create_request( - method="GET", - url=f"/api/v1/policies/{policy_id}/rules?limit={rule_fetch_limit}", - body=None, - headers={"Accept": "application/json"}, + rules_data = await _raw_get_json_paginated( + self.client, + f"/api/v1/policies/{policy_id}/rules", + page_size=rule_fetch_limit, + context=f"access policy {policy_id} rules", ) - if error is not None: - logger.error( - f"Raw rules fetch (create_request) failed for {policy_id}: {error}" - ) + if rules_data is None: return AuthenticationPolicy( id=policy_id, name="", status="", is_default=False, rules=[] ) - - _response, response_body, error = await self.client._request_executor.execute( - request - ) - if error is not None: - logger.error(f"Raw rules fetch (execute) failed for {policy_id}: {error}") - return AuthenticationPolicy( - id=policy_id, name="", status="", is_default=False, rules=[] - ) - - if isinstance(response_body, (bytes, bytearray)): - try: - response_body = response_body.decode("utf-8") - except UnicodeDecodeError as decode_err: - logger.error( - f"Could not decode rules response for {policy_id}: {decode_err}" - ) - return AuthenticationPolicy( - id=policy_id, name="", status="", is_default=False, rules=[] - ) - try: - rules_data = json.loads(response_body) if response_body else [] - except json.JSONDecodeError as decode_err: - logger.error(f"Could not parse rules JSON for {policy_id}: {decode_err}") - return AuthenticationPolicy( - id=policy_id, name="", status="", is_default=False, rules=[] - ) - - if not isinstance(rules_data, list): - logger.error( - f"Unexpected raw rules payload shape for {policy_id}: " - f"got {type(rules_data).__name__}, expected list" - ) - return AuthenticationPolicy( - id=policy_id, name="", status="", is_default=False, rules=[] - ) - - if len(rules_data) >= rule_fetch_limit: - logger.warning( - f"Access policy {policy_id} returned {len(rules_data)} rules " - f"via raw-JSON fallback — the per-policy fetch limit " - f"({rule_fetch_limit}) was hit; any rules beyond this limit " - "are not evaluated by Prowler." - ) rules_out = [_raw_rule_to_model(rule) for rule in rules_data] return AuthenticationPolicy( id=policy_id, name="", status="", is_default=False, rules=rules_out @@ -391,33 +326,7 @@ class Application(OktaService): @staticmethod async def _paginate(fetch): - """Drain all pages of an SDK list call. - - `fetch` is a callable taking the `after` cursor (or None) and - returning the SDK's `(items, resp, err)` tuple. Follows the - `Link: rel="next"` header until exhausted. Mirrors the helper in - `signon_service`. - """ - all_items = [] - result = await fetch(None) - err = result[-1] - if err is not None: - return [], err - items = result[0] - resp = result[1] if len(result) >= 3 else None - all_items.extend(items or []) - while True: - cursor = _next_after_cursor(resp) - if not cursor: - break - result = await fetch(cursor) - err = result[-1] - if err is not None: - return all_items, err - items = result[0] - resp = result[1] if len(result) >= 3 else None - all_items.extend(items or []) - return all_items, None + return await _paginate_shared(fetch) def _policy_id_from_href(href: Optional[str]) -> Optional[str]: diff --git a/prowler/providers/okta/services/authenticator/__init__.py b/prowler/providers/okta/services/authenticator/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_client.py b/prowler/providers/okta/services/authenticator/authenticator_client.py new file mode 100644 index 0000000000..3657a2821e --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.okta.services.authenticator.authenticator_service import ( + Authenticator, +) + +authenticator_client = Authenticator(Provider.get_global_provider()) diff --git a/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant.metadata.json new file mode 100644 index 0000000000..363b95f84a --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_okta_verify_fips_compliant", + "CheckTitle": "Okta Verify authenticator is active and restricts enrollment to FIPS-compliant devices", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The **Okta Verify authenticator** (`okta_verify`) must be present, in the `ACTIVE` status, and configured to require FIPS-compliant devices for enrollment (`settings.compliance.fips == REQUIRED`).\n\nMissing, inactive, and active-but-non-FIPS authenticators surface as distinct FAIL findings so the operator can act on the specific gap.", + "Risk": "Without FIPS-required enrollment, users can authenticate with devices whose cryptographic modules are not FIPS-validated.\n\n- **Regulatory exposure** under frameworks that mandate FIPS-validated cryptography\n- **Inconsistent assurance** across the user population\n- **Weak baseline** if the authenticator is active but the FIPS flag is `OPTIONAL` or unset", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/authenticator", + "https://help.okta.com/en-us/content/topics/mobile/ov-admin-config.htm", + "https://help.okta.com/oie/en-us/content/topics/identity-engine/authenticators/configure-okta-verify-options.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authenticators**.\n3. Activate the **Okta Verify** authenticator if it is not already `ACTIVE`.\n4. Open the authenticator's settings and set *FIPS Compliance* to **Users enrolling in Okta Verify can use FIPS compliant devices only**.\n5. Save the authenticator.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Ensure the **Okta Verify** authenticator is:\n- Present in the org (`key = okta_verify`)\n- In the `ACTIVE` status\n- Configured so *FIPS Compliance* is **Required** (`settings.compliance.fips == REQUIRED`)\n\nIf the organization does not require FIPS-validated authenticators, mute the check rather than disabling the FIPS toggle on a partial population.", + "Url": "https://hub.prowler.com/check/authenticator_okta_verify_fips_compliant" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273205 / OKTA-APP-001700." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant.py b/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant.py new file mode 100644 index 0000000000..bf3c64b4b8 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant.py @@ -0,0 +1,65 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.authenticator_helpers import ( + find_authenticator_by_key, + missing_authenticator_resource, + missing_authenticators_scope_finding, +) + + +class authenticator_okta_verify_fips_compliant(Check): + """STIG V-273205 / OKTA-APP-001700. + + The check requires Okta to restrict Okta Verify enrollment to FIPS-compliant devices. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate Okta Verify FIPS compliance settings.""" + org_domain = authenticator_client.provider.identity.org_domain + missing_scope = authenticator_client.missing_scope.get("authenticators") + if missing_scope: + return [ + missing_authenticators_scope_finding( + self.metadata(), + org_domain, + "okta_verify", + "Okta Verify authenticator", + missing_scope, + ) + ] + + authenticator = find_authenticator_by_key( + authenticator_client.authenticators, "okta_verify" + ) + resource = authenticator or missing_authenticator_resource( + "okta_verify", "Okta Verify authenticator" + ) + report = CheckReportOkta( + metadata=self.metadata(), resource=resource, org_domain=org_domain + ) + if not authenticator: + report.status = "FAIL" + report.status_extended = "Okta Verify authenticator is missing." + elif authenticator.status.upper() != "ACTIVE": + report.status = "FAIL" + report.status_extended = ( + f"Okta Verify authenticator is not active; current status is " + f"{authenticator.status}." + ) + elif authenticator.fips.upper() == "REQUIRED": + report.status = "PASS" + report.status_extended = ( + "Okta Verify authenticator requires FIPS-compliant devices " + "for enrollment." + ) + else: + current_fips = authenticator.fips or "unset" + report.status = "FAIL" + report.status_extended = ( + "Okta Verify authenticator is active but does not require " + "FIPS-compliant devices for enrollment (current value: " + f"{current_fips})." + ) + return [report] diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check.metadata.json new file mode 100644 index 0000000000..f61ccef8a1 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_common_password_check", + "CheckTitle": "Every active Okta Password Policy rejects common passwords", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must reject passwords found in Okta's common-password dictionary (the *Restrict use of common passwords* setting).\n\nOkta evaluates Password Policies by group assignment; a custom policy with the check disabled can govern users. The check emits one finding per active policy.", + "Risk": "Without dictionary checking, users can pick passwords known to attackers from public breaches.\n\n- **Credential stuffing** succeeds with the same passwords compromised elsewhere\n- **Trivial guessing** stays viable for top-N lists (`123456`, `password`, …)\n- **Inconsistent baselines** leave users on legacy policies that allow common passwords", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and enable **Restrict use of common passwords**.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_dictionary_lookup = true # Critical: enables the common-password dictionary check\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Restrict use of common passwords* is **enabled**\n- Group assignments do not route users to legacy policies that leave the check disabled\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_common_password_check" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273208 / OKTA-APP-002980." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check.py b/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check.py new file mode 100644 index 0000000000..d90d88afeb --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_common_password_check(Check): + """STIG V-273208 / OKTA-APP-002980. + + Every active Okta Password Policy must reject passwords found in the common-password dictionary. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "common-password dictionary checks" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.common_password_check is True: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(common password check enabled: {policy.common_password_check})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(common password check enabled: {policy.common_password_check})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase.metadata.json new file mode 100644 index 0000000000..42a74b6e71 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_complexity_lowercase", + "CheckTitle": "Every active Okta Password Policy requires at least one lowercase character", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must require at least one **lowercase** character in the user's password.\n\nOkta evaluates Password Policies by group assignment, so a permissive custom policy that drops lowercase complexity can govern users even when the default policy is compliant. The check emits one finding per active policy so weaker custom policies do not hide behind a compliant default.", + "Risk": "Without lowercase complexity, the effective alphabet shrinks and passwords become easier to enumerate.\n\n- **Brute force** succeeds against a smaller character space\n- **Wordlist attacks** match more candidates without case mixing\n- **Inconsistent baselines** leave users on legacy policies with weaker complexity than the default", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and enable **Lower case letter** under *Complexity*.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_min_lowercase = 1 # Critical: STIG-aligned complexity\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Minimum number of lowercase characters* is `1` or more\n- Group assignments do not route users to legacy policies that disable lowercase complexity\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_complexity_lowercase" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273197 / OKTA-APP-000680." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase.py new file mode 100644 index 0000000000..e058e27b7b --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_complexity_lowercase(Check): + """STIG V-273197 / OKTA-APP-000680. + + Every active Okta Password Policy must require at least one lowercase character. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "at least one lowercase character" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.min_lower_case is not None and policy.min_lower_case >= 1: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(minimum lowercase characters: {policy.min_lower_case})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(minimum lowercase characters: {policy.min_lower_case})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number.metadata.json new file mode 100644 index 0000000000..fb3d4a920b --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_complexity_number", + "CheckTitle": "Every active Okta Password Policy requires at least one numeric character", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must require at least one **numeric** character in the user's password.\n\nOkta evaluates Password Policies by group assignment, so a permissive custom policy that drops numeric complexity can govern users even when the default policy is compliant. The check emits one finding per active policy so weaker custom policies do not hide behind a compliant default.", + "Risk": "Without numeric complexity, the effective alphabet shrinks and dictionary words remain viable as passwords.\n\n- **Brute force** succeeds against a smaller character space\n- **Wordlist attacks** match plain dictionary words without numeric padding\n- **Inconsistent baselines** leave users on legacy policies with weaker complexity than the default", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and enable **Number** under *Complexity*.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_min_number = 1 # Critical: STIG-aligned complexity\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Minimum number of numeric characters* is `1` or more\n- Group assignments do not route users to legacy policies that disable numeric complexity\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_complexity_number" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273198 / OKTA-APP-000690." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number.py new file mode 100644 index 0000000000..78ffe6878e --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_complexity_number(Check): + """STIG V-273198 / OKTA-APP-000690. + + Every active Okta Password Policy must require at least one numeric character. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "at least one numeric character" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.min_number is not None and policy.min_number >= 1: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(minimum numeric characters: {policy.min_number})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(minimum numeric characters: {policy.min_number})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol.metadata.json new file mode 100644 index 0000000000..1268780551 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_complexity_symbol", + "CheckTitle": "Every active Okta Password Policy requires at least one symbol character", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must require at least one **symbol** character in the user's password.\n\nOkta evaluates Password Policies by group assignment, so a permissive custom policy that drops symbol complexity can govern users even when the default policy is compliant. The check emits one finding per active policy so weaker custom policies do not hide behind a compliant default.", + "Risk": "Without symbol complexity, the effective alphabet shrinks and passwords stay closer to natural-language phrases.\n\n- **Brute force** succeeds against a smaller character space\n- **Wordlist attacks** match dictionary words without symbol padding\n- **Inconsistent baselines** leave users on legacy policies with weaker complexity than the default", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and enable **Symbol** under *Complexity*.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_min_symbol = 1 # Critical: STIG-aligned complexity\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Minimum number of symbol characters* is `1` or more\n- Group assignments do not route users to legacy policies that disable symbol complexity\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_complexity_symbol" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273199 / OKTA-APP-000700." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol.py new file mode 100644 index 0000000000..208a0f51d3 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_complexity_symbol(Check): + """STIG V-273199 / OKTA-APP-000700. + + Every active Okta Password Policy must require at least one symbol character. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "at least one symbol character" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.min_symbol is not None and policy.min_symbol >= 1: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(minimum symbol characters: {policy.min_symbol})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(minimum symbol characters: {policy.min_symbol})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase.metadata.json new file mode 100644 index 0000000000..7e885364ae --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_complexity_uppercase", + "CheckTitle": "Every active Okta Password Policy requires at least one uppercase character", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must require at least one **uppercase** character in the user's password.\n\nOkta evaluates Password Policies by group assignment, so a permissive custom policy that drops uppercase complexity can govern users even when the default policy is compliant. The check emits one finding per active policy so weaker custom policies do not hide behind a compliant default.", + "Risk": "Without uppercase complexity, the effective alphabet shrinks and passwords become easier to enumerate.\n\n- **Brute force** succeeds against a smaller character space\n- **Wordlist attacks** match more candidates without case mixing\n- **Inconsistent baselines** leave users on legacy policies with weaker complexity than the default", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and enable **Upper case letter** under *Complexity*.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_min_uppercase = 1 # Critical: STIG-aligned complexity\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Minimum number of uppercase characters* is `1` or more\n- Group assignments do not route users to legacy policies that disable uppercase complexity\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_complexity_uppercase" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273196 / OKTA-APP-000670." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase.py b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase.py new file mode 100644 index 0000000000..1419027980 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_complexity_uppercase(Check): + """STIG V-273196 / OKTA-APP-000670. + + Every active Okta Password Policy must require at least one uppercase character. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "at least one uppercase character" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.min_upper_case is not None and policy.min_upper_case >= 1: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(minimum uppercase characters: {policy.min_upper_case})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(minimum uppercase characters: {policy.min_upper_case})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_history_5/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_history_5/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5.metadata.json new file mode 100644 index 0000000000..a7191b0364 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_history_5", + "CheckTitle": "Every active Okta Password Policy remembers at least 5 previous passwords", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must keep at least the last `5` passwords in history so users cannot immediately recycle a recently-used password.\n\nOkta evaluates Password Policies by group assignment, so a permissive custom policy that lowers the history depth can govern users even when the default policy is compliant. The check emits one finding per active policy.", + "Risk": "A short password history lets users cycle back to compromised or trivially-guessable values shortly after a forced rotation.\n\n- **Reuse of breached passwords** within the same account\n- **Defeats forced rotation** by letting users return to a previous password\n- **Inconsistent baselines** leave users on legacy policies with shorter history than the default", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and set *Enforce password history for last* to `5` passwords or more.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_history_count = 5 # Critical: STIG-aligned history depth\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Enforce password history for last* is `5` passwords or more\n- Group assignments do not route users to legacy policies with shorter history\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_history_5" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273209 / OKTA-APP-003010." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5.py b/prowler/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5.py new file mode 100644 index 0000000000..b2eb6d16ba --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_history_5(Check): + """STIG V-273209 / OKTA-APP-003010. + + Every active Okta Password Policy must remember at least the last 5 previous passwords. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "password history of at least 5 previous passwords" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.history_count is not None and policy.history_count >= 5: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(password history count: {policy.history_count})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(password history count: {policy.history_count})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3.metadata.json new file mode 100644 index 0000000000..e287ed38f3 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_lockout_threshold_3", + "CheckTitle": "Every active Okta Password Policy locks accounts after 3 or fewer failed attempts", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must lock the account after at most `3` consecutive failed sign-in attempts.\n\nOkta evaluates Password Policies by group assignment, so a permissive custom policy with a higher threshold (or no lockout) can govern users even when the default is compliant. The check emits one finding per active policy.", + "Risk": "A high lockout threshold (or no threshold) leaves accounts exposed to online password guessing.\n\n- **Online brute force** retains enough attempts to enumerate common passwords\n- **Credential stuffing** can iterate through breached credentials at scale\n- **Inconsistent baselines** leave users on legacy policies with weaker thresholds than the default", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and set *Lock out user after X unsuccessful attempts* to `3` or fewer.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_max_lockout_attempts = 3 # Critical: STIG-aligned lockout threshold\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Lock out user after X unsuccessful attempts* is `3` or fewer\n- Group assignments do not route users to legacy policies with higher thresholds or no lockout\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_lockout_threshold_3" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273189 / OKTA-APP-000170. Not applicable when Okta delegates password sourcing to an external directory (AD/LDAP) — mute the check in that case; the directory enforces lockout instead." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3.py b/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3.py new file mode 100644 index 0000000000..8026725c8a --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_lockout_threshold_3(Check): + """STIG V-273189 / OKTA-APP-000170. + + Every active Okta Password Policy must lock accounts after no more than 3 consecutive failed login attempts. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "password lockout after 3 or fewer failed attempts" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.max_attempts is not None and policy.max_attempts <= 3: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(maximum failed attempts: {policy.max_attempts})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(maximum failed attempts: {policy.max_attempts})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d.metadata.json new file mode 100644 index 0000000000..449904303e --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_maximum_age_60d", + "CheckTitle": "Every active Okta Password Policy enforces a maximum password age of 60 days or less", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must require users to change their password at least every `60` days. The check rejects both unlimited expiration (`0`, which Okta treats as *never expires*) and any value greater than `60`.\n\nOkta evaluates Password Policies by group assignment; a permissive custom policy with a longer window can govern users. The check emits one finding per active policy.", + "Risk": "Long-lived passwords give a compromised credential indefinite usefulness.\n\n- **Stolen passwords** stay valid until the user happens to change them\n- **Breach detection lag** means a leaked password can be in use for months unnoticed\n- **No expiration** is the same risk as an infinite expiration window", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and set *Enforce password expiration* to `60` days or less. Do **not** leave it disabled or set to `0` — that disables expiration entirely.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_max_age_days = 60 # Critical: STIG-aligned maximum age; do not use 0 (disables expiration)\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Enforce password expiration* is `60` days or less\n- Never set the value to `0`, which disables expiration\n- Group assignments do not route users to legacy policies with longer windows or disabled expiration\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_maximum_age_60d" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273201 / OKTA-APP-000745." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d.py b/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d.py new file mode 100644 index 0000000000..2dbc5d9c07 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_maximum_age_60d(Check): + """STIG V-273201 / OKTA-APP-000745. + + Every active Okta Password Policy must enforce a 60-day maximum password age. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "maximum password age of 60 days or less" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.max_age_days is not None and 0 < policy.max_age_days <= 60: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(maximum age days: {policy.max_age_days})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(maximum age days: {policy.max_age_days})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h.metadata.json new file mode 100644 index 0000000000..8adbf3b5a0 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_minimum_age_24h", + "CheckTitle": "Every active Okta Password Policy enforces a minimum password age of 24 hours", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must prevent users from changing their password again for at least `24` hours (`1440` minutes) after the previous change.\n\nA minimum age stops users from cycling through their entire history in one session to land back on a previously-known password. The check emits one finding per active policy.", + "Risk": "Without a minimum password age, users can sidestep history and rotation requirements in minutes.\n\n- **Defeats password history** by walking through new passwords back to a previous one\n- **Defeats forced rotation** by returning to the prior password after the mandatory change\n- **Inconsistent baselines** leave users on legacy policies with shorter minimums than the default", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and set *Minimum password age* to `24` hours (`1440` minutes) or more.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_min_age_minutes = 1440 # Critical: STIG-aligned 24h minimum age\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Minimum password age* is `1440` minutes (`24` hours) or more\n- Group assignments do not route users to legacy policies with shorter minimums or no minimum age\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_minimum_age_24h" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273200 / OKTA-APP-000740." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h.py b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h.py new file mode 100644 index 0000000000..93ce511458 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_minimum_age_24h(Check): + """STIG V-273200 / OKTA-APP-000740. + + Every active Okta Password Policy must enforce a 24-hour minimum password age. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "minimum password age of at least 24 hours" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.min_age_minutes is not None and policy.min_age_minutes >= 1440: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(minimum age minutes: {policy.min_age_minutes})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(minimum age minutes: {policy.min_age_minutes})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15.metadata.json new file mode 100644 index 0000000000..d7409c6bb1 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_password_minimum_length_15", + "CheckTitle": "Every active Okta Password Policy enforces a minimum length of 15 characters", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every **active Okta Password Policy** must require at least `15` characters in the user's password.\n\nOkta evaluates Password Policies by group assignment, so a permissive custom policy with a shorter minimum length can govern users even when the default policy is compliant. The check emits one finding per active policy so weaker custom policies do not hide behind a compliant default.", + "Risk": "Short password minimums weaken brute-force and credential-stuffing resistance for every user assigned to the affected policy.\n\n- **Brute force** completes in feasible time against short passwords\n- **Credential stuffing** succeeds with reused passwords from public breaches\n- **Inconsistent baselines** leave users on legacy policies with weaker assurance than the default", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/policy", + "https://help.okta.com/en-us/content/topics/security/policies/configure-password-policies.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authentication** > **Password**.\n3. For every active policy, click **Edit** and set *Minimum password length* to `15` or more.\n4. Save the policy.\n5. Repeat for every active Password Policy returned by the check.", + "Terraform": "```hcl\nresource \"okta_policy_password\" \"\" {\n name = \"\"\n status = \"ACTIVE\"\n password_min_length = 15 # Critical: STIG-aligned minimum\n}\n```" + }, + "Recommendation": { + "Text": "Configure every active **Okta Password Policy** so:\n- *Minimum password length* is `15` characters or more\n- Group assignments do not route users to legacy policies with shorter minimums\n\nReview each active Password Policy individually — the check evaluates them one at a time.", + "Url": "https://hub.prowler.com/check/authenticator_password_minimum_length_15" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273195 / OKTA-APP-000650." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15.py b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15.py new file mode 100644 index 0000000000..35e0a02b8b --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15.py @@ -0,0 +1,60 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.password_policy_helpers import ( + active_password_policies, + missing_password_policies_scope_finding, + no_active_password_policies_finding, + password_policy_label, +) + + +class authenticator_password_minimum_length_15(Check): + """STIG V-273195 / OKTA-APP-000650. + + Every active Okta Password Policy must enforce a minimum password length of 15 characters. + The check emits one finding per active policy so a weaker + custom policy cannot hide behind a compliant default. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate all active Okta Password Policies.""" + findings = [] + org_domain = authenticator_client.provider.identity.org_domain + requirement = "minimum password length of at least 15 characters" + missing_scope = authenticator_client.missing_scope.get("password_policies") + + if missing_scope: + return [ + missing_password_policies_scope_finding( + self.metadata(), org_domain, missing_scope, requirement + ) + ] + + policies = active_password_policies(authenticator_client.password_policies) + if not policies: + return [ + no_active_password_policies_finding( + self.metadata(), org_domain, requirement + ) + ] + + for policy in policies: + report = CheckReportOkta( + metadata=self.metadata(), resource=policy, org_domain=org_domain + ) + if policy.min_length is not None and policy.min_length >= 15: + report.status = "PASS" + report.status_extended = ( + f"{password_policy_label(policy)} enforces {requirement} " + f"(minimum length: {policy.min_length})." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"{password_policy_label(policy)} does not enforce {requirement} " + f"(minimum length: {policy.min_length})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/authenticator/authenticator_service.py b/prowler/providers/okta/services/authenticator/authenticator_service.py new file mode 100644 index 0000000000..86b4084dfb --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_service.py @@ -0,0 +1,236 @@ +from typing import Optional + +from pydantic import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.okta.lib.service.pagination import paginate as _paginate_shared +from prowler.providers.okta.lib.service.service import OktaService + +REQUIRED_SCOPES: dict[str, str] = { + "password_policies": "okta.policies.read", + "authenticators": "okta.authenticators.read", +} + + +def _value(value) -> str: + """Return plain string values from Okta SDK enums and raw strings.""" + if value is None: + return "" + enum_value = getattr(value, "value", None) + if enum_value is not None: + return str(enum_value) + return str(value) + + +def _int_or_none(value) -> Optional[int]: + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _bool_or_none(value) -> Optional[bool]: + """Coerce common Okta boolean shapes into a real `Optional[bool]`. + + The Okta SDK typed `bool` fields are already real booleans, but the + raw-JSON fallback paths in sibling services have surfaced both + JSON-style booleans (`true`/`false` as Python `bool` after `json.loads`) + and string-flavored ones (`"true"`/`"false"`). `bool("false")` is + `True` — so naive coercion silently flips the meaning. Reject that + explicitly. + """ + if value is None: + return None + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"true", "1", "yes"}: + return True + if normalized in {"false", "0", "no", ""}: + return False + return None + return bool(value) + + +class Authenticator(OktaService): + """Fetches Okta Password Policies and Authenticators for STIG checks. + + Populates: + - `self.password_policies` — keyed by policy id. Each `PasswordPolicy` + carries the projected fields the 10 password-policy checks read + (length, complexity, age, history, lockout, common-password + dictionary). The complete typed SDK response is collapsed into a + flat dataclass so the checks never reach back into the SDK shape. + - `self.authenticators` — keyed by authenticator id. Used by the + two non-password checks (Smart Card IdP, Okta Verify FIPS). + + Before each fetch the service compares its required OAuth scope + (see `REQUIRED_SCOPES`) against the access token's granted scopes + (`provider.identity.granted_scopes`). When a scope is known to be + missing, the fetch is skipped and recorded in `self.missing_scope` + so each check can emit an explicit MANUAL finding instead of a + misleading "no resources returned". Empty granted_scopes means + "unknown" — the service attempts the fetch and lets the SDK fail + loudly. + """ + + def __init__(self, provider): + super().__init__(__class__.__name__, provider) + granted = set(getattr(provider.identity, "granted_scopes", None) or []) + self.missing_scope: dict[str, Optional[str]] = { + resource: (scope if granted and scope not in granted else None) + for resource, scope in REQUIRED_SCOPES.items() + } + self.password_policies: dict[str, PasswordPolicy] = ( + {} + if self.missing_scope["password_policies"] + else self._list_password_policies() + ) + self.authenticators: dict[str, OktaAuthenticator] = ( + {} if self.missing_scope["authenticators"] else self._list_authenticators() + ) + + def _list_password_policies(self) -> dict[str, "PasswordPolicy"]: + """List PASSWORD policies with normalized password settings.""" + logger.info("Authenticator - Listing Okta PASSWORD policies...") + try: + return self._run(self._fetch_password_policies()) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return {} + + async def _fetch_password_policies(self) -> dict[str, "PasswordPolicy"]: + result: dict[str, PasswordPolicy] = {} + items, err = await _paginate_shared( + lambda after: self.client.list_policies( + type="PASSWORD", after=after, limit="200" + ) + ) + if err is not None: + logger.error(f"Error listing PASSWORD policies: {err}") + return result + + for policy in items: + policy_obj = self._build_password_policy(policy) + result[policy_obj.id] = policy_obj + return result + + def _list_authenticators(self) -> dict[str, "OktaAuthenticator"]: + """List org authenticators with normalized settings.""" + logger.info("Authenticator - Listing Okta authenticators...") + try: + return self._run(self._fetch_authenticators()) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return {} + + async def _fetch_authenticators(self) -> dict[str, "OktaAuthenticator"]: + # `list_authenticators` is non-paginated in the SDK (no `after` + # parameter); inline the tuple unwrap rather than going through + # `paginate`. Same shape `application_service` uses for + # `get_first_party_app_settings`. + result: dict[str, OktaAuthenticator] = {} + sdk_result = await self.client.list_authenticators() + err = sdk_result[-1] + if err is not None: + logger.error(f"Error listing authenticators: {err}") + return result + items = sdk_result[0] or [] + + for authenticator in items: + auth_obj = self._build_authenticator(authenticator) + result[auth_obj.id] = auth_obj + return result + + @staticmethod + def _build_password_policy(policy) -> "PasswordPolicy": + settings = getattr(policy, "settings", None) + password_settings = getattr(settings, "password", None) if settings else None + lockout = ( + getattr(password_settings, "lockout", None) if password_settings else None + ) + complexity = ( + getattr(password_settings, "complexity", None) + if password_settings + else None + ) + dictionary = getattr(complexity, "dictionary", None) if complexity else None + common = getattr(dictionary, "common", None) if dictionary else None + age = getattr(password_settings, "age", None) if password_settings else None + policy_id = _value(getattr(policy, "id", None)) + return PasswordPolicy( + id=policy_id, + name=_value(getattr(policy, "name", None)) or policy_id, + status=_value(getattr(policy, "status", None)), + priority=_int_or_none(getattr(policy, "priority", None)), + is_default=bool(getattr(policy, "system", False)), + max_attempts=_int_or_none(getattr(lockout, "max_attempts", None)), + min_length=_int_or_none(getattr(complexity, "min_length", None)), + min_upper_case=_int_or_none(getattr(complexity, "min_upper_case", None)), + min_lower_case=_int_or_none(getattr(complexity, "min_lower_case", None)), + min_number=_int_or_none(getattr(complexity, "min_number", None)), + min_symbol=_int_or_none(getattr(complexity, "min_symbol", None)), + min_age_minutes=_int_or_none(getattr(age, "min_age_minutes", None)), + max_age_days=_int_or_none(getattr(age, "max_age_days", None)), + history_count=_int_or_none(getattr(age, "history_count", None)), + common_password_check=_bool_or_none(getattr(common, "exclude", None)), + ) + + @staticmethod + def _build_authenticator(authenticator) -> "OktaAuthenticator": + settings = getattr(authenticator, "settings", None) + compliance = getattr(settings, "compliance", None) if settings else None + auth_id = _value(getattr(authenticator, "id", None)) + return OktaAuthenticator( + id=auth_id, + key=_value(getattr(authenticator, "key", None)), + name=_value(getattr(authenticator, "name", None)) or auth_id, + status=_value(getattr(authenticator, "status", None)), + type=_value(getattr(authenticator, "type", None)), + fips=_value(getattr(compliance, "fips", None)), + ) + + +class PasswordPolicy(BaseModel): + """Normalized Okta Password Policy settings used by checks.""" + + id: str + name: str + status: str = "" + priority: Optional[int] = None + is_default: bool = False + max_attempts: Optional[int] = None + min_length: Optional[int] = None + min_upper_case: Optional[int] = None + min_lower_case: Optional[int] = None + min_number: Optional[int] = None + min_symbol: Optional[int] = None + min_age_minutes: Optional[int] = None + max_age_days: Optional[int] = None + history_count: Optional[int] = None + common_password_check: Optional[bool] = None + + +class OktaAuthenticator(BaseModel): + """Normalized Okta Authenticator settings used by checks.""" + + id: str + key: str + name: str + status: str = "" + type: str = "" + fips: str = "" + + +class AuthenticatorSummary(BaseModel): + """Synthetic resource for org-level authenticator findings.""" + + id: str + name: str diff --git a/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/__init__.py b/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active.metadata.json b/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active.metadata.json new file mode 100644 index 0000000000..b8e93ae548 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "authenticator_smart_card_active", + "CheckTitle": "Okta Smart Card IdP authenticator is configured and active", + "CheckType": [], + "ServiceName": "authenticator", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "The **Smart Card IdP authenticator** (`smart_card_idp`) must exist in the org and be in the `ACTIVE` status so certificate-based authentication is available to users and apps that require it.\n\nThe check resolves the authenticator by its built-in `key`. Missing and inactive authenticators surface as distinct FAIL findings.", + "Risk": "Without an active Smart Card authenticator, users cannot satisfy mandated certificate-based authentication and may be forced onto weaker fallback paths.\n\n- **Mandated CAC/PIV use** cannot be enforced\n- **Compliance gaps** for environments that require X.509 certificate authentication\n- **Fallback to password-only** sign-in for affected groups", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/authenticator", + "https://help.okta.com/oie/en-us/Content/Topics/identity-engine/authenticators/smart-card-authenticator.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Authenticators**.\n3. If the **Smart Card IdP** authenticator is not listed, click **Add Authenticator** and add it.\n4. Open the authenticator and switch its status to **ACTIVE**.\n5. Bind it to the Authentication Policies that require certificate-based auth.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Ensure the **Smart Card IdP** authenticator is:\n- Present in the org (`key = smart_card_idp`)\n- In the `ACTIVE` status\n- Referenced by every Authentication Policy that requires certificate-based authentication\n\nIf certificate-based authentication is not in scope for the organization, mute the check rather than disabling the authenticator.", + "Url": "https://hub.prowler.com/check/authenticator_smart_card_active" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273204 / OKTA-APP-001670." +} diff --git a/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active.py b/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active.py new file mode 100644 index 0000000000..4341db8612 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active.py @@ -0,0 +1,56 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_client import ( + authenticator_client, +) +from prowler.providers.okta.services.authenticator.lib.authenticator_helpers import ( + find_authenticator_by_key, + missing_authenticator_resource, + missing_authenticators_scope_finding, +) + + +class authenticator_smart_card_active(Check): + """STIG V-273204 / OKTA-APP-001670. + + The check requires Okta to configure and activate the Smart Card (PIV) authenticator. + """ + + def execute(self) -> list[CheckReportOkta]: + """Evaluate the Smart Card IdP authenticator status.""" + org_domain = authenticator_client.provider.identity.org_domain + missing_scope = authenticator_client.missing_scope.get("authenticators") + if missing_scope: + return [ + missing_authenticators_scope_finding( + self.metadata(), + org_domain, + "smart_card_idp", + "Smart Card IdP authenticator", + missing_scope, + ) + ] + + authenticator = find_authenticator_by_key( + authenticator_client.authenticators, "smart_card_idp" + ) + resource = authenticator or missing_authenticator_resource( + "smart_card_idp", "Smart Card IdP authenticator" + ) + report = CheckReportOkta( + metadata=self.metadata(), resource=resource, org_domain=org_domain + ) + if authenticator and authenticator.status.upper() == "ACTIVE": + report.status = "PASS" + report.status_extended = "Smart Card IdP authenticator is ACTIVE." + elif authenticator: + report.status = "FAIL" + report.status_extended = ( + f"Smart Card IdP authenticator is not active; current status is " + f"{authenticator.status}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + "Smart Card IdP authenticator is not active or missing." + ) + return [report] diff --git a/prowler/providers/okta/services/authenticator/lib/__init__.py b/prowler/providers/okta/services/authenticator/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/authenticator/lib/authenticator_helpers.py b/prowler/providers/okta/services/authenticator/lib/authenticator_helpers.py new file mode 100644 index 0000000000..851daec5a8 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/lib/authenticator_helpers.py @@ -0,0 +1,49 @@ +from prowler.lib.check.models import CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_service import ( + AuthenticatorSummary, + OktaAuthenticator, +) + +_SCOPE_ADVICE = ( + "Grant it on the Okta API Scopes tab of the service app in the Okta Admin " + "Console, then re-run the check." +) + + +def find_authenticator_by_key( + authenticators: dict[str, OktaAuthenticator], key: str +) -> OktaAuthenticator | None: + """Return the first authenticator with the requested key. + + Okta enforces unique authenticator `key` values per org for built-in + types (`okta_verify`, `smart_card_idp`, etc.), so the "first match" + is the only match in practice. If a future Okta release relaxes + that and returns duplicates, only the first is evaluated — STIG + semantics need refining at that point. + """ + for authenticator in authenticators.values(): + if authenticator.key == key: + return authenticator + return None + + +def missing_authenticator_resource(key: str, name: str) -> AuthenticatorSummary: + """Build a synthetic resource for a missing authenticator.""" + return AuthenticatorSummary(id=f"{key}-missing", name=name) + + +def missing_authenticators_scope_finding( + metadata, org_domain: str, key: str, name: str, scope: str +) -> CheckReportOkta: + """Build the MANUAL finding emitted when authenticators cannot be listed.""" + resource = AuthenticatorSummary(id=f"{key}-scope-missing", name=name) + report = CheckReportOkta( + metadata=metadata, resource=resource, org_domain=org_domain + ) + report.status = "MANUAL" + report.status_extended = ( + f"Could not retrieve Okta authenticators to evaluate {name}: the Okta " + f"service app is missing the required `{scope}` API scope. " + f"{_SCOPE_ADVICE}" + ) + return report diff --git a/prowler/providers/okta/services/authenticator/lib/password_policy_helpers.py b/prowler/providers/okta/services/authenticator/lib/password_policy_helpers.py new file mode 100644 index 0000000000..08aa64a3a6 --- /dev/null +++ b/prowler/providers/okta/services/authenticator/lib/password_policy_helpers.py @@ -0,0 +1,80 @@ +from prowler.lib.check.models import CheckReportOkta +from prowler.providers.okta.services.authenticator.authenticator_service import ( + PasswordPolicy, +) + +_SCOPE_ADVICE = ( + "Grant it on the Okta API Scopes tab of the service app in the Okta Admin " + "Console, then re-run the check." +) + + +def active_password_policies( + password_policies: dict[str, PasswordPolicy], +) -> list[PasswordPolicy]: + """Return active password policies sorted by priority. + + Treats `policy.status == ""` as ACTIVE: the typed Okta SDK + occasionally returns policies without a `status` field populated + (the SDK enum doesn't cover every server-side value Okta has + shipped). Dropping those would silently hide real policies — we + 'd rather evaluate them and let the per-field comparator decide. + """ + return sorted( + [ + policy + for policy in password_policies.values() + if not policy.status or policy.status.upper() == "ACTIVE" + ], + key=lambda policy: ( + policy.priority if policy.priority is not None else float("inf"), + policy.name, + ), + ) + + +def password_policy_label(policy: PasswordPolicy) -> str: + kind = "default" if policy.is_default else "custom" + priority = policy.priority if policy.priority is not None else "unset" + return f"Password Policy {policy.name} (priority {priority}, {kind})" + + +def no_active_password_policies_finding( + metadata, org_domain: str, requirement: str +) -> CheckReportOkta: + """Build the FAIL finding emitted when no active password policies exist.""" + placeholder = PasswordPolicy( + id="password-policies-missing", + name="(no active password policies)", + status="MISSING", + ) + report = CheckReportOkta( + metadata=metadata, resource=placeholder, org_domain=org_domain + ) + report.status = "FAIL" + report.status_extended = ( + "No active Okta Password Policies were returned by the API. " + f"The organization must enforce: {requirement}." + ) + return report + + +def missing_password_policies_scope_finding( + metadata, org_domain: str, scope: str, requirement: str +) -> CheckReportOkta: + """Build the MANUAL finding emitted when Password Policies cannot be listed.""" + placeholder = PasswordPolicy( + id="password-policies-scope-missing", + name="(scope not granted)", + status="UNKNOWN", + ) + report = CheckReportOkta( + metadata=metadata, resource=placeholder, org_domain=org_domain + ) + report.status = "MANUAL" + report.status_extended = ( + f"Could not retrieve Okta Password Policies to evaluate {requirement}: " + f"the Okta service app is missing the required `{scope}` API scope. " + f"{_SCOPE_ADVICE}" + ) + return report diff --git a/prowler/providers/okta/services/idp/__init__.py b/prowler/providers/okta/services/idp/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/idp/idp_client.py b/prowler/providers/okta/services/idp/idp_client.py new file mode 100644 index 0000000000..5bbf98c17a --- /dev/null +++ b/prowler/providers/okta/services/idp/idp_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.okta.services.idp.idp_service import Idp + +idp_client = Idp(Provider.get_global_provider()) diff --git a/prowler/providers/okta/services/idp/idp_service.py b/prowler/providers/okta/services/idp/idp_service.py new file mode 100644 index 0000000000..2ff106cb47 --- /dev/null +++ b/prowler/providers/okta/services/idp/idp_service.py @@ -0,0 +1,118 @@ +from typing import Optional + +from pydantic import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.okta.lib.service.pagination import paginate +from prowler.providers.okta.lib.service.service import OktaService + +# Okta's API value for the "Smart Card" IdP shown in the Admin Console. +# The UI label is "Smart Card IdP" but the `type` field on the API response +# is `X509` (Mutual TLS) — that is the value we filter on. +SMART_CARD_IDP_TYPE = "X509" + +REQUIRED_SCOPES: dict[str, str] = { + "identity_providers": "okta.idps.read", +} + + +class Idp(OktaService): + """Fetches Okta Identity Providers. + + Populates `self.identity_providers` keyed by IdP id. Each entry + captures the minimum fields the bundled checks read: identity + (`id`, `name`), `type`, `status`, and — for `X509` Smart Card IdPs + — the certificate-chain `issuer` and `kid` exposed by Okta's + `protocol.credentials.trust` structure. Reading the issuer DN lets + the check surface it for out-of-band verification against the + DOD-approved CA list. + + Required OAuth scopes (`REQUIRED_SCOPES`) are compared against the + access token's granted scopes (`provider.identity.granted_scopes`). + Missing scopes are recorded in `self.missing_scope` so the check + can emit an explicit MANUAL finding. + """ + + def __init__(self, provider): + super().__init__(__class__.__name__, provider) + granted = set(getattr(provider.identity, "granted_scopes", None) or []) + self.missing_scope: dict[str, Optional[str]] = { + resource: (scope if granted and scope not in granted else None) + for resource, scope in REQUIRED_SCOPES.items() + } + + self.identity_providers: dict[str, OktaIdentityProvider] = ( + {} + if self.missing_scope["identity_providers"] + else self._list_identity_providers() + ) + + def _list_identity_providers(self) -> dict: + logger.info("Idp - Listing Okta Identity Providers...") + try: + return self._run(self._fetch_identity_providers()) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return {} + + async def _fetch_identity_providers(self) -> dict: + result: dict[str, OktaIdentityProvider] = {} + all_idps, err = await paginate( + lambda after: self.client.list_identity_providers(after=after) + ) + if err is not None: + logger.error(f"Error listing identity providers: {err}") + return result + + for idp in all_idps: + idp_id = getattr(idp, "id", "") or "" + if not idp_id: + continue + issuer, kid = _trust_fields(idp) + result[idp_id] = OktaIdentityProvider( + id=idp_id, + name=getattr(idp, "name", "") or "", + type=_stringify_enum(getattr(idp, "type", None)) or "", + status=_stringify_enum(getattr(idp, "status", None)) or "", + trust_issuer=issuer, + trust_kid=kid, + ) + return result + + +def _trust_fields(idp) -> tuple[Optional[str], Optional[str]]: + """Extract `issuer` and `kid` from an `X509` IdP's protocol.credentials.trust. + + The SDK exposes `IdentityProvider.protocol` as `IdentityProviderProtocol`, + a Pydantic v2 oneOf wrapper that holds the concrete protocol (ProtocolMtls + for X509 IdPs) on `actual_instance`. `credentials` is not proxied on the + wrapper, so reading it directly returns None — we have to unwrap first. + """ + protocol = getattr(idp, "protocol", None) + if protocol is None: + return None, None + actual_protocol = getattr(protocol, "actual_instance", None) or protocol + credentials = getattr(actual_protocol, "credentials", None) + if credentials is None: + return None, None + trust = getattr(credentials, "trust", None) + if trust is None: + return None, None + return getattr(trust, "issuer", None), getattr(trust, "kid", None) + + +def _stringify_enum(value) -> Optional[str]: + if value is None: + return None + return getattr(value, "value", None) or str(value) + + +class OktaIdentityProvider(BaseModel): + id: str + name: str = "" + type: str = "" + status: str = "" + trust_issuer: Optional[str] = None + trust_kid: Optional[str] = None diff --git a/prowler/providers/okta/services/idp/idp_smart_card_dod_approved_ca/__init__.py b/prowler/providers/okta/services/idp/idp_smart_card_dod_approved_ca/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca.metadata.json b/prowler/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca.metadata.json new file mode 100644 index 0000000000..e7e85294be --- /dev/null +++ b/prowler/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "idp_smart_card_dod_approved_ca", + "CheckTitle": "Okta Smart Card (X509) Identity Provider uses a DOD-approved certificate authority", + "CheckType": [], + "ServiceName": "idp", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every Okta Smart Card (X509) Identity Provider must be `ACTIVE` and its certificate chain must be issued by a DOD-approved CA. The check ships default issuer-DN patterns covering DOD PKI and ECA, and matches them against the chain's `issuer`. Override or extend via `okta_dod_approved_ca_issuer_patterns` in the audit config to recognise tenant-specific DOD CAs.", + "Risk": "An Okta Smart Card IdP whose certificate chain is not issued by a DOD-approved CA can be used to authenticate non-vetted identities.\n\n- **Trust on an unverified CA** allows impersonation of CAC/PIV holders\n- **Bypass of the federal PKI** required for DOD-grade identity assurance\n- **Acceptance of certificates** from a private or unaccredited issuer", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://help.okta.com/en-us/content/topics/security/idp-enable-smart-card.htm", + "https://developer.okta.com/docs/api/openapi/okta-management/management/tag/IdentityProvider/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Security** > **Identity Providers**.\n3. For each IdP whose **Type** is **Smart Card**, click **Actions** > **Configure**.\n4. Under **Certificate chain**, verify the certificate is from a DOD-approved Certificate Authority (DOD PKI, ECA, JITC, or equivalent).\n5. If the IdP is not **Active**, activate it once the chain is validated.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Verify each Okta Smart Card (X509) Identity Provider is ACTIVE and its certificate chain is issued by a DOD-approved Certificate Authority. Document the issuer for audit evidence.", + "Url": "https://hub.prowler.com/check/idp_smart_card_dod_approved_ca" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273207 / OKTA-APP-001920." +} diff --git a/prowler/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca.py b/prowler/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca.py new file mode 100644 index 0000000000..5d19cca0dd --- /dev/null +++ b/prowler/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca.py @@ -0,0 +1,148 @@ +import re + +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.idp.idp_client import idp_client +from prowler.providers.okta.services.idp.idp_service import ( + SMART_CARD_IDP_TYPE, + OktaIdentityProvider, +) +from prowler.providers.okta.services.idp.lib.idp_helpers import ( + missing_idps_scope_finding, +) + +# Default issuer-DN substring patterns recognised as DOD-approved Certificate +# Authorities. The DOD PKI publishes canonical DN forms that include +# `O=U.S. Government, OU=DoD` (for DoD Root, DoD ID, DoD EMAIL, DoD SW, DoD +# JITC CAs) and `O=U.S. Government, OU=ECA` for the External Certificate +# Authorities. Customers running an internal CA outside these patterns can +# extend the list via the `okta_dod_approved_ca_issuer_patterns` audit-config +# entry — see the per-check Notes in metadata.json. +DEFAULT_DOD_CA_ISSUER_PATTERNS = ( + # `OU=DoD` is the distinctive DISA DN component for every CA in the DoD + # PKI (Root, ID, EMAIL, SW, JITC). `OU=ECA` is the equivalent for the + # External Certificate Authorities. The trailing `\b` prevents accidental + # matches against superstrings like `OU=DoDExtra`. + r"\bOU=DoD\b", + r"\bOU=ECA\b", +) + + +class idp_smart_card_dod_approved_ca(Check): + """Verifies that Okta Smart Card (X509) IdPs are configured and use a DOD-approved CA. + + PASS when the IdP is `ACTIVE` and its certificate chain's `issuer` + DN matches one of the configured DOD-approved CA patterns. MANUAL + when active but the issuer doesn't match (operator can verify + out-of-band or extend the pattern list). FAIL when no Smart Card + IdP is configured or when the configured IdP is inactive. + """ + + def execute(self) -> list[CheckReportOkta]: + findings: list[CheckReportOkta] = [] + org_domain = idp_client.provider.identity.org_domain + audit_config = idp_client.audit_config or {} + configured_patterns = audit_config.get("okta_dod_approved_ca_issuer_patterns") + patterns = ( + tuple(configured_patterns) + if configured_patterns + else DEFAULT_DOD_CA_ISSUER_PATTERNS + ) + + missing_scope = idp_client.missing_scope.get("identity_providers") + if missing_scope: + findings.append( + missing_idps_scope_finding(self.metadata(), org_domain, missing_scope) + ) + return findings + + smart_card_idps = [ + idp + for idp in idp_client.identity_providers.values() + if (idp.type or "").upper() == SMART_CARD_IDP_TYPE + ] + + if not smart_card_idps: + placeholder = OktaIdentityProvider( + id="okta-smart-card-idp-missing", + name="(no Smart Card IdP configured)", + type=SMART_CARD_IDP_TYPE, + status="MISSING", + ) + report = CheckReportOkta( + metadata=self.metadata(), resource=placeholder, org_domain=org_domain + ) + report.status = "FAIL" + report.status_extended = ( + "No Smart Card (X509) Identity Providers are configured. " + "Configure a Smart Card IdP in the Admin Console " + "(Security > Identity Providers) with a certificate chain " + "issued by a DOD-approved CA. If CAC/PIV authentication is " + "not required for this tenant, mutelist this check with " + "that documented exception." + ) + findings.append(report) + return findings + + for idp in smart_card_idps: + report = CheckReportOkta( + metadata=self.metadata(), resource=idp, org_domain=org_domain + ) + label = f"Okta Smart Card IdP '{idp.name}' (id={idp.id}, type={idp.type})" + chain_detail = _format_chain_detail(idp) + + if (idp.status or "").upper() != "ACTIVE": + report.status = "FAIL" + report.status_extended = ( + f"{label} is not ACTIVE (status={idp.status or 'unset'}). " + "Activate the IdP from Security > Identity Providers, then " + f"verify the certificate chain. {chain_detail}" + ) + findings.append(report) + continue + + matched_pattern = _matched_issuer_pattern(idp.trust_issuer, patterns) + if matched_pattern is not None: + report.status = "PASS" + report.status_extended = ( + f"{label} is ACTIVE and its chain issuer matches a " + f"DOD-approved CA pattern (`{matched_pattern}`). " + f"{chain_detail}" + ) + else: + report.status = "MANUAL" + report.status_extended = ( + f"{label} is ACTIVE but its chain issuer does not match any " + "configured DOD-approved CA pattern. Verify out-of-band " + "that the certificate chain belongs to a DOD-approved " + "Certificate Authority, or extend " + "`okta_dod_approved_ca_issuer_patterns` in the audit " + f"config. {chain_detail}" + ) + findings.append(report) + return findings + + +def _matched_issuer_pattern(issuer, patterns): + if not issuer: + return None + for pattern in patterns: + try: + if re.search(pattern, issuer): + return pattern + except re.error: + # Skip malformed operator-supplied patterns rather than crashing + # the whole check. + continue + return None + + +def _format_chain_detail(idp: OktaIdentityProvider) -> str: + if idp.trust_issuer or idp.trust_kid: + return ( + f"Chain issuer: {idp.trust_issuer or 'unset'}; " + f"kid: {idp.trust_kid or 'unset'}." + ) + return ( + "Chain issuer and kid were not exposed by the API; inspect the IdP in " + "the Admin Console under Security > Identity Providers > Configure." + ) diff --git a/prowler/providers/okta/services/idp/lib/__init__.py b/prowler/providers/okta/services/idp/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/idp/lib/idp_helpers.py b/prowler/providers/okta/services/idp/lib/idp_helpers.py new file mode 100644 index 0000000000..f1689f34a1 --- /dev/null +++ b/prowler/providers/okta/services/idp/lib/idp_helpers.py @@ -0,0 +1,26 @@ +"""Shared helpers for the OKTA idp STIG checks.""" + +from prowler.lib.check.models import CheckReportOkta +from prowler.providers.okta.services.idp.idp_service import OktaIdentityProvider + + +def missing_idps_scope_finding( + metadata, org_domain: str, scope: str +) -> CheckReportOkta: + """Build the MANUAL finding when the IdPs scope is not granted.""" + placeholder = OktaIdentityProvider( + id="okta-idps-scope-missing", + name="(scope not granted)", + status="MISSING", + ) + report = CheckReportOkta( + metadata=metadata, resource=placeholder, org_domain=org_domain + ) + report.status = "MANUAL" + report.status_extended = ( + "Could not retrieve Okta Identity Providers: the Okta service app is " + f"missing the required `{scope}` API scope. Grant it on the service " + "app's Okta API Scopes tab in the Okta Admin Console, then re-run the " + "check." + ) + return report diff --git a/prowler/providers/okta/services/network/__init__.py b/prowler/providers/okta/services/network/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/network/lib/__init__.py b/prowler/providers/okta/services/network/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/network/lib/network_zone_helpers.py b/prowler/providers/okta/services/network/lib/network_zone_helpers.py new file mode 100644 index 0000000000..bcd906b01a --- /dev/null +++ b/prowler/providers/okta/services/network/lib/network_zone_helpers.py @@ -0,0 +1,88 @@ +from prowler.lib.check.models import CheckReportOkta +from prowler.providers.okta.services.network.network_zone_service import ( + NetworkZoneSummary, + OktaNetworkZone, +) + +ANONYMIZER_CATEGORY_MARKERS = ( + "ANONYM", + "PROXY", + "TOR", + "VPN", +) + + +def active_blocklist_zones( + network_zones: dict[str, OktaNetworkZone], +) -> list[OktaNetworkZone]: + """Return active Network Zones configured for blocklist usage.""" + return sorted( + [ + zone + for zone in network_zones.values() + if zone.status.upper() == "ACTIVE" and zone.usage.upper() == "BLOCKLIST" + ], + key=lambda zone: (zone.name, zone.id), + ) + + +def is_ip_blocklist_with_entries(zone: OktaNetworkZone) -> bool: + """Return True when an IP blocklist zone contains gateway/proxy entries.""" + return zone.type.upper() == "IP" and bool(zone.gateways or zone.proxies) + + +def is_enhanced_dynamic_anonymizer_blocklist(zone: OktaNetworkZone) -> bool: + """Return True for active Enhanced Dynamic blocklists covering anonymizers.""" + if zone.type.upper() != "DYNAMIC_V2": + return False + categories = [category.upper() for category in zone.ip_service_categories] + return any( + marker in category + for category in categories + for marker in ANONYMIZER_CATEGORY_MARKERS + ) + + +def compliant_anonymized_proxy_blocklist( + network_zones: dict[str, OktaNetworkZone], +) -> tuple[OktaNetworkZone | None, str]: + """Find the Network Zone that satisfies anonymized-proxy blocklisting.""" + for zone in active_blocklist_zones(network_zones): + if is_enhanced_dynamic_anonymizer_blocklist(zone): + return zone, "active Enhanced Dynamic Zone blocklist for anonymizers" + return None, "" + + +def static_ip_blocklist_evidence( + network_zones: dict[str, OktaNetworkZone], +) -> OktaNetworkZone | None: + """Return static IP blocklist evidence that requires human validation.""" + for zone in active_blocklist_zones(network_zones): + if is_ip_blocklist_with_entries(zone): + return zone + return None + + +_SCOPE_ADVICE = ( + "Grant it on the Okta API Scopes tab of the service app in the Okta Admin " + "Console, then re-run the check." +) + + +def missing_network_zone_scope_finding( + metadata, org_domain: str, scope: str +) -> CheckReportOkta: + """Build the MANUAL finding emitted when Network Zones cannot be listed.""" + resource = NetworkZoneSummary( + id="network-zones-scope-missing", + name="(scope not granted)", + ) + report = CheckReportOkta( + metadata=metadata, resource=resource, org_domain=org_domain + ) + report.status = "MANUAL" + report.status_extended = ( + f"Could not retrieve Okta Network Zones: the Okta service app " + f"is missing the required `{scope}` API scope. {_SCOPE_ADVICE}" + ) + return report diff --git a/prowler/providers/okta/services/network/network_zone_block_anonymized_proxies/__init__.py b/prowler/providers/okta/services/network/network_zone_block_anonymized_proxies/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/network/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies.metadata.json b/prowler/providers/okta/services/network/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies.metadata.json new file mode 100644 index 0000000000..d2a4791c17 --- /dev/null +++ b/prowler/providers/okta/services/network/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "network_zone_block_anonymized_proxies", + "CheckTitle": "Okta uses active Network Zone blocklists for anonymized proxy sources", + "CheckType": [], + "ServiceName": "network", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "network", + "Description": "**Okta Network Zone blocklists** should block anonymized proxy access before authentication. Enhanced Dynamic Zone anonymizer categories provide direct coverage; static IP blocklists show gateway/proxy blocking but cannot prove full anonymizer-provider coverage.", + "Risk": "**Anonymized proxy access** lets attackers hide source networks while attempting credential attacks, session establishment, or policy bypass from untrusted infrastructure.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developer.okta.com/docs/api/openapi/okta-management/management/tags/networkzone", + "https://help.okta.com/en-us/content/topics/security/network/about-enhanced-dynamic-zones.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "Security > Networks: configure BlockedIpZone gateway/proxy IP entries or activate DefaultEnhancedDynamicZone / Enhanced Dynamic Zone blocklisting for anonymizers.", + "Terraform": "" + }, + "Recommendation": { + "Text": "**Prefer an active Enhanced Dynamic Zone blocklist** for anonymizers. If you use a static IP Network Zone blocklist, keep gateway/proxy entries actively maintained because Prowler cannot prove full anonymizer-provider coverage from static entries alone.", + "Url": "https://hub.prowler.com/check/network_zone_block_anonymized_proxies" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/okta/services/network/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies.py b/prowler/providers/okta/services/network/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies.py new file mode 100644 index 0000000000..3da973b415 --- /dev/null +++ b/prowler/providers/okta/services/network/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies.py @@ -0,0 +1,77 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.network.lib.network_zone_helpers import ( + compliant_anonymized_proxy_blocklist, + missing_network_zone_scope_finding, + static_ip_blocklist_evidence, +) +from prowler.providers.okta.services.network.network_zone_client import ( + network_zone_client, +) +from prowler.providers.okta.services.network.network_zone_service import ( + NetworkZoneSummary, +) + + +class network_zone_block_anonymized_proxies(Check): + """Ensure Okta actively blocks anonymized proxy sources before auth.""" + + def execute(self) -> list[CheckReportOkta]: + """Evaluate whether an active blocklist covers anonymized proxies.""" + org_domain = network_zone_client.provider.identity.org_domain + missing_scope = network_zone_client.missing_scope.get("network_zones") + if missing_scope: + return [ + missing_network_zone_scope_finding( + self.metadata(), org_domain, missing_scope + ) + ] + + retrieval_error = getattr(network_zone_client, "retrieval_error", None) + if retrieval_error: + resource = NetworkZoneSummary( + id="network-zones-retrieval-error", + name="(retrieval failed)", + ) + report = CheckReportOkta( + metadata=self.metadata(), resource=resource, org_domain=org_domain + ) + report.status = "MANUAL" + report.status_extended = ( + "Okta Network Zones could not be retrieved or validated. " + f"Reason: {retrieval_error}" + ) + return [report] + + matching_zone, reason = compliant_anonymized_proxy_blocklist( + network_zone_client.network_zones + ) + manual_zone = ( + None + if matching_zone + else static_ip_blocklist_evidence(network_zone_client.network_zones) + ) + + resource = matching_zone or manual_zone or NetworkZoneSummary() + report = CheckReportOkta( + metadata=self.metadata(), resource=resource, org_domain=org_domain + ) + if matching_zone: + report.status = "PASS" + report.status_extended = ( + f"Okta Network Zone {matching_zone.name} is an {reason}." + ) + elif manual_zone: + report.status = "MANUAL" + report.status_extended = ( + f"Okta Network Zone {manual_zone.name} is an active manual IP " + "blocklist with gateway or proxy IP entries; Prowler cannot " + "verify full anonymizer coverage for static entries." + ) + else: + report.status = "FAIL" + report.status_extended = ( + "No active Okta Network Zone blocklist was found that blocks " + "anonymized proxies. Existing zones do not actively block gateway " + "or proxy IPs, nor an Enhanced Dynamic Zone anonymizer category." + ) + return [report] diff --git a/prowler/providers/okta/services/network/network_zone_client.py b/prowler/providers/okta/services/network/network_zone_client.py new file mode 100644 index 0000000000..6d4f603f80 --- /dev/null +++ b/prowler/providers/okta/services/network/network_zone_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.okta.services.network.network_zone_service import NetworkZone + +network_zone_client = NetworkZone(Provider.get_global_provider()) diff --git a/prowler/providers/okta/services/network/network_zone_service.py b/prowler/providers/okta/services/network/network_zone_service.py new file mode 100644 index 0000000000..550c9b0d49 --- /dev/null +++ b/prowler/providers/okta/services/network/network_zone_service.py @@ -0,0 +1,234 @@ +from typing import Optional + +from pydantic import BaseModel, Field, ValidationError + +from prowler.lib.logger import logger +from prowler.providers.okta.lib.service.pagination import paginate as _paginate_shared +from prowler.providers.okta.lib.service.raw_fetch import ( + get_json_paginated as _raw_get_json_paginated, +) +from prowler.providers.okta.lib.service.service import OktaService + +REQUIRED_SCOPES: dict[str, str] = { + "network_zones": "okta.networkZones.read", +} + + +def _value(value) -> str: + """Return plain string values from Okta SDK enums and raw strings.""" + if value is None: + return "" + enum_value = getattr(value, "value", None) + if enum_value is not None: + return str(enum_value) + return str(value) + + +class NetworkZone(OktaService): + """Fetches Okta Network Zones for STIG network-zone checks.""" + + def __init__(self, provider): + super().__init__(__class__.__name__, provider) + granted = set(getattr(provider.identity, "granted_scopes", None) or []) + self.missing_scope: dict[str, Optional[str]] = { + resource: (scope if granted and scope not in granted else None) + for resource, scope in REQUIRED_SCOPES.items() + } + self.retrieval_error: str | None = None + self.network_zones: dict[str, OktaNetworkZone] = ( + {} if self.missing_scope["network_zones"] else self._list_network_zones() + ) + + def _set_retrieval_error(self, message: str) -> None: + self.retrieval_error = message + logger.error(message) + + def _list_network_zones(self) -> dict[str, "OktaNetworkZone"]: + """List all Network Zones visible to the configured Okta service app.""" + logger.info("NetworkZone - Listing Okta Network Zones...") + try: + return self._run(self._fetch_all()) + except Exception as error: + line_number = getattr(error.__traceback__, "tb_lineno", "unknown") + self._set_retrieval_error( + f"{error.__class__.__name__}[{line_number}]: {error}" + ) + return {} + + async def _fetch_all(self) -> dict[str, "OktaNetworkZone"]: + result: dict[str, OktaNetworkZone] = {} + try: + all_zones, err = await _paginate_shared( + lambda after: self.client.list_network_zones(after=after, limit=200) + ) + except (ValueError, ValidationError) as ex: + # Upstream Okta SDK ↔ Management API schema drift: the SDK + # generates `EnhancedDynamicNetworkZoneAllOfAsnsInclude` as an + # object-shaped pydantic model, but the API returns + # `asns.include` as a JSON array (typically `[]`), so pydantic + # rejects the whole zone with `model_type` errors. Fall back + # to a raw-JSON fetch so STIG evaluation isn't blocked by an + # upstream SDK bug. Same workaround shape as + # `application_service._fetch_access_policy_raw`. The wider + # `(ValueError, ValidationError)` catch matches the + # `user_service` precedent — the SDK raises either depending + # on whether the failure is a discriminator miss or a model + # mismatch. + logger.warning( + f"Okta SDK raised {type(ex).__name__} parsing Network Zones — " + "falling back to raw-JSON parse. This is an okta-sdk-python " + "deserialization bug; the workaround should be removed once " + "upstream fixes it." + ) + return await self._fetch_all_raw() + if err is not None: + self._set_retrieval_error(f"Error listing Network Zones: {err}") + return result + + for zone in all_zones: + zone_obj = self._build_zone(zone) + result[zone_obj.id] = zone_obj + return result + + async def _fetch_all_raw(self) -> dict[str, "OktaNetworkZone"]: + """Raw-JSON fallback for `list_network_zones`. + + Bypasses the SDK's typed deserialization via the shared + `get_json_paginated` helper, then projects each zone onto our + own pydantic snapshot — which only validates the fields the + STIG checks actually read. + """ + result: dict[str, OktaNetworkZone] = {} + zones_data = await _raw_get_json_paginated( + self.client, + "/api/v1/zones", + page_size=200, + context="Network Zones", + ) + if zones_data is None: + self._set_retrieval_error( + "Raw Network Zones fetch failed; see logs for details." + ) + return result + for zone_dict in zones_data: + if not isinstance(zone_dict, dict): + continue + zone_obj = _raw_zone_to_model(zone_dict) + result[zone_obj.id] = zone_obj + return result + + @staticmethod + def _build_zone(zone) -> "OktaNetworkZone": + zone_id = _value(getattr(zone, "id", None)) + return OktaNetworkZone( + id=zone_id, + name=_value(getattr(zone, "name", None)) or zone_id, + status=_value(getattr(zone, "status", None)), + type=_value(getattr(zone, "type", None)), + usage=_value(getattr(zone, "usage", None)), + system=bool(getattr(zone, "system", False)), + gateways=_address_values(getattr(zone, "gateways", None)), + proxies=_address_values(getattr(zone, "proxies", None)), + asns=_condition_values(getattr(zone, "asns", None)), + locations=_condition_values(getattr(zone, "locations", None)), + ip_service_categories=_condition_values( + getattr(zone, "ip_service_categories", None) + ), + ) + + +def _raw_zone_to_model(zone_dict: dict) -> "OktaNetworkZone": + """Project a raw `/api/v1/zones` JSON zone onto our model. + + Mirrors `NetworkZone._build_zone` but reads camelCase JSON keys + (`ipServiceCategories`) instead of the SDK's snake_case attributes. + Used by the raw-JSON fallback that activates when the Okta SDK's + strict pydantic validators reject zone payloads the Management API + returns (e.g. Enhanced Dynamic Zones with `asns.include: []`). + """ + zone_id = str(zone_dict.get("id") or "") + categories = _condition_values(zone_dict.get("ipServiceCategories")) + # IP-typed zones return `gateways`/`proxies` as `[{type, value}]` + # arrays; Enhanced Dynamic Zones return `asns`/`locations` and + # `ipServiceCategories` as `{include, exclude}` objects. Keep the + # `list[str]` shape by extracting address values and included + # condition values from both SDK models and raw JSON. + return OktaNetworkZone( + id=zone_id, + name=str(zone_dict.get("name") or zone_id), + status=str(zone_dict.get("status") or ""), + type=str(zone_dict.get("type") or ""), + usage=str(zone_dict.get("usage") or ""), + system=bool(zone_dict.get("system", False)), + gateways=_address_values(zone_dict.get("gateways")), + proxies=_address_values(zone_dict.get("proxies")), + asns=_condition_values(zone_dict.get("asns")), + locations=_condition_values(zone_dict.get("locations")), + ip_service_categories=categories, + ) + + +def _address_values(raw) -> list[str]: + """Return string values from an Okta address-style JSON array. + + Each entry in `gateways`/`proxies` is `{"type": ..., "value": ...}`; + `asns`/`locations` may be a `{include, exclude}` object on Enhanced + Dynamic Zones. Non-list inputs collapse to `[]` so the resulting + list satisfies the pydantic `list[str]` field. + """ + if not isinstance(raw, list): + return [] + out: list[str] = [] + for entry in raw: + if isinstance(entry, dict): + value = entry.get("value") + elif entry is not None: + value = getattr(entry, "value", entry) + else: + value = None + if value is not None: + out.append(_value(value)) + return out + + +def _condition_values(raw) -> list[str]: + """Return string values from Okta include/exclude-style conditions.""" + if raw is None: + return [] + values = ( + raw.get("include") if isinstance(raw, dict) else getattr(raw, "include", raw) + ) + if values is None: + return [] + if not isinstance(values, list): + values = [values] + normalized = [] + for value in values: + if isinstance(value, dict): + value = value.get("value") + if value is not None: + normalized.append(_value(value)) + return normalized + + +class OktaNetworkZone(BaseModel): + """Normalized Okta Network Zone attributes used by checks.""" + + id: str + name: str + status: str = "" + type: str = "" + usage: str = "" + system: bool = False + gateways: list[str] = Field(default_factory=list) + proxies: list[str] = Field(default_factory=list) + asns: list[str] = Field(default_factory=list) + locations: list[str] = Field(default_factory=list) + ip_service_categories: list[str] = Field(default_factory=list) + + +class NetworkZoneSummary(BaseModel): + """Synthetic resource for org-level Network Zone findings.""" + + id: str = "okta-network-zones" + name: str = "Okta Network Zones" diff --git a/prowler/providers/okta/services/signon/signon_service.py b/prowler/providers/okta/services/signon/signon_service.py index 7c32363501..663e9cf187 100644 --- a/prowler/providers/okta/services/signon/signon_service.py +++ b/prowler/providers/okta/services/signon/signon_service.py @@ -1,34 +1,11 @@ from typing import Optional -from urllib.parse import parse_qs, urlparse from pydantic import BaseModel from prowler.lib.logger import logger +from prowler.providers.okta.lib.service.pagination import paginate as _paginate_shared from prowler.providers.okta.lib.service.service import OktaService - -def _next_after_cursor(resp) -> Optional[str]: - """Extract the `after` cursor from a `Link: ...; rel="next"` header. - - Returns None when there is no next page. Header format follows RFC 5988 - and Okta's pagination guide. - """ - if resp is None: - return None - headers = getattr(resp, "headers", None) or {} - link = headers.get("link") or headers.get("Link") or "" - if not link: - return None - for part in link.split(","): - if 'rel="next"' not in part: - continue - url_segment = part.split(";", 1)[0].strip().lstrip("<").rstrip(">") - cursor = parse_qs(urlparse(url_segment).query).get("after", [None])[0] - if cursor: - return cursor - return None - - REQUIRED_SCOPES: dict[str, str] = { "global_session_policies": "okta.policies.read", "sign_in_pages": "okta.brands.read", @@ -228,33 +205,7 @@ class Signon(OktaService): @staticmethod async def _paginate(fetch): - """Drain all pages of an SDK list call. - - `fetch` is a callable that takes the `after` cursor (or None for - the first page) and returns the SDK's standard `(items, resp, err)` - tuple. We follow `Link: rel="next"` headers until exhausted. - """ - all_items = [] - result = await fetch(None) - # Defensive against the SDK's 2-tuple early-error path: error is last. - err = result[-1] - if err is not None: - return [], err - items = result[0] - resp = result[1] if len(result) >= 3 else None - all_items.extend(items or []) - while True: - cursor = _next_after_cursor(resp) - if not cursor: - break - result = await fetch(cursor) - err = result[-1] - if err is not None: - return all_items, err - items = result[0] - resp = result[1] if len(result) >= 3 else None - all_items.extend(items or []) - return all_items, None + return await _paginate_shared(fetch) class GlobalSessionPolicyRule(BaseModel): diff --git a/prowler/providers/okta/services/systemlog/__init__.py b/prowler/providers/okta/services/systemlog/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/systemlog/lib/__init__.py b/prowler/providers/okta/services/systemlog/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/systemlog/lib/systemlog_helpers.py b/prowler/providers/okta/services/systemlog/lib/systemlog_helpers.py new file mode 100644 index 0000000000..e82cedff5f --- /dev/null +++ b/prowler/providers/okta/services/systemlog/lib/systemlog_helpers.py @@ -0,0 +1,26 @@ +"""Shared helpers for the OKTA systemlog STIG checks.""" + +from prowler.lib.check.models import CheckReportOkta +from prowler.providers.okta.services.systemlog.systemlog_service import LogStream + + +def missing_log_streams_scope_finding( + metadata, org_domain: str, scope: str +) -> CheckReportOkta: + """Build the MANUAL finding when the log-streams scope is not granted.""" + placeholder = LogStream( + id="okta-log-streams-scope-missing", + name="(scope not granted)", + status="MISSING", + type="", + ) + report = CheckReportOkta( + metadata=metadata, resource=placeholder, org_domain=org_domain + ) + report.status = "MANUAL" + report.status_extended = ( + "Could not retrieve Okta Log Streams: the Okta service app is missing " + f"the required `{scope}` API scope. Grant it on the service app's " + "Okta API Scopes tab in the Okta Admin Console, then re-run the check." + ) + return report diff --git a/prowler/providers/okta/services/systemlog/systemlog_client.py b/prowler/providers/okta/services/systemlog/systemlog_client.py new file mode 100644 index 0000000000..3dc0a1a0af --- /dev/null +++ b/prowler/providers/okta/services/systemlog/systemlog_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.okta.services.systemlog.systemlog_service import SystemLog + +systemlog_client = SystemLog(Provider.get_global_provider()) diff --git a/prowler/providers/okta/services/systemlog/systemlog_service.py b/prowler/providers/okta/services/systemlog/systemlog_service.py new file mode 100644 index 0000000000..96c8b6d7ae --- /dev/null +++ b/prowler/providers/okta/services/systemlog/systemlog_service.py @@ -0,0 +1,136 @@ +from typing import Optional + +from pydantic import BaseModel, ValidationError + +from prowler.lib.logger import logger +from prowler.providers.okta.lib.service.pagination import paginate +from prowler.providers.okta.lib.service.raw_fetch import ( + get_json_paginated as raw_get_json_paginated, +) +from prowler.providers.okta.lib.service.service import OktaService + +REQUIRED_SCOPES: dict[str, str] = { + "log_streams": "okta.logStreams.read", +} + + +class SystemLog(OktaService): + """Fetches Okta Log Stream configurations. + + Populates `self.log_streams` keyed by Log Stream id. Each entry + carries `name`, `status`, `type` — enough for the streaming-enabled + check to evaluate whether the tenant has off-loaded audit records + to an external SIEM/event bus. + + Required OAuth scopes (`REQUIRED_SCOPES`) are compared against the + access token's granted scopes (`provider.identity.granted_scopes`). + Missing scopes are recorded in `self.missing_scope` so the check + can emit an explicit MANUAL finding instead of a misleading + "no resources returned". + """ + + def __init__(self, provider): + super().__init__(__class__.__name__, provider) + granted = set(getattr(provider.identity, "granted_scopes", None) or []) + self.missing_scope: dict[str, Optional[str]] = { + resource: (scope if granted and scope not in granted else None) + for resource, scope in REQUIRED_SCOPES.items() + } + + self.log_streams: dict[str, LogStream] = ( + {} if self.missing_scope["log_streams"] else self._list_log_streams() + ) + + def _list_log_streams(self) -> dict: + logger.info("SystemLog - Listing Okta Log Streams...") + try: + return self._run(self._fetch_log_streams()) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return {} + + async def _fetch_log_streams(self) -> dict: + result: dict[str, LogStream] = {} + try: + all_streams, err = await paginate( + lambda after: self.client.list_log_streams(after=after) + ) + except ValidationError as ve: + # Upstream okta-sdk-python bug: e.g. `LogStreamSettingsAws`'s + # `eventSourceName` validator regex is `^[a-zA-Z0-9.\-_]$` — + # missing the `+` quantifier, so it rejects every + # multi-character name. Fall back to raw JSON so the check + # can still evaluate the tenant's actual log-stream state. + # Remove this workaround once okta-sdk-python fixes the + # validator (issue to be filed upstream). + logger.warning( + f"Okta SDK raised ValidationError parsing log streams " + f"({ve.error_count()} error(s)) — falling back to raw-JSON " + "parse. This is an okta-sdk-python deserialization bug." + ) + return await self._fetch_log_streams_raw() + + if err is not None: + logger.error(f"Error listing log streams: {err}") + return result + + for stream in all_streams: + stream_id = getattr(stream, "id", "") or "" + if not stream_id: + continue + result[stream_id] = LogStream( + id=stream_id, + name=getattr(stream, "name", "") or "", + status=getattr(stream, "status", "") or "", + type=_stringify_enum(getattr(stream, "type", None)) or "", + ) + return result + + async def _fetch_log_streams_raw(self) -> dict: + """Raw-JSON fallback for `list_log_streams`. + + Bypasses the SDK's typed deserialization via the shared + `get_json_paginated` helper (which follows the `Link: rel=next` + cursor so tenants with >200 streams are not silently truncated), + and projects the response onto our own pydantic snapshot which + only validates the four fields the check reads. Keeps the check + evaluable on tenants whose Log Stream settings happen to trip + an SDK enum/regex validator. + """ + result: dict[str, LogStream] = {} + data = await raw_get_json_paginated( + self.client, + "/api/v1/logStreams", + page_size=200, + context="log streams", + ) + if data is None: + return result + for item in data: + if not isinstance(item, dict): + continue + stream_id = item.get("id") + if not stream_id: + continue + result[stream_id] = LogStream( + id=stream_id, + name=item.get("name") or "", + status=(item.get("status") or "").upper(), + type=item.get("type") or "", + ) + return result + + +def _stringify_enum(value) -> Optional[str]: + if value is None: + return None + return getattr(value, "value", None) or str(value) + + +class LogStream(BaseModel): + id: str + name: str = "" + status: str = "" + type: str = "" diff --git a/prowler/providers/okta/services/systemlog/systemlog_streaming_enabled/__init__.py b/prowler/providers/okta/services/systemlog/systemlog_streaming_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled.metadata.json b/prowler/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled.metadata.json new file mode 100644 index 0000000000..27541ee2fe --- /dev/null +++ b/prowler/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "okta", + "CheckID": "systemlog_streaming_enabled", + "CheckTitle": "Okta off-loads audit records to a central log server via Log Streaming", + "CheckType": [], + "ServiceName": "systemlog", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "monitoring", + "Description": "Okta must off-load audit records to a central log server. At least one **Log Stream** (AWS EventBridge, Splunk Cloud, etc.) must be configured and `ACTIVE` in the tenant. Alternatively, an external SIEM pulling the System Log API can satisfy the requirement, but that pull-based path is verified manually.", + "Risk": "Audit records stored only inside the Okta tenant are exposed to accidental or incidental deletion or alteration.\n\n- **No central retention** of authentication events for incident investigations\n- **Single point of failure** for the audit trail\n- **No correlation** with other identity, network, and endpoint telemetry in the SIEM", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://help.okta.com/en-us/content/topics/reports/log-streaming/about-log-streams.htm", + "https://developer.okta.com/docs/api/openapi/okta-management/management/tag/LogStream/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Reports** > **Log Streaming**.\n3. Click **Add Log Stream** and select **AWS EventBridge**, **Splunk Cloud**, or another supported destination.\n4. Complete the connection fields and save.\n5. Activate the stream and verify the destination receives events.\n6. If the destination SIEM is not natively supported, document the pull-based ingestion that uses the System Log API.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure at least one ACTIVE Okta Log Stream that off-loads audit records to a central SIEM (AWS EventBridge, Splunk Cloud, or another supported destination). Document any alternative pull-based ingestion via the System Log API.", + "Url": "https://hub.prowler.com/check/systemlog_streaming_enabled" + } + }, + "Categories": [ + "logging" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273202 / OKTA-APP-001430." +} diff --git a/prowler/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled.py b/prowler/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled.py new file mode 100644 index 0000000000..61448aeaf5 --- /dev/null +++ b/prowler/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled.py @@ -0,0 +1,88 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.systemlog.lib.systemlog_helpers import ( + missing_log_streams_scope_finding, +) +from prowler.providers.okta.services.systemlog.systemlog_client import systemlog_client +from prowler.providers.okta.services.systemlog.systemlog_service import LogStream + + +class systemlog_streaming_enabled(Check): + """Verifies that at least one Okta Log Stream is configured and active. + + Off-loading audit records to a central SIEM (AWS EventBridge, Splunk + Cloud, etc.) is the standard mechanism for centralised retention. + An alternative path — pulling the System Log API into an external + SIEM — is allowed by the requirement, but cannot be verified + automatically; this check emits a MANUAL note in that case. + """ + + def execute(self) -> list[CheckReportOkta]: + findings: list[CheckReportOkta] = [] + org_domain = systemlog_client.provider.identity.org_domain + + missing_scope = systemlog_client.missing_scope.get("log_streams") + if missing_scope: + findings.append( + missing_log_streams_scope_finding( + self.metadata(), org_domain, missing_scope + ) + ) + return findings + + active_streams = [ + stream + for stream in systemlog_client.log_streams.values() + if not stream.status or stream.status.upper() == "ACTIVE" + ] + + if not systemlog_client.log_streams: + placeholder = LogStream( + id="okta-log-streams-missing", + name="(no Log Streams configured)", + status="MISSING", + type="", + ) + report = CheckReportOkta( + metadata=self.metadata(), resource=placeholder, org_domain=org_domain + ) + report.status = "FAIL" + report.status_extended = ( + "No Okta Log Streams are configured. Configure a Log Stream " + "(Reports > Log Streaming) to off-load audit records to a " + "central SIEM. If an external SIEM is already pulling logs " + "via the System Log API, mutelist this check with that " + "evidence." + ) + findings.append(report) + return findings + + if not active_streams: + placeholder = LogStream( + id="okta-log-streams-inactive", + name="(no active Log Streams)", + status="INACTIVE", + type="", + ) + report = CheckReportOkta( + metadata=self.metadata(), resource=placeholder, org_domain=org_domain + ) + report.status = "FAIL" + report.status_extended = ( + f"{len(systemlog_client.log_streams)} Okta Log Stream(s) are " + "configured but none are ACTIVE. Activate a Log Stream to " + "off-load audit records to a central SIEM." + ) + findings.append(report) + return findings + + for stream in active_streams: + report = CheckReportOkta( + metadata=self.metadata(), resource=stream, org_domain=org_domain + ) + report.status = "PASS" + report.status_extended = ( + f"Okta Log Stream '{stream.name}' (type={stream.type or 'unset'}) " + "is ACTIVE and off-loads audit records to a central SIEM." + ) + findings.append(report) + return findings diff --git a/prowler/providers/okta/services/user/__init__.py b/prowler/providers/okta/services/user/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/user/lib/__init__.py b/prowler/providers/okta/services/user/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/user/lib/user_helpers.py b/prowler/providers/okta/services/user/lib/user_helpers.py new file mode 100644 index 0000000000..423801f2d8 --- /dev/null +++ b/prowler/providers/okta/services/user/lib/user_helpers.py @@ -0,0 +1,26 @@ +"""Shared helpers for the OKTA user STIG checks.""" + +from prowler.lib.check.models import CheckReportOkta +from prowler.providers.okta.services.user.user_service import UserAutomation + + +def missing_user_scope_finding( + metadata, org_domain: str, scope: str +) -> CheckReportOkta: + """Build the MANUAL finding when an OAuth scope is not granted.""" + placeholder = UserAutomation( + id="okta-user-scope-missing", + name="(scope not granted)", + status="MISSING", + ) + report = CheckReportOkta( + metadata=metadata, resource=placeholder, org_domain=org_domain + ) + report.status = "MANUAL" + report.status_extended = ( + f"Could not retrieve Okta user lifecycle automations: the Okta service " + f"app is missing the required `{scope}` API scope. Grant it on the " + "service app's Okta API Scopes tab in the Okta Admin Console, then " + "re-run the check." + ) + return report diff --git a/prowler/providers/okta/services/user/user_client.py b/prowler/providers/okta/services/user/user_client.py new file mode 100644 index 0000000000..9a49fbdbac --- /dev/null +++ b/prowler/providers/okta/services/user/user_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.okta.services.user.user_service import User + +user_client = User(Provider.get_global_provider()) diff --git a/prowler/providers/okta/services/user/user_inactivity_automation_35d_enabled/__init__.py b/prowler/providers/okta/services/user/user_inactivity_automation_35d_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled.metadata.json b/prowler/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled.metadata.json new file mode 100644 index 0000000000..64f24d95ae --- /dev/null +++ b/prowler/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "okta", + "CheckID": "user_inactivity_automation_35d_enabled", + "CheckTitle": "Okta automation suspends or deactivates users after 35 days of inactivity", + "CheckType": [], + "ServiceName": "user", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "An Okta **Workflows Automation** must disable inactive user accounts. The automation must be `ACTIVE`, on an `ACTIVE` schedule, evaluate `User Inactivity = 35 days` (or less), apply to a group covering every user, and trigger `Suspended` / `Deactivated` / `Deprovisioned`. Threshold override: `okta_user_inactivity_max_days`. N/A when user sourcing is delegated to Active Directory or LDAP.", + "Risk": "Inactive Okta accounts retained indefinitely give an attacker who exploits one undetected access to downstream applications.\n\n- **Account takeover via dormant identities** that no one is monitoring\n- **Lateral movement** through SSO sessions of forgotten users\n- **Stale entitlements** that survive role and policy reorganisations", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://help.okta.com/en-us/content/topics/automation-hooks/automations-main.htm" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the **Okta Admin Console** as a *Super Admin*.\n2. Navigate to **Workflow** > **Automations** and click **Add Automation**.\n3. Name the automation (e.g., `User Inactivity`).\n4. Add a condition: select **User Inactivity in Okta** and enter `35` days.\n5. Configure the schedule to run daily and activate it.\n6. Apply the automation to a group that covers every user — typically `Everyone`.\n7. Add an action: **Change User lifecycle state in Okta** and choose `Suspended` (or `Deactivated`/`Deprovisioned`).\n8. Activate the automation.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Create an active Okta Workflows automation that runs daily, evaluates `User Inactivity in Okta = 35 days`, applies to a group covering every user, and changes the user lifecycle state to Suspended/Deactivated. If user sourcing is delegated to Active Directory or LDAP, document that the connected directory enforces this requirement instead.", + "Url": "https://hub.prowler.com/check/user_inactivity_automation_35d_enabled" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Aligns with DISA STIG V-273188 / OKTA-APP-000090." +} diff --git a/prowler/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled.py b/prowler/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled.py new file mode 100644 index 0000000000..79e77c3f73 --- /dev/null +++ b/prowler/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled.py @@ -0,0 +1,204 @@ +from prowler.lib.check.models import Check, CheckReportOkta +from prowler.providers.okta.services.user.lib.user_helpers import ( + missing_user_scope_finding, +) +from prowler.providers.okta.services.user.user_client import user_client +from prowler.providers.okta.services.user.user_service import UserAutomation + +DEFAULT_INACTIVITY_DAYS = 35 +SUSPENSION_LIFECYCLE_ACTIONS = {"SUSPENDED", "DEACTIVATED", "DEPROVISIONED"} + + +class user_inactivity_automation_35d_enabled(Check): + """Verifies that Okta suspends/deactivates users after 35 days of inactivity. + + A Workflows Automation must exist with: + - status ACTIVE, + - schedule active, + - condition `User Inactivity in Okta = 35 days`, + - action that changes the user state to Suspended / Deactivated, + - applied to a group covering every user (typically `Everyone`). + + When user sourcing is delegated to an external directory (Active + Directory or LDAP), the requirement is N/A on the Okta side — the + connected directory is expected to enforce inactivity-based + deactivation instead. Threshold override: + `okta_user_inactivity_max_days` in the audit config. + """ + + def execute(self) -> list[CheckReportOkta]: + findings: list[CheckReportOkta] = [] + audit_config = user_client.audit_config or {} + threshold_days = audit_config.get( + "okta_user_inactivity_max_days", DEFAULT_INACTIVITY_DAYS + ) + org_domain = user_client.provider.identity.org_domain + + for scope_key in ("automations", "identity_providers"): + missing_scope = user_client.missing_scope.get(scope_key) + if missing_scope: + findings.append( + missing_user_scope_finding( + self.metadata(), org_domain, missing_scope + ) + ) + return findings + + # External-directory N/A path. + if user_client.external_directory_idps: + idp_names = ", ".join( + f"'{idp.name}' (type={idp.type})" + for idp in user_client.external_directory_idps.values() + ) + placeholder = UserAutomation( + id="okta-user-inactivity-na-external-directory", + name="(external directory enforces inactivity)", + status="N/A", + ) + report = CheckReportOkta( + metadata=self.metadata(), + resource=placeholder, + org_domain=org_domain, + ) + report.status = "MANUAL" + report.status_extended = ( + "User sourcing is delegated to an external directory " + f"({idp_names}). The 35-day inactivity disable requirement is " + "expected to be enforced by the connected directory rather " + "than by an Okta automation. Confirm out-of-band that the " + "external directory disables accounts after " + f"{threshold_days} days of inactivity." + ) + findings.append(report) + return findings + + compliant_automations = [ + automation + for automation in user_client.automations.values() + if _is_compliant(automation, threshold_days) + ] + + if not user_client.automations: + placeholder = UserAutomation( + id="okta-user-inactivity-no-automations", + name="(no automations configured)", + status="MISSING", + ) + report = CheckReportOkta( + metadata=self.metadata(), + resource=placeholder, + org_domain=org_domain, + ) + report.status = "FAIL" + report.status_extended = ( + "No Okta Workflows automations are configured. Create an " + "automation that suspends or deactivates users after " + f"{threshold_days} days of inactivity, scoped to a group " + "covering every user (typically 'Everyone'), with an active " + "schedule." + ) + findings.append(report) + return findings + + if compliant_automations: + for automation in compliant_automations: + report = CheckReportOkta( + metadata=self.metadata(), + resource=automation, + org_domain=org_domain, + ) + report.status = "PASS" + groups_label = ", ".join(automation.applies_to_groups) + report.status_extended = ( + f"Okta automation '{automation.name}' is ACTIVE with an " + f"active schedule, triggers after " + f"{automation.inactivity_days} days of inactivity, and " + f"changes the user state to " + f"{automation.lifecycle_action or 'unset'}. " + f"Applied to group(s): {groups_label}. Verify that these " + "group(s) cover every user. Okta has no built-in " + "'Everyone' group ID, so tenant-wide coverage cannot be " + "asserted automatically." + ) + findings.append(report) + return findings + + # Automations exist but none satisfy the predicate — surface the + # closest candidate for the auditor. + candidate = _closest_candidate(user_client.automations.values()) + report = CheckReportOkta( + metadata=self.metadata(), + resource=candidate + or UserAutomation( + id="okta-user-inactivity-noncompliant", + name="(no compliant automation)", + status="MISSING", + ), + org_domain=org_domain, + ) + report.status = "FAIL" + report.status_extended = _failure_message(candidate, threshold_days) + findings.append(report) + return findings + + +def _is_compliant(automation: UserAutomation, threshold_days: int) -> bool: + # `applies_to_groups` must be non-empty — Okta USER_LIFECYCLE policies + # do not implicitly cover every user; the scope is whatever group IDs + # the operator put in `people.groups.include`. An empty scope means + # the automation runs against nobody. Operator must still verify those + # group(s) cover the intended user population (surfaced in the PASS + # status_extended). + return bool( + automation.status.upper() == "ACTIVE" + and automation.schedule_status.upper() == "ACTIVE" + and automation.inactivity_days is not None + and automation.inactivity_days <= threshold_days + and (automation.lifecycle_action or "").upper() in SUSPENSION_LIFECYCLE_ACTIONS + and bool(automation.applies_to_groups) + ) + + +def _closest_candidate(automations): + automations = list(automations) + if not automations: + return None + automations.sort( + key=lambda a: ( + 0 if a.status.upper() == "ACTIVE" else 1, + 0 if a.schedule_status.upper() == "ACTIVE" else 1, + ( + abs(a.inactivity_days - DEFAULT_INACTIVITY_DAYS) + if a.inactivity_days is not None + else 10_000 + ), + a.name, + ) + ) + return automations[0] + + +def _failure_message(automation, threshold_days): + if automation is None: + return f"No Okta automation enforces {threshold_days}-day inactivity disable." + issues = [] + if automation.status.upper() != "ACTIVE": + issues.append(f"status {automation.status or 'unset'}") + if automation.schedule_status.upper() != "ACTIVE": + issues.append(f"schedule {automation.schedule_status or 'unset'}") + if automation.inactivity_days is None: + issues.append("no inactivity condition") + elif automation.inactivity_days > threshold_days: + issues.append( + f"inactivity {automation.inactivity_days}d (max {threshold_days}d)" + ) + action = (automation.lifecycle_action or "").upper() + if action not in SUSPENSION_LIFECYCLE_ACTIONS: + issues.append(f"action {automation.lifecycle_action or 'unset'}") + if not automation.applies_to_groups: + issues.append("no group scope") + detail = ", ".join(issues) if issues else "incomplete" + return ( + f"Okta automation '{automation.name}' fails {threshold_days}d " + f"inactivity: {detail}." + ) diff --git a/prowler/providers/okta/services/user/user_service.py b/prowler/providers/okta/services/user/user_service.py new file mode 100644 index 0000000000..c816bd38ee --- /dev/null +++ b/prowler/providers/okta/services/user/user_service.py @@ -0,0 +1,455 @@ +from typing import Optional + +from pydantic import BaseModel, ValidationError + +from prowler.lib.logger import logger +from prowler.providers.okta.lib.service.pagination import paginate +from prowler.providers.okta.lib.service.raw_fetch import ( + get_json_paginated as raw_get_json_paginated, +) +from prowler.providers.okta.lib.service.service import OktaService + +# External-directory IdP `type` values that delegate user sourcing to a +# separate identity store. When any of these is present and ACTIVE, the +# STIG's 35-day inactivity disable requirement is N/A on the Okta side — +# the connected directory is expected to enforce it instead. +EXTERNAL_DIRECTORY_IDP_TYPES = {"ACTIVE_DIRECTORY", "LDAP"} + +# Okta exposes "Workflow > Automations" as USER_LIFECYCLE policies with +# inactivity rule conditions, not as a standalone `/api/v1/automations` +# resource. The SDK's `UserPolicyRuleCondition.inactivity` and +# `ScheduledUserLifecycleAction` models confirm this; the API rejects +# every other `type` candidate. +USER_LIFECYCLE_POLICY_TYPE = "USER_LIFECYCLE" + +REQUIRED_SCOPES: dict[str, str] = { + "automations": "okta.policies.read", + "identity_providers": "okta.idps.read", +} + + +class User(OktaService): + """Fetches Okta User Lifecycle Automations and external-directory IdPs. + + Populates: + - `self.automations` — keyed by USER_LIFECYCLE policy rule id. Each + entry projects the fields the 35-day inactivity check evaluates: + identity (`id`, `name` — taken from the rule), `status`, + `schedule_status` (inherited from the parent policy), the + `inactivity_days` condition and `applies_to_groups` scope from the + parent policy, and the `lifecycle_action` from the rule. + - `self.external_directory_idps` — keyed by IdP id. Used to short + circuit the STIG to N/A when user sourcing is delegated to an + external directory (Active Directory, LDAP). + + The Okta Admin Console's "Workflow > Automations" page is rendered + on top of `USER_LIFECYCLE` policies in the Management API + (`list_policies(type='USER_LIFECYCLE')` + `list_policy_rules(...)`). + There is no standalone `/api/v1/automations` GET endpoint; the SDK's + `InactivityPolicyRuleCondition`, `UserPolicyRuleCondition`, and + `ScheduledUserLifecycleAction` models all hang off the policy API. + + Required OAuth scopes (`REQUIRED_SCOPES`) are compared against the + access token's granted scopes (`provider.identity.granted_scopes`). + Missing scopes are recorded in `self.missing_scope` so the check + can emit an explicit MANUAL finding. + """ + + def __init__(self, provider): + super().__init__(__class__.__name__, provider) + granted = set(getattr(provider.identity, "granted_scopes", None) or []) + self.missing_scope: dict[str, Optional[str]] = { + resource: (scope if granted and scope not in granted else None) + for resource, scope in REQUIRED_SCOPES.items() + } + + self.automations: dict[str, UserAutomation] = ( + {} if self.missing_scope["automations"] else self._list_automations() + ) + self.external_directory_idps: dict[str, ExternalDirectoryIdp] = ( + {} + if self.missing_scope["identity_providers"] + else self._list_external_directory_idps() + ) + + def _list_automations(self) -> dict: + logger.info("User - Listing USER_LIFECYCLE policies and rules...") + try: + return self._run(self._fetch_automations()) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return {} + + async def _fetch_automations(self) -> dict: + result: dict[str, UserAutomation] = {} + try: + all_policies, err = await paginate( + lambda after: self.client.list_policies( + type=USER_LIFECYCLE_POLICY_TYPE, after=after + ) + ) + except (ValueError, ValidationError) as ex: + # Upstream okta-sdk-python bug: `Policy.from_dict` uses a + # discriminator dispatch that maps `type` → concrete Policy + # subclass, and `USER_LIFECYCLE` is not in the map. The SDK + # raises ValueError ("failed to lookup discriminator value") + # even though the API returns a valid policy. Fall back to + # raw JSON. Remove once okta-sdk-python adds + # USER_LIFECYCLE → UserLifecyclePolicy to the mapping. + logger.warning( + f"Okta SDK raised {type(ex).__name__} parsing USER_LIFECYCLE " + "policies — falling back to raw-JSON parse. This is an " + "okta-sdk-python deserialization bug " + "(missing discriminator mapping)." + ) + return await self._fetch_automations_raw() + + if err is not None: + logger.error(f"Error listing USER_LIFECYCLE policies: {err}") + return result + + for policy in all_policies: + policy_id = getattr(policy, "id", "") or "" + if not policy_id: + continue + policy_status = _stringify_enum(getattr(policy, "status", None)) or "" + policy_name = getattr(policy, "name", "") or "" + rules = await self._fetch_rules(policy_id) + if rules is None: + # Rule typed parsing tripped an SDK validator. Re-run the + # whole automation discovery via raw JSON so we don't lose + # the rule data for this — or any other — policy. Cheaper + # than mixing typed and raw projections. + logger.warning( + f"Rule typed parsing failed for USER_LIFECYCLE policy " + f"{policy_id} — re-running all automations via raw-JSON." + ) + return await self._fetch_automations_raw() + if not rules: + # A policy with no rules exists in the Admin Console UI as + # an "Automation" the operator hasn't finished configuring + # (no conditions, no actions). Emit a placeholder so the + # check FAILs with a specific message naming every missing + # piece, instead of pretending the policy doesn't exist. + result[policy_id] = _shell_automation( + policy_id, policy_name, policy_status + ) + continue + for rule in rules: + automation = _rule_to_automation(rule, policy) + if automation is None: + continue + result[automation.id] = automation + return result + + async def _fetch_rules(self, policy_id: str) -> Optional[list]: + """Return the policy's typed rules, or None to signal raw fallback. + + The Okta SDK's `list_policy_rules` shares the same brittle typed + deserialization as `list_policies` (strict pydantic validators + rejecting values the API actually returns). When that happens the + caller can't reuse any of the typed projection for this policy — + we return None as a sentinel and the caller re-runs the whole + discovery via `_fetch_automations_raw`. Returning `[]` would + otherwise misclassify the policy as an "unfinished automation" + and FAIL it. + """ + rule_fetch_limit = 100 + try: + result = await self.client.list_policy_rules( + policy_id, limit=str(rule_fetch_limit) + ) + except (ValueError, ValidationError) as ex: + logger.warning( + f"Okta SDK raised {type(ex).__name__} parsing rules for " + f"USER_LIFECYCLE policy {policy_id} — signaling raw fallback." + ) + return None + err = result[-1] + if err is not None: + logger.error( + f"Error listing rules for USER_LIFECYCLE policy {policy_id}: {err}" + ) + return [] + rules = list(result[0] or []) + if len(rules) >= rule_fetch_limit: + logger.warning( + f"USER_LIFECYCLE policy {policy_id} returned {len(rules)} rules — " + f"the per-policy fetch limit ({rule_fetch_limit}) was hit; any " + "rules beyond this limit are not evaluated." + ) + return rules + + async def _fetch_automations_raw(self) -> dict: + """Raw-JSON fallback for `list_policies(type='USER_LIFECYCLE')`. + + Bypasses the SDK's typed deserialization via the shared + `get_json_paginated` helper, then drains each policy's rules + via the same path. Projects everything onto our `UserAutomation` + snapshot which only validates the fields the check reads. + """ + result: dict[str, UserAutomation] = {} + policies_data = await raw_get_json_paginated( + self.client, + f"/api/v1/policies?type={USER_LIFECYCLE_POLICY_TYPE}", + page_size=200, + context="USER_LIFECYCLE policies", + ) + if policies_data is None: + return result + + for policy_dict in policies_data: + if not isinstance(policy_dict, dict): + continue + policy_id = policy_dict.get("id") + if not policy_id: + continue + policy_status = (policy_dict.get("status") or "").upper() + policy_name = policy_dict.get("name") or "" + + rules_data = await raw_get_json_paginated( + self.client, + f"/api/v1/policies/{policy_id}/rules", + page_size=100, + context=f"USER_LIFECYCLE policy {policy_id} rules", + ) + if not rules_data: + # No rules under the policy → emit placeholder. Same + # rationale as the typed path: surface the unfinished + # automation so the check can name what's missing. + result[policy_id] = _shell_automation( + policy_id, policy_name, policy_status + ) + continue + for rule_dict in rules_data: + automation = _raw_rule_to_automation( + rule_dict, policy_dict, policy_id, policy_name, policy_status + ) + if automation is None: + continue + result[automation.id] = automation + return result + + def _list_external_directory_idps(self) -> dict: + logger.info("User - Listing Okta IdPs for external-directory detection...") + try: + return self._run(self._fetch_external_directory_idps()) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return {} + + async def _fetch_external_directory_idps(self) -> dict: + result: dict[str, ExternalDirectoryIdp] = {} + all_idps, err = await paginate( + lambda after: self.client.list_identity_providers(after=after) + ) + if err is not None: + logger.error(f"Error listing identity providers: {err}") + return result + + for idp in all_idps: + idp_type = _stringify_enum(getattr(idp, "type", None)) or "" + if idp_type.upper() not in EXTERNAL_DIRECTORY_IDP_TYPES: + continue + idp_status = _stringify_enum(getattr(idp, "status", None)) or "" + if idp_status.upper() != "ACTIVE": + continue + idp_id = getattr(idp, "id", "") or "" + if not idp_id: + continue + result[idp_id] = ExternalDirectoryIdp( + id=idp_id, + name=getattr(idp, "name", "") or "", + type=idp_type, + status=idp_status, + ) + return result + + +def _rule_to_automation(rule, policy) -> Optional["UserAutomation"]: + """Project a typed USER_LIFECYCLE policy + rule pair onto our snapshot. + + Important: in the actual API response, an Okta "Automation" is split + across two resources — the **inactivity condition + group scope** + live on the *policy* (`policy.conditions.people.users.inactivity`, + `policy.conditions.people.groups.include`), and the **lifecycle + action** lives on the *rule* (`rule.actions.user_lifecycle.action` + on the typed model; `updateUserLifecycle.targetStatus` on raw JSON). + The rule's own `conditions` is typically empty. Projecting requires + both — kept aligned with `_raw_rule_to_automation` so the two paths + yield identical snapshots. + """ + rule_id = getattr(rule, "id", "") or "" + if not rule_id: + return None + + policy_id = getattr(policy, "id", "") or "" + policy_name = getattr(policy, "name", "") or "" + policy_status = (_stringify_enum(getattr(policy, "status", None)) or "").upper() + + # Inactivity + groups live on the POLICY in the API response. + inactivity_days: Optional[int] = None + applies_to_groups: list[str] = [] + conditions = getattr(policy, "conditions", None) + people = getattr(conditions, "people", None) if conditions else None + users = getattr(people, "users", None) if people else None + inactivity = getattr(users, "inactivity", None) if users else None + if inactivity is not None: + number = getattr(inactivity, "number", None) + unit = (_stringify_enum(getattr(inactivity, "unit", None)) or "").upper() + if isinstance(number, int) and unit in {"DAYS", "DAY"}: + inactivity_days = number + groups = getattr(people, "groups", None) if people else None + include_groups = getattr(groups, "include", None) if groups else None + if include_groups: + applies_to_groups = [str(g) for g in include_groups if g] + + # Lifecycle action lives on the RULE. + actions = getattr(rule, "actions", None) + user_lifecycle = ( + getattr(actions, "user_lifecycle", None) if actions else None + ) or (getattr(actions, "userLifecycle", None) if actions else None) + lifecycle_action: Optional[str] = None + if user_lifecycle is not None: + for attr in ("action", "status"): + value = _stringify_enum(getattr(user_lifecycle, attr, None)) + if value: + lifecycle_action = value.upper() + break + + rule_name = getattr(rule, "name", "") or policy_name or "(unnamed)" + rule_status = _stringify_enum(getattr(rule, "status", None)) or "" + + return UserAutomation( + id=rule_id, + name=rule_name, + status=rule_status.upper(), + schedule_status=policy_status, + inactivity_days=inactivity_days, + lifecycle_action=lifecycle_action, + applies_to_groups=applies_to_groups, + policy_id=policy_id, + policy_name=policy_name, + ) + + +def _raw_rule_to_automation( + rule_dict, + policy_dict, + policy_id: str, + policy_name: str, + policy_status: str, +) -> Optional["UserAutomation"]: + """Project a raw USER_LIFECYCLE policy+rule pair onto our snapshot. + + Important: in the actual API response, an Okta "Automation" is split + across two resources — the **inactivity condition + group scope** + live on the *policy* (`policy.conditions.people.users.inactivity`, + `policy.conditions.people.groups.include`), and the **lifecycle + action** lives on the *rule* + (`rule.actions.updateUserLifecycle.targetStatus`). The rule's own + `conditions` is typically empty `{}`. Projecting requires both. + + Schedule isn't exposed by the API on either resource. Okta runs an + automation on its UI-configured schedule iff the policy is ACTIVE, + so we treat `policy.status` as the schedule proxy. + """ + if not isinstance(rule_dict, dict): + return None + rule_id = rule_dict.get("id") + if not rule_id: + return None + + # Inactivity + groups live on the POLICY in the API response. + inactivity_days: Optional[int] = None + applies_to_groups: list[str] = [] + if isinstance(policy_dict, dict): + policy_conditions = policy_dict.get("conditions") or {} + people = policy_conditions.get("people") or {} + users = people.get("users") or {} + inactivity = users.get("inactivity") + if isinstance(inactivity, dict): + number = inactivity.get("number") + unit = (inactivity.get("unit") or "").upper() + if isinstance(number, int) and unit in {"DAYS", "DAY"}: + inactivity_days = number + groups = people.get("groups") or {} + include_groups = groups.get("include") + if isinstance(include_groups, list): + applies_to_groups = [str(g) for g in include_groups if g] + + # Lifecycle action lives on the RULE under + # `actions.updateUserLifecycle.targetStatus` (the API uses + # "updateUserLifecycle" rather than the SDK's `user_lifecycle`). + rule_actions = rule_dict.get("actions") or {} + update_user_lifecycle = rule_actions.get("updateUserLifecycle") or {} + lifecycle_action: Optional[str] = None + if isinstance(update_user_lifecycle, dict): + target = update_user_lifecycle.get("targetStatus") + if isinstance(target, str) and target: + lifecycle_action = target.upper() + + return UserAutomation( + id=rule_id, + name=(rule_dict.get("name") or policy_name or "(unnamed)"), + status=(rule_dict.get("status") or "").upper(), + schedule_status=policy_status, + inactivity_days=inactivity_days, + lifecycle_action=lifecycle_action, + applies_to_groups=applies_to_groups, + policy_id=policy_id, + policy_name=policy_name, + ) + + +def _shell_automation( + policy_id: str, policy_name: str, policy_status: str +) -> "UserAutomation": + """Placeholder UserAutomation for a USER_LIFECYCLE policy with no rules. + + Surfaces the unfinished automation in `self.automations` so the check + can list every missing piece in its FAIL message (no inactivity + condition, no lifecycle action, status inactive, etc.) instead of + silently dropping the policy. + """ + upper_status = (policy_status or "").upper() + return UserAutomation( + id=policy_id, + name=policy_name or "(unnamed automation)", + status=upper_status, + schedule_status=upper_status, + inactivity_days=None, + lifecycle_action=None, + applies_to_groups=[], + policy_id=policy_id, + policy_name=policy_name, + ) + + +def _stringify_enum(value) -> Optional[str]: + if value is None: + return None + return getattr(value, "value", None) or str(value) + + +class UserAutomation(BaseModel): + id: str + name: str = "" + status: str = "" + schedule_status: str = "" + inactivity_days: Optional[int] = None + lifecycle_action: Optional[str] = None + applies_to_groups: list[str] = [] + policy_id: str = "" + policy_name: str = "" + + +class ExternalDirectoryIdp(BaseModel): + id: str + name: str = "" + type: str = "" + status: str = "" diff --git a/prowler/providers/oraclecloud/services/identity/identity_storage_service_level_admins_scoped/__init__.py b/prowler/providers/oraclecloud/services/identity/identity_storage_service_level_admins_scoped/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/oraclecloud/services/identity/identity_storage_service_level_admins_scoped/identity_storage_service_level_admins_scoped.metadata.json b/prowler/providers/oraclecloud/services/identity/identity_storage_service_level_admins_scoped/identity_storage_service_level_admins_scoped.metadata.json new file mode 100644 index 0000000000..4e8f905658 --- /dev/null +++ b/prowler/providers/oraclecloud/services/identity/identity_storage_service_level_admins_scoped/identity_storage_service_level_admins_scoped.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "oraclecloud", + "CheckID": "identity_storage_service_level_admins_scoped", + "CheckTitle": "OCI IAM storage service-level admin policies exclude delete permissions", + "CheckType": [], + "ServiceName": "identity", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Policy", + "ResourceGroup": "IAM", + "Description": "**OCI IAM policies** are reviewed to ensure storage service-level administrator statements that grant `manage` permissions exclude the relevant storage delete permissions with `request.permission`. This supports CIS OCI 3.1 control 1.15 separation of duties for Block Volume, File Storage, and Object Storage administrators.", + "Risk": "Storage service-level administrators with unrestricted `manage` permissions can delete the resources they administer, including volumes, backups, file systems, mount targets, export sets, objects, or buckets. This weakens separation of duties and can lead to data loss, service disruption, or destructive insider activity.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Identity/policyreference/policyreference.htm", + "https://docs.oracle.com/en-us/iaas/Content/Block/home.htm", + "https://docs.oracle.com/en-us/iaas/Content/File/home.htm", + "https://docs.oracle.com/en-us/iaas/Content/Object/home.htm" + ], + "Remediation": { + "Code": { + "CLI": "oci iam policy update --policy-id --statements \"[\\\"Allow group VolumeUsers to manage volumes in tenancy where request.permission!='VOLUME_DELETE'\\\"]\"", + "NativeIaC": "", + "Other": "1. In OCI Console, go to Identity & Security > Policies\n2. Open each active policy that grants storage service-level administrators `manage` permissions\n3. Edit storage manage statements to exclude the relevant delete permission with `request.permission`\n4. Example: Allow group BucketUsers to manage buckets in tenancy where request.permission!='BUCKET_DELETE'\n5. Save changes", + "Terraform": "```hcl\nresource \"oci_identity_policy\" \"storage_admins\" {\n compartment_id = var.compartment_id\n name = \"storage-admins\"\n description = \"Storage administrators without delete permissions\"\n\n statements = [\n \"Allow group VolumeUsers to manage volumes in tenancy where request.permission!='VOLUME_DELETE'\",\n \"Allow group VolumeUsers to manage volume-backups in tenancy where request.permission!='VOLUME_BACKUP_DELETE'\",\n \"Allow group FileUsers to manage file-systems in tenancy where request.permission!='FILE_SYSTEM_DELETE'\",\n \"Allow group FileUsers to manage mount-targets in tenancy where request.permission!='MOUNT_TARGET_DELETE'\",\n \"Allow group FileUsers to manage export-sets in tenancy where request.permission!='EXPORT_SET_DELETE'\",\n \"Allow group BucketUsers to manage objects in tenancy where request.permission!='OBJECT_DELETE'\",\n \"Allow group BucketUsers to manage buckets in tenancy where request.permission!='BUCKET_DELETE'\"\n ]\n}\n```" + }, + "Recommendation": { + "Text": "Exclude delete permissions from storage service-level administrator policies. Use `request.permission!='VOLUME_DELETE'`, `request.permission!='VOLUME_BACKUP_DELETE'`, `request.permission!='FILE_SYSTEM_DELETE'`, `request.permission!='MOUNT_TARGET_DELETE'`, `request.permission!='EXPORT_SET_DELETE'`, `request.permission!='OBJECT_DELETE'`, and `request.permission!='BUCKET_DELETE'` as appropriate for each storage manage statement.", + "Url": "https://hub.prowler.com/check/identity_storage_service_level_admins_scoped" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/oraclecloud/services/identity/identity_storage_service_level_admins_scoped/identity_storage_service_level_admins_scoped.py b/prowler/providers/oraclecloud/services/identity/identity_storage_service_level_admins_scoped/identity_storage_service_level_admins_scoped.py new file mode 100644 index 0000000000..49ef16748a --- /dev/null +++ b/prowler/providers/oraclecloud/services/identity/identity_storage_service_level_admins_scoped/identity_storage_service_level_admins_scoped.py @@ -0,0 +1,176 @@ +"""Check storage service-level administrators cannot delete managed resources.""" + +import re + +from prowler.lib.check.models import Check, Check_Report_OCI +from prowler.providers.oraclecloud.services.identity.identity_client import ( + identity_client, +) + +STORAGE_DELETE_PERMISSIONS_BY_RESOURCE = { + "volumes": {"VOLUME_DELETE"}, + "volume-backups": {"VOLUME_BACKUP_DELETE"}, + "file-systems": {"FILE_SYSTEM_DELETE"}, + "mount-targets": {"MOUNT_TARGET_DELETE"}, + "export-sets": {"EXPORT_SET_DELETE"}, + "objects": {"OBJECT_DELETE"}, + "buckets": {"BUCKET_DELETE"}, + "volume-family": {"VOLUME_DELETE", "VOLUME_BACKUP_DELETE"}, + "file-family": {"FILE_SYSTEM_DELETE", "MOUNT_TARGET_DELETE", "EXPORT_SET_DELETE"}, + "object-family": {"OBJECT_DELETE", "BUCKET_DELETE"}, +} +ALL_STORAGE_DELETE_PERMISSIONS = set().union( + *STORAGE_DELETE_PERMISSIONS_BY_RESOURCE.values() +) +STORAGE_DELETE_PERMISSIONS_BY_RESOURCE["all-resources"] = ALL_STORAGE_DELETE_PERMISSIONS + +MANAGE_STATEMENT_PATTERN = re.compile( + r"\ballow\s+group\b.+?\bto\s+manage\s+(?P[a-z-]+)\b", + re.IGNORECASE, +) +QUOTED_LITERAL_PATTERN = re.compile(r"'(?:\\.|[^'\\])*'|\"(?:\\.|[^\"\\])*\"") + + +def _normalize_statement(statement: str) -> str: + """Collapse whitespace in an OCI policy statement.""" + return " ".join(statement.strip().split()) + + +def _has_disjunctive_condition(statement: str) -> bool: + """Return True when the WHERE condition can allow alternate branches.""" + condition = re.split(r"\bwhere\b", statement, flags=re.IGNORECASE, maxsplit=1) + if len(condition) != 2: + return False + + condition_without_literals = QUOTED_LITERAL_PATTERN.sub("", condition[1]) + return bool( + re.search(r"\b(any|or)\b|\|\|", condition_without_literals, re.IGNORECASE) + ) + + +def _storage_manage_resource(statement: str) -> str | None: + """Return the managed storage resource in a policy statement, if any.""" + normalized_statement = _normalize_statement(statement) + match = MANAGE_STATEMENT_PATTERN.search(normalized_statement) + if not match: + return None + + resource = match.group("resource").lower() + if resource not in STORAGE_DELETE_PERMISSIONS_BY_RESOURCE: + return None + + return resource + + +def _excluded_permissions(statement: str) -> set[str]: + """Return delete permissions explicitly excluded with request.permission != value.""" + if _has_disjunctive_condition(statement): + return set() + + exclusions = set() + for permission in ALL_STORAGE_DELETE_PERMISSIONS: + pattern = re.compile( + rf"\brequest\.permission\s*!=\s*['\"]?{re.escape(permission)}['\"]?\b", + re.IGNORECASE, + ) + if pattern.search(statement): + exclusions.add(permission) + return exclusions + + +def _missing_delete_exclusions(statement: str) -> tuple[str, set[str]] | None: + """Return the storage resource and missing delete exclusions for a statement.""" + normalized_statement = _normalize_statement(statement) + resource = _storage_manage_resource(normalized_statement) + if not resource: + return None + + required_permissions = STORAGE_DELETE_PERMISSIONS_BY_RESOURCE[resource] + + excluded_permissions = _excluded_permissions(normalized_statement) + missing_permissions = required_permissions - excluded_permissions + if not missing_permissions: + return None + + return resource, missing_permissions + + +class identity_storage_service_level_admins_scoped(Check): + """Ensure storage service-level admins cannot delete resources they manage.""" + + def execute(self) -> list[Check_Report_OCI]: + """Execute the storage service-level administrators scoped check. + + Returns: + A list of OCI check reports for active non-tenant-admin policies. + """ + findings = [] + + for policy in identity_client.policies: + if policy.lifecycle_state != "ACTIVE": + continue + + if policy.name.upper() == "TENANT ADMIN POLICY": + continue + + region = policy.region if hasattr(policy, "region") else "global" + violations = [] + has_storage_manage_statement = False + + for statement in policy.statements: + if _storage_manage_resource(statement): + has_storage_manage_statement = True + + missing_result = _missing_delete_exclusions(statement) + if not missing_result: + continue + + resource, missing_permissions = missing_result + violations.append( + f"statement `{_normalize_statement(statement)}` manages {resource} without excluding: {', '.join(sorted(missing_permissions))}" + ) + + if not has_storage_manage_statement: + continue + + report = Check_Report_OCI( + metadata=self.metadata(), + resource=policy, + region=region, + resource_id=policy.id, + resource_name=policy.name, + compartment_id=policy.compartment_id, + ) + + if violations: + report.status = "FAIL" + report.status_extended = ( + f"Policy '{policy.name}' allows storage service-level administrators to manage storage resources without explicitly excluding required delete permissions: " + + "; ".join(violations) + + "." + ) + else: + report.status = "PASS" + report.status_extended = f"Policy '{policy.name}' excludes required storage delete permissions from storage manage statements." + + findings.append(report) + + if not findings: + region = ( + identity_client.audited_regions[0].key + if identity_client.audited_regions + else "global" + ) + report = Check_Report_OCI( + metadata=self.metadata(), + resource={}, + region=region, + resource_id=identity_client.audited_tenancy, + resource_name="Tenancy", + compartment_id=identity_client.audited_tenancy, + ) + report.status = "PASS" + report.status_extended = "No active storage service-level administrator policies grant manage permissions without excluding delete permissions." + findings.append(report) + + return findings diff --git a/prowler/providers/stackit/services/objectstorage/__init__.py b/prowler/providers/stackit/services/objectstorage/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/__init__.py b/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration.metadata.json b/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration.metadata.json new file mode 100644 index 0000000000..38da16c5fa --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "stackit", + "CheckID": "objectstorage_access_key_expiration", + "CheckTitle": "ObjectStorage access keys should have an expiration date", + "CheckType": [], + "ServiceName": "objectstorage", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "**ObjectStorage access keys** should have an explicit expiration date. Long-lived credentials increase the blast radius of a credential compromise because they cannot expire on their own. Setting an expiration date enforces periodic rotation and limits the exposure window if a key is leaked.", + "Risk": "If an **ObjectStorage access key** is leaked, stolen, or forgotten without an expiration date, it remains usable indefinitely. An attacker can retain persistent access to object storage resources until the key is manually revoked.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.stackit.cloud/products/storage/object-storage/", + "https://docs.stackit.cloud/products/storage/object-storage/how-tos/create-and-delete-object-storage-credentials/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. In the STACKIT Portal navigate to Object Storage > Access Keys. 2. Delete the non-expiring access key. 3. Create a new access key with an expiration date appropriate for your rotation policy (e.g. 90 days). 4. Update all applications and services that use the old key with the new credentials.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Create **ObjectStorage access keys** with an explicit expiration date and establish a rotation process. Delete non-expiring keys and replace them with time-limited credentials. A rotation period of **90 days or less** is recommended.", + "Url": "https://hub.prowler.com/check/objectstorage_access_key_expiration" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Access keys are scoped to credentials groups. This check evaluates all access keys across all credentials groups in the project." +} diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration.py b/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration.py new file mode 100644 index 0000000000..2fb49ac725 --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration.py @@ -0,0 +1,27 @@ +from prowler.lib.check.models import Check, CheckReportStackIT +from prowler.providers.stackit.services.objectstorage.objectstorage_client import ( + objectstorage_client, +) + + +class objectstorage_access_key_expiration(Check): + def execute(self): + findings = [] + for key in objectstorage_client.access_keys: + report = CheckReportStackIT( + metadata=self.metadata(), + resource=key, + ) + report.resource_id = key.key_id + report.resource_name = key.display_name + report.location = key.region + + if key.has_expiration(): + report.status = "PASS" + report.status_extended = f"Access key {key.display_name} has an expiration date set ({key.expires})." + else: + report.status = "FAIL" + report.status_extended = f"Access key {key.display_name} has no expiration date and never rotates." + + findings.append(report) + return findings diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/__init__.py b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled.metadata.json b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled.metadata.json new file mode 100644 index 0000000000..cd587c72da --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "stackit", + "CheckID": "objectstorage_bucket_object_lock_enabled", + "CheckTitle": "ObjectStorage buckets should have S3 Object Lock enabled", + "CheckType": [], + "ServiceName": "objectstorage", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "storage", + "Description": "**S3 Object Lock** prevents objects from being deleted or overwritten for a fixed period or indefinitely. Enabling it protects against accidental deletion and ransomware by enforcing a **write-once-read-many (WORM)** model. Object Lock can only be enabled when the bucket is created.", + "Risk": "Without **Object Lock**, objects can be deleted or overwritten at any time, increasing the risk of data loss from accidental deletion, malicious actors, or ransomware. Backups and compliance data are particularly vulnerable.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.stackit.cloud/products/storage/object-storage/", + "https://docs.stackit.cloud/products/storage/object-storage/how-tos/object-lock-bucket/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "Object Lock must be enabled at bucket creation time and cannot be enabled on an existing bucket. Create a new bucket with Object Lock enabled and migrate your data to it.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Create **ObjectStorage buckets** with S3 Object Lock enabled for workloads that require data immutability, compliance archiving, or ransomware protection. Object Lock cannot be retroactively enabled on existing buckets.", + "Url": "https://hub.prowler.com/check/objectstorage_bucket_object_lock_enabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Object Lock can only be activated at bucket creation. Buckets without Object Lock are not necessarily misconfigured — evaluate based on the sensitivity and compliance requirements of the stored data." +} diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled.py b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled.py new file mode 100644 index 0000000000..89a3c1d3fb --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled.py @@ -0,0 +1,31 @@ +from prowler.lib.check.models import Check, CheckReportStackIT +from prowler.providers.stackit.services.objectstorage.objectstorage_client import ( + objectstorage_client, +) + + +class objectstorage_bucket_object_lock_enabled(Check): + def execute(self): + findings = [] + for bucket in objectstorage_client.buckets: + report = CheckReportStackIT( + metadata=self.metadata(), + resource=bucket, + ) + report.resource_id = bucket.name + report.resource_name = bucket.name + report.location = bucket.region + + if bucket.object_lock_enabled: + report.status = "PASS" + report.status_extended = ( + f"Bucket {bucket.name} has S3 Object Lock enabled." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Bucket {bucket.name} does not have S3 Object Lock enabled." + ) + + findings.append(report) + return findings diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/__init__.py b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy.metadata.json b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy.metadata.json new file mode 100644 index 0000000000..44088e5889 --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "stackit", + "CheckID": "objectstorage_bucket_retention_policy", + "CheckTitle": "ObjectStorage buckets should have a default retention policy configured", + "CheckType": [], + "ServiceName": "objectstorage", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "storage", + "Description": "An **ObjectStorage default retention policy** automatically applies a minimum retention period to every object uploaded to the bucket, preventing deletion or overwriting before the period expires. Without it, objects can be removed immediately after upload, undermining compliance and data durability requirements.", + "Risk": "Buckets without a **default retention policy** offer no automatic protection against premature object deletion. Compliance data, audit logs, and backups may be deleted before their required retention period elapses.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.stackit.cloud/products/storage/object-storage/", + "https://docs.stackit.cloud/products/storage/object-storage/how-tos/object-lock-default-retention/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "Use the STACKIT Object Storage API or Portal to set a default retention policy on the bucket. Choose COMPLIANCE mode for strict immutability or GOVERNANCE mode to allow privileged users to override the policy.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure a **default retention policy** on every bucket that stores compliance-relevant or sensitive data. Choose `COMPLIANCE` mode for regulatory requirements and `GOVERNANCE` mode when administrative overrides are acceptable.", + "Url": "https://hub.prowler.com/check/objectstorage_bucket_retention_policy" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [ + "objectstorage_bucket_object_lock_enabled" + ], + "Notes": "A default retention policy requires Object Lock to be enabled on the bucket. Buckets without Object Lock cannot have a retention policy." +} diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy.py b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy.py new file mode 100644 index 0000000000..a9dbc7ed2b --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy.py @@ -0,0 +1,30 @@ +from prowler.lib.check.models import Check, CheckReportStackIT +from prowler.providers.stackit.services.objectstorage.objectstorage_client import ( + objectstorage_client, +) + + +class objectstorage_bucket_retention_policy(Check): + def execute(self): + findings = [] + for bucket in objectstorage_client.buckets: + report = CheckReportStackIT( + metadata=self.metadata(), + resource=bucket, + ) + report.resource_id = bucket.name + report.resource_name = bucket.name + report.location = bucket.region + + if bucket.retention_days and bucket.retention_days > 0: + report.status = "PASS" + report.status_extended = ( + f"Bucket {bucket.name} has a default retention policy of " + f"{bucket.retention_days} day(s) in {bucket.retention_mode} mode." + ) + else: + report.status = "FAIL" + report.status_extended = f"Bucket {bucket.name} does not have a default retention policy configured." + + findings.append(report) + return findings diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_client.py b/prowler/providers/stackit/services/objectstorage/objectstorage_client.py new file mode 100644 index 0000000000..56cdbfd0bf --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.stackit.services.objectstorage.objectstorage_service import ( + ObjectStorageService, +) + +objectstorage_client = ObjectStorageService(Provider.get_global_provider()) diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_service.py b/prowler/providers/stackit/services/objectstorage/objectstorage_service.py new file mode 100644 index 0000000000..e37545ec24 --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_service.py @@ -0,0 +1,306 @@ +import json +from datetime import datetime, timezone +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 + + +class ObjectStorageService: + def __init__(self, provider: StackitProvider): + self.provider = provider + self.project_id = provider.identity.project_id + self.regional_clients = provider.generate_regional_clients("objectstorage") + + self.buckets: list[Bucket] = [] + self.access_keys: list[AccessKey] = [] + + self._fetch_all_regions() + + def _fetch_all_regions(self): + for region, client in self.regional_clients.items(): + try: + self._list_buckets(client, region) + self._list_access_keys(client, region) + except Exception as error: + if getattr(error, "status", None) == 404: + logger.info( + f"StackIT project {self.project_id} has no ObjectStorage " + f"presence in region {region}; skipping." + ) + continue + raise + + def _handle_api_call(self, api_function, *args, **kwargs): + try: + with suppress_stderr(): + return api_function(*args, **kwargs) + except Exception as e: + self.provider.handle_api_error(e) + raise + + def _list_buckets(self, client, region: str): + response = self._handle_api_call( + client.list_buckets, project_id=self.project_id, region=region + ) + + buckets_list = getattr(response, "buckets", None) or [] + if isinstance(response, dict): + buckets_list = response.get("buckets", []) + + for bucket_data in buckets_list: + try: + if hasattr(bucket_data, "name"): + name = bucket_data.name + object_lock_enabled = getattr( + bucket_data, "object_lock_enabled", False + ) + elif isinstance(bucket_data, dict): + name = bucket_data.get("name", "") + object_lock_enabled = bucket_data.get("objectLockEnabled", False) + else: + continue + except Exception as e: + logger.error(f"Error processing bucket: {e}") + continue + + retention_days, retention_mode = self._get_default_retention( + client, region, name + ) + + self.buckets.append( + Bucket( + name=name, + region=region, + project_id=self.project_id, + object_lock_enabled=object_lock_enabled, + retention_days=retention_days, + retention_mode=retention_mode, + ) + ) + + logger.info(f"Listed {len(buckets_list)} buckets in {region}") + + def _get_default_retention( + self, client, region: str, bucket_name: str + ) -> tuple[Optional[int], Optional[str]]: + try: + response = self._handle_api_call( + client.get_default_retention, + project_id=self.project_id, + region=region, + bucket_name=bucket_name, + ) + days = getattr(response, "days", None) + mode = getattr(response, "mode", None) + if isinstance(response, dict): + days = response.get("days") + mode = response.get("mode") + return days, str(mode) if mode else None + except Exception as e: + if getattr(e, "status", None) == 404: + return None, None + raise + + def _list_access_keys(self, client, region: str): + credentials_groups_response = self._handle_api_call( + client.list_credentials_groups, project_id=self.project_id, region=region + ) + + credentials_groups = ( + getattr(credentials_groups_response, "credentials_groups", None) or [] + ) + if isinstance(credentials_groups_response, dict): + credentials_groups = credentials_groups_response.get( + "credentialsGroups", + credentials_groups_response.get("credentials_groups", []), + ) + + total_keys = 0 + + for credentials_group_data in credentials_groups: + try: + if isinstance(credentials_group_data, dict): + credentials_group_id = credentials_group_data.get( + "id", + credentials_group_data.get( + "groupId", + credentials_group_data.get("credentialsGroupId", ""), + ), + ) + credentials_group_name = credentials_group_data.get( + "displayName", + credentials_group_data.get("name", credentials_group_id), + ) + else: + credentials_group_id = ( + getattr(credentials_group_data, "id", None) + or getattr(credentials_group_data, "group_id", None) + or getattr(credentials_group_data, "credentials_group_id", "") + ) + credentials_group_name = getattr( + credentials_group_data, + "display_name", + getattr(credentials_group_data, "name", credentials_group_id), + ) + except Exception as e: + logger.error(f"Error processing credentials group: {e}") + continue + + if not credentials_group_id: + continue + + response = self._list_access_keys_response( + client, region, credentials_group_id + ) + keys_list = self._extract_access_keys(response) + + for key_data in keys_list: + try: + if hasattr(key_data, "key_id"): + key_id = key_data.key_id + display_name = getattr(key_data, "display_name", key_id) + expires = getattr(key_data, "expires", None) + elif isinstance(key_data, dict): + key_id = key_data.get("keyId", key_data.get("key_id", "")) + display_name = key_data.get( + "displayName", key_data.get("display_name", key_id) + ) + expires = key_data.get("expires") + else: + continue + + if not key_id: + continue + + self.access_keys.append( + AccessKey( + key_id=key_id, + display_name=display_name, + expires=expires, + region=region, + project_id=self.project_id, + credentials_group_id=credentials_group_id, + credentials_group_name=credentials_group_name, + ) + ) + except Exception as e: + logger.error(f"Error processing access key: {e}") + continue + + total_keys += len(keys_list) + + logger.info(f"Listed {total_keys} access keys in {region}") + + def _list_access_keys_response( + self, client, region: str, credentials_group_id: str + ): + raw_method = None + if callable( + getattr(type(client), "list_access_keys_without_preload_content", None) + ): + raw_method = client.list_access_keys_without_preload_content + elif callable(vars(client).get("list_access_keys_without_preload_content")): + raw_method = vars(client)["list_access_keys_without_preload_content"] + + if raw_method: + response = self._handle_api_call( + raw_method, + project_id=self.project_id, + region=region, + credentials_group=credentials_group_id, + ) + self._raise_for_raw_response_status(response) + return response + + return self._handle_api_call( + client.list_access_keys, + project_id=self.project_id, + region=region, + credentials_group=credentials_group_id, + ) + + def _raise_for_raw_response_status(self, response): + status = getattr(response, "status", None) + if status is None: + status = getattr(response, "status_code", None) + if isinstance(status, int) and status >= 400: + error = Exception( + f"StackIT ObjectStorage list_access_keys failed with status {status}" + ) + error.status = status + self.provider.handle_api_error(error) + raise error + + @staticmethod + def _extract_access_keys(response) -> list: + payload = response + if not isinstance(payload, (dict, list)): + json_method = getattr(response, "json", None) + if callable(json_method): + payload = json_method() + elif hasattr(response, "data"): + payload = ObjectStorageService._parse_raw_json(response.data) + elif hasattr(response, "text"): + payload = ObjectStorageService._parse_raw_json(response.text) + + if isinstance(payload, dict): + return payload.get("accessKeys", payload.get("access_keys", [])) + if isinstance(payload, list): + return payload + return getattr(response, "access_keys", None) or [] + + @staticmethod + def _parse_raw_json(raw): + if raw in (None, b"", ""): + return {} + if isinstance(raw, (bytes, bytearray)): + raw = raw.decode("utf-8") + if isinstance(raw, str): + return json.loads(raw) + return raw + + +class Bucket(BaseModel): + name: str + region: str + project_id: str + object_lock_enabled: bool = False + retention_days: Optional[int] = None + retention_mode: Optional[str] = None + + +class AccessKey(BaseModel): + key_id: str + display_name: str + # None or a sentinel year-0001 date string means the key never expires. + expires: Optional[str] = None + region: str + project_id: str + credentials_group_id: Optional[str] = None + credentials_group_name: Optional[str] = None + + def has_expiration(self) -> bool: + """Return True if the key has a real (non-sentinel) expiration date.""" + if not self.expires: + return False + try: + expires_str = self.expires.replace("Z", "+00:00") + dt = datetime.fromisoformat(expires_str) + # Year 0001 (or earlier) is the SDK sentinel for "never expires" + return dt.year > 1 + except (ValueError, AttributeError): + return False + + def expires_within_days(self, days: int) -> bool: + """Return True if the key expires within the given number of days from now.""" + if not self.has_expiration(): + return False + expires_str = self.expires.replace("Z", "+00:00") + dt = datetime.fromisoformat(expires_str) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + delta = dt - datetime.now(tz=timezone.utc) + return delta.days <= days diff --git a/prowler/providers/stackit/stackit_provider.py b/prowler/providers/stackit/stackit_provider.py index 417555ef1d..4c34afd31b 100644 --- a/prowler/providers/stackit/stackit_provider.py +++ b/prowler/providers/stackit/stackit_provider.py @@ -15,6 +15,7 @@ from colorama import Style # loader and surfacing as a misleading empty report. 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 prowler.config.config import ( @@ -224,11 +225,17 @@ class StackitProvider(Provider): return json_regions.intersection(audited_regions) return json_regions + _SERVICE_API_CLASS = { + "iaas": IaasDefaultApi, + "objectstorage": ObjectStorageDefaultApi, + } + def generate_regional_clients(self, service: str = "iaas") -> dict: """Generate regional API clients for the given service. Returns dict: {"eu01": DefaultApi_client, "eu02": DefaultApi_client} """ + api_class = self._SERVICE_API_CLASS.get(service, IaasDefaultApi) regional_clients = {} service_regions = self.get_available_service_regions( service, self._audited_regions @@ -240,7 +247,7 @@ class StackitProvider(Provider): self._service_account_key_path, self._service_account_key, ) - client = IaasDefaultApi(config) + client = api_class(config) client.region = region # Attach region attribute regional_clients[region] = client diff --git a/prowler/providers/stackit/stackit_regions_by_service.json b/prowler/providers/stackit/stackit_regions_by_service.json index d9c8cdb9be..5841d4e32d 100644 --- a/prowler/providers/stackit/stackit_regions_by_service.json +++ b/prowler/providers/stackit/stackit_regions_by_service.json @@ -5,6 +5,12 @@ "eu01", "eu02" ] + }, + "objectstorage": { + "regions": [ + "eu01", + "eu02" + ] } } } diff --git a/pyproject.toml b/pyproject.toml index a87e200e4b..f3cec52678 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ requires = ["hatchling"] [dependency-groups] dev = [ "bandit==1.8.3", - "black==25.1.0", + "black==26.3.1", "coverage==7.6.12", "docker==7.1.0", "filelock==3.20.3", @@ -17,7 +17,7 @@ dev = [ "openapi-spec-validator==0.7.1", "prek==0.3.9", "pylint==3.3.4", - "pytest==8.3.5", + "pytest==9.0.3", "pytest-cov==6.0.0", "pytest-env==1.1.5", "pytest-randomly==3.16.0", @@ -32,10 +32,10 @@ classifiers = [ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12" + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13" ] dependencies = [ - "awsipranges==0.3.3", "alive-progress==3.3.0", "azure-identity==1.21.0", "azure-keyvault-keys==4.10.0", @@ -73,15 +73,15 @@ dependencies = [ "dash-bootstrap-components==2.0.3", "defusedxml==0.7.1", "detect-secrets==1.5.0", - "dulwich==0.23.0", + "dulwich==1.2.5", "google-api-python-client==2.163.0", "google-auth-httplib2==0.2.0", "jsonschema==4.23.0", "kubernetes==32.0.1", "markdown==3.10.2", - "microsoft-kiota-abstractions==1.9.2", + "microsoft-kiota-abstractions==1.9.9", + "numpy==2.2.6", "msgraph-sdk==1.55.0", - "numpy==2.0.2", "okta==3.4.2", "openstacksdk==4.2.0", "pandas==2.2.3", @@ -95,11 +95,12 @@ dependencies = [ "slack-sdk==3.39.0", "stackit-core==0.2.0", "stackit-iaas==1.4.0", + "stackit-objectstorage==1.4.0", "stackit-resourcemanager==0.8.0", "tabulate==0.9.0", "tzlocal==5.3.1", "uuid6==2024.7.10", - "py-iam-expand==0.1.0", + "py-iam-expand==0.3.0", "h2==4.3.0", "oci==2.169.0", "alibabacloud_credentials==1.0.3", @@ -122,8 +123,8 @@ license = "Apache-2.0" maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}] name = "prowler" readme = "README.md" -requires-python = ">=3.10,<3.13" -version = "5.30.0" +requires-python = ">=3.10,<3.14" +version = "5.31.0" [project.scripts] prowler = "prowler.__main__:prowler" @@ -161,7 +162,7 @@ constraint-dependencies = [ "aenum==3.1.17", "aiofiles==24.1.0", "aiohappyeyeballs==2.6.1", - "aiohttp==3.13.5", + "aiohttp==3.14.0", "aiosignal==1.4.0", "alibabacloud-actiontrail20200706==2.4.1", "alibabacloud-credentials==1.0.3", @@ -204,7 +205,7 @@ constraint-dependencies = [ "azure-core==1.41.0", "azure-mgmt-core==1.6.0", "bandit==1.8.3", - "black==25.1.0", + "black==26.3.1", "blinker==1.9.0", "certifi==2026.4.22", "cffi==2.0.0", @@ -266,12 +267,12 @@ constraint-dependencies = [ "markupsafe==3.0.3", "mccabe==0.7.0", "mdurl==0.1.2", - "microsoft-kiota-authentication-azure==1.9.2", - "microsoft-kiota-http==1.9.2", - "microsoft-kiota-serialization-form==1.9.2", - "microsoft-kiota-serialization-json==1.9.2", - "microsoft-kiota-serialization-multipart==1.9.2", - "microsoft-kiota-serialization-text==1.9.2", + "microsoft-kiota-authentication-azure==1.9.9", + "microsoft-kiota-http==1.9.9", + "microsoft-kiota-serialization-form==1.9.9", + "microsoft-kiota-serialization-json==1.9.9", + "microsoft-kiota-serialization-multipart==1.9.9", + "microsoft-kiota-serialization-text==1.9.9", "mock==5.2.0", "moto==5.1.11", "mpmath==1.3.0", @@ -314,16 +315,17 @@ constraint-dependencies = [ "pydash==8.0.6", "pyflakes==3.2.0", "pygments==2.20.0", - "pyjwt==2.12.1", + "pyjwt==2.13.0", "pylint==3.3.4", "pynacl==1.6.2", "pyopenssl==26.2.0", "pyparsing==3.3.2", - "pytest==8.3.5", + "pytest==9.0.3", "pytest-cov==6.0.0", "pytest-env==1.1.5", "pytest-randomly==3.16.0", "pytest-xdist==3.6.1", + "pytokens==0.4.1", "pywin32==311", "pyyaml==6.0.3", "referencing==0.36.2", @@ -363,3 +365,13 @@ constraint-dependencies = [ "zstd==1.5.7.3" ] override-dependencies = ["okta==3.4.2"] + +[tool.vulture] +# Suppress known false positives. The CI command only passes --exclude and +# --min-confidence on the CLI, so ignore_names from here still applies (vulture +# only overrides the keys set on the CLI). +# - mock_* : pytest fixtures injected as test params but unused in the body +# (e.g. mock_sensitive_args in tests/lib/cli/redact_test.py) +# - view : DRF BasePermission.has_object_permission(self, request, view, obj) +# framework-required signature param in skills/django-drf template assets +ignore_names = ["mock_*", "view"] diff --git a/scripts/development/dev-local.sh b/scripts/development/dev-local.sh new file mode 100755 index 0000000000..d193b81532 --- /dev/null +++ b/scripts/development/dev-local.sh @@ -0,0 +1,558 @@ +#!/usr/bin/env bash +# +# Local dev for Prowler API + worker. +# Postgres / Valkey / Neo4j run in Docker via docker-compose-dev.yml; +# Django and Celery run natively. +# +# Quick start: +# make dev-setup first-time bootstrap (deps, migrations, fixtures) +# make dev launch api + worker + postgres logs in tmux +# make dev-attach attach to the tmux session in the current terminal pane +# make dev-launch use fixed ports and attach interactive local dev +# make dev-stop stop everything: tmux + stop + remove containers +# make dev-clean remove stopped containers only (data preserved) +# make dev-wipe full nuke: kill + clean + delete ./_data/ +# +# Agent / non-interactive usage: 'make dev' is idempotent, runs +# everything detached, blocks until the API answers HTTP, and ends with +# parseable key=value lines (api_url=, api_log=, worker_log=, +# postgres_log=, attach_cmd=, stop_cmd=). +# Every pane is teed ANSI-stripped to _data/logs/{api,worker,postgres}.log +# (truncated per run), so logs are readable with tail -f, no tmux needed. +# +# 'attach' attaches to tmux inside the current terminal pane. +# +# Inside the tmux session "prowler-dev-" the prefix key is Ctrl+b. After it: +# d detach (everything keeps running; reattach: make dev-attach) +# move between panes +# z zoom current pane (toggle) +# [ scrollback mode (q to exit) +# x kill current pane (asks for confirmation) +# & kill current window +# :kill-session end the whole session +# +# How to stop everything from inside tmux: +# 1) Ctrl+b then d detach back to your shell +# 2) make dev-stop tears down tmux + containers +# Alternative without detaching: open a new tmux window with Ctrl+b then c +# and run make dev-stop there; the session will close itself. +# +# Stop just the python procs (keep containers up to skip a slow neo4j boot next time): +# ./scripts/development/dev-local.sh down +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +cd "$REPO_ROOT" + +path_hash() { + if command -v shasum >/dev/null 2>&1; then + printf '%s' "$REPO_ROOT" | shasum | awk '{print substr($1, 1, 8)}' + else + printf '%s' "$REPO_ROOT" | cksum | awk '{print $1}' + fi +} + +compose_project_default() { + local base hash + base="$(basename "$REPO_ROOT" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]+/-/g; s/^-+//; s/-+$//')" + hash="$(path_hash)" + printf '%s-%s' "${base:-prowler}" "$hash" +} + +export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-$(compose_project_default)}" + +if [ -f .env ]; then + set -a + # shellcheck disable=SC1091 + . ./.env + set +a +fi + +export POSTGRES_HOST=localhost +export POSTGRES_PORT="${POSTGRES_PORT:-5432}" +export VALKEY_HOST=localhost +export VALKEY_PORT="${VALKEY_PORT:-6379}" +export VALKEY_SCHEME="${VALKEY_SCHEME:-redis}" +export NEO4J_HOST=localhost +export NEO4J_PORT="${NEO4J_PORT:-7687}" +export NEO4J_HTTP_PORT="${NEO4J_HTTP_PORT:-7474}" +export DJANGO_SETTINGS_MODULE=config.django.devel +export DJANGO_DEBUG="${DJANGO_DEBUG:-True}" +export DJANGO_PORT="${DJANGO_PORT:-8080}" +export DJANGO_LOGGING_FORMATTER="${DJANGO_LOGGING_FORMATTER:-human_readable}" +export DJANGO_LOGGING_LEVEL="${DJANGO_LOGGING_LEVEL:-info}" +export DJANGO_MANAGE_DB_PARTITIONS="${DJANGO_MANAGE_DB_PARTITIONS:-False}" + +if docker compose version >/dev/null 2>&1; then + COMPOSE=(docker compose) +else + COMPOSE=(docker-compose) +fi +COMPOSE_FILE="docker-compose-dev.yml" + +log() { printf '\033[1;34m→\033[0m %s\n' "$*"; } +ok() { printf '\033[1;32m✓\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m!\033[0m %s\n' "$*"; } + +require_uv() { + if ! command -v uv >/dev/null 2>&1; then + warn "uv not found in PATH. Install: https://docs.astral.sh/uv/getting-started/installation/" + exit 1 + fi +} + +services_up() { + log "Starting postgres + valkey + neo4j via Docker (waits for healthchecks)..." + "${COMPOSE[@]}" -f "$COMPOSE_FILE" up -d --wait postgres valkey neo4j + ok "Services ready: pg:${POSTGRES_PORT} / valkey:${VALKEY_PORT} / neo4j:${NEO4J_PORT} / neo4j-http:${NEO4J_HTTP_PORT}" +} + +kill_tmux_session() { + command -v tmux >/dev/null 2>&1 || return 0 + if tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then + log "Killing tmux session '$TMUX_SESSION'" + tmux kill-session -t "$TMUX_SESSION" + fi +} + +tmux_pane_count() { + tmux list-panes -t "$TMUX_SESSION:" 2>/dev/null | wc -l | tr -d ' ' +} + +# Tee a tmux pane's output to _data/logs/.log so non-interactive +# consumers (scripts, agents) can follow the stack without attaching. +# ANSI codes are stripped; the file is truncated on session creation so +# its content always matches the current run. +pipe_pane_log() { + local pane="$1" name="$2" + local logfile="$REPO_ROOT/_data/logs/$name.log" + : > "$logfile" + tmux pipe-pane -t "$pane" -o "perl -pe 'BEGIN{\$|=1} s/\\e\\[[0-9;?]*[A-Za-z]//g' >> '$logfile'" +} + +# Stop native dev processes (api/worker/beat) launched outside tmux. Scoped by +# cwd under REPO_ROOT so other prowler clones running their own stacks are not +# touched. +kill_native_procs() { + command -v pgrep >/dev/null 2>&1 || return 0 + command -v lsof >/dev/null 2>&1 || return 0 + + local pids="" pid pcwd pat + for pat in "manage.py runserver" "celery -A config.celery worker" "celery -A config.celery beat"; do + while IFS= read -r pid; do + [ -n "$pid" ] || continue + pcwd="$(lsof -a -d cwd -p "$pid" 2>/dev/null | awk 'NR>1 {print $NF; exit}')" + case "$pcwd" in + "$REPO_ROOT"|"$REPO_ROOT"/*) pids="$pids $pid" ;; + esac + done < <(pgrep -f "$pat" 2>/dev/null) + done + + pids="$(printf '%s\n' "$pids" | tr ' ' '\n' | sed '/^$/d' | sort -u | xargs)" + [ -z "$pids" ] && return 0 + + log "Stopping native dev procs ($pids) under $REPO_ROOT" + # shellcheck disable=SC2086 + kill -TERM $pids 2>/dev/null || true + sleep 0.5 + # shellcheck disable=SC2086 + kill -KILL $pids 2>/dev/null || true +} + +services_down() { + kill_tmux_session + kill_native_procs + log "Stopping postgres + valkey + neo4j..." + "${COMPOSE[@]}" -f "$COMPOSE_FILE" stop postgres valkey neo4j +} + +services_status() { + "${COMPOSE[@]}" -f "$COMPOSE_FILE" ps postgres valkey neo4j +} + +deps() { + require_uv + log "uv sync (api/)..." + (cd api && uv sync) +} + +migrate() { + require_uv + log "Applying migrations (admin DB)..." + ( + cd api/src/backend + uv run python manage.py check_and_fix_socialaccount_sites_migration --database admin + uv run python manage.py migrate --database admin + ) + ok "Migrations applied" +} + +fixtures() { + require_uv + log "Loading dev fixtures..." + ( + cd api/src/backend + for fixture in api/fixtures/dev/*.json; do + [ -f "$fixture" ] || continue + echo " loading $(basename "$fixture")" + uv run python manage.py loaddata "$fixture" --database admin + done + ) + ok "Fixtures loaded" +} + +api_run() { + require_uv + log "Starting Django API on [::]:${DJANGO_PORT} (IPv4 + IPv6 dual-stack)" + cd api/src/backend + exec uv run python manage.py runserver "[::]:${DJANGO_PORT}" +} + +worker_run() { + require_uv + log "Starting Celery worker" + cd api/src/backend + exec uv run python -m celery -A config.celery worker \ + -l "${DJANGO_LOGGING_LEVEL}" \ + -Q celery,scans,scan-reports,deletion,backfill,overview,integrations,compliance,attack-paths-scans \ + -E +} + +beat_run() { + require_uv + log "Starting Celery beat (DatabaseScheduler)" + cd api/src/backend + exec uv run python -m celery -A config.celery beat \ + -l "${DJANGO_LOGGING_LEVEL}" \ + --scheduler django_celery_beat.schedulers:DatabaseScheduler +} + +TMUX_SESSION="prowler-dev-${COMPOSE_PROJECT_NAME}" +SCRIPT_PATH="$REPO_ROOT/scripts/development/dev-local.sh" + +require_tmux() { + if ! command -v tmux >/dev/null 2>&1; then + warn "tmux not installed. Run: brew install tmux" + exit 1 + fi +} + +remove_repo_compose_containers() { + command -v docker >/dev/null 2>&1 || return 0 + local ids + ids="$( + docker ps -aq 2>/dev/null \ + | while IFS= read -r id; do + docker inspect --format '{{.ID}} {{index .Config.Labels "com.docker.compose.project.working_dir"}}' "$id" 2>/dev/null || true + done \ + | awk -v repo="$REPO_ROOT" '$2 == repo {print $1}' + )" + [ -n "$ids" ] || return 0 + warn "Removing existing Docker containers for this repo before launch" + # shellcheck disable=SC2086 + docker rm -f $ids >/dev/null +} + +remove_docker_containers_on_ports() { + command -v docker >/dev/null 2>&1 || return 0 + local all_ids ids_to_remove="" id port published_ports + all_ids="$(docker ps -aq 2>/dev/null || true)" + [ -n "$all_ids" ] || return 0 + + for id in $all_ids; do + published_ports="$(docker inspect --format '{{range $containerPort, $bindings := .HostConfig.PortBindings}}{{range $bindings}}{{.HostPort}}{{"\n"}}{{end}}{{end}}' "$id" 2>/dev/null || true)" + [ -n "$published_ports" ] || continue + for port in "$@"; do + if printf '%s\n' "$published_ports" | grep -qx "$port"; then + ids_to_remove="$ids_to_remove $id" + break + fi + done + done + + ids_to_remove="$(printf '%s\n' "$ids_to_remove" | tr ' ' '\n' | sed '/^$/d' | sort -u | xargs)" + [ -n "$ids_to_remove" ] || return 0 + warn "Removing Docker containers publishing fixed dev ports: $ids_to_remove" + # shellcheck disable=SC2086 + docker rm -f $ids_to_remove >/dev/null +} + +kill_listeners_on_ports() { + command -v lsof >/dev/null 2>&1 || return 0 + local pids="" port port_pids + for port in "$@"; do + port_pids="$(lsof -nP -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null || true)" + [ -n "$port_pids" ] && pids="$pids $port_pids" + done + + pids="$(printf '%s\n' "$pids" | tr ' ' '\n' | sed '/^$/d' | sort -u | xargs)" + [ -n "$pids" ] || return 0 + warn "Killing local processes listening on fixed dev ports: $pids" + # shellcheck disable=SC2086 + kill -TERM $pids 2>/dev/null || true + sleep 0.5 + # shellcheck disable=SC2086 + kill -KILL $pids 2>/dev/null || true +} + +clear_dev_port_conflicts() { + local ports=("$DJANGO_PORT" "$POSTGRES_PORT" "$VALKEY_PORT" "$NEO4J_PORT" "$NEO4J_HTTP_PORT") + log "Ensuring fixed dev ports are free: api:${DJANGO_PORT} pg:${POSTGRES_PORT} valkey:${VALKEY_PORT} neo4j:${NEO4J_PORT} neo4j-http:${NEO4J_HTTP_PORT}" + remove_docker_containers_on_ports "${ports[@]}" + kill_listeners_on_ports "${ports[@]}" +} + +# Block until the API answers HTTP on DJANGO_PORT (any status code counts). +wait_for_api() { + local timeout="${1:-90}" waited=0 + log "Waiting for API on :${DJANGO_PORT} (timeout ${timeout}s)..." + until curl -s -o /dev/null --max-time 2 "http://localhost:${DJANGO_PORT}/"; do + waited=$((waited + 1)) + if [ "$waited" -ge "$timeout" ]; then + warn "API not responding after ${timeout}s. Check _data/logs/api.log" + return 1 + fi + sleep 1 + done + ok "API responding on :${DJANGO_PORT}" +} + +needs_db_bootstrap() { + ! "${COMPOSE[@]}" -f "$COMPOSE_FILE" exec -T postgres \ + psql -U prowler -d prowler_db -c 'select 1' >/dev/null 2>&1 +} + +bootstrap_db_if_needed() { + if needs_db_bootstrap; then + warn "App DB user not ready. Bootstrapping (deps + migrate + fixtures)..." + deps + migrate + fixtures + ok "DB bootstrap complete" + fi +} + +all_run() { + require_tmux + require_uv + services_up + bootstrap_db_if_needed + + # Wrap a command so it auto-runs as the pane's foreground process and, on + # exit (e.g. Ctrl+C), drops into the user's login shell instead of closing. + # Avoids the `send-keys` race where keys arrive before zsh+starship are ready. + local user_shell="${SHELL:-/bin/zsh}" + pane_cmd() { + printf 'bash -c %q' "$1; exec ${user_shell}" + } + local api_cmd worker_cmd pg_cmd + api_cmd="$(pane_cmd "$SCRIPT_PATH api")" + worker_cmd="$(pane_cmd "$SCRIPT_PATH worker")" + pg_cmd="$(pane_cmd "${COMPOSE[*]} -f $COMPOSE_FILE logs -f postgres")" + + # If a tmux server is already running (e.g. another project's session), new + # sessions inherit THAT server's env, not the launcher shell's env. Pass our + # overrides explicitly so each pane sees the right project name, ports, etc. + local -a env_args=() + local var + for var in COMPOSE_PROJECT_NAME \ + POSTGRES_HOST POSTGRES_PORT \ + VALKEY_HOST VALKEY_PORT VALKEY_SCHEME \ + NEO4J_HOST NEO4J_PORT NEO4J_HTTP_PORT \ + DJANGO_SETTINGS_MODULE DJANGO_DEBUG DJANGO_PORT \ + DJANGO_LOGGING_FORMATTER DJANGO_LOGGING_LEVEL \ + DJANGO_MANAGE_DB_PARTITIONS; do + env_args+=(-e "${var}=${!var-}") + done + + local expected_panes=3 + + if tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then + local current_panes + current_panes="$(tmux_pane_count)" + if [ "$current_panes" -lt "$expected_panes" ]; then + warn "Session '$TMUX_SESSION' has $current_panes pane(s), expected $expected_panes. Rebuilding it." + tmux kill-session -t "$TMUX_SESSION" + else + log "Session '$TMUX_SESSION' already exists" + fi + fi + + if ! tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then + log "Creating tmux session '$TMUX_SESSION' (api / worker / db logs)" + tmux new-session -d -s "$TMUX_SESSION" -n services -c "$REPO_ROOT" "${env_args[@]}" "$api_cmd" + tmux split-window -t "$TMUX_SESSION:0.0" -v -p 50 -c "$REPO_ROOT" "${env_args[@]}" "$worker_cmd" + tmux split-window -t "$TMUX_SESSION:0.1" -h -p 50 -c "$REPO_ROOT" "${env_args[@]}" "$pg_cmd" + tmux select-pane -t "$TMUX_SESSION:0.0" + tmux set-option -t "$TMUX_SESSION" -g mouse on >/dev/null + tmux set-option -t "$TMUX_SESSION" -g bell-action none >/dev/null + tmux set-option -t "$TMUX_SESSION" -g visual-bell off >/dev/null + tmux set-option -t "$TMUX_SESSION" -g monitor-bell off >/dev/null + tmux set-option -t "$TMUX_SESSION" -g monitor-activity off >/dev/null + tmux set-option -t "$TMUX_SESSION" -g visual-activity off >/dev/null + tmux set-option -t "$TMUX_SESSION" -g activity-action none >/dev/null + tmux set-option -t "$TMUX_SESSION" -g silence-action none >/dev/null + tmux bind-key -T copy-mode MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy" 2>/dev/null + tmux bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy" 2>/dev/null + tmux set-option -t "$TMUX_SESSION" -g status-right " API:${DJANGO_PORT} | DB:${POSTGRES_PORT} | Broker:${VALKEY_PORT} " >/dev/null + + mkdir -p "$REPO_ROOT/_data/logs" + pipe_pane_log "$TMUX_SESSION:0.0" api + pipe_pane_log "$TMUX_SESSION:0.1" worker + pipe_pane_log "$TMUX_SESSION:0.2" postgres + fi + + wait_for_api 90 + + ok "Dev stack ready (detached)" + cat </dev/null; then + warn "No session '$TMUX_SESSION'. Starting it with: $0 all" + all_run + else + local current_panes + current_panes="$(tmux_pane_count)" + if [ "$current_panes" -lt 3 ]; then + warn "Session '$TMUX_SESSION' only has $current_panes pane(s). Rebuilding with db logs." + tmux kill-session -t "$TMUX_SESSION" + all_run + fi + fi + if [ -n "${TMUX:-}" ]; then + tmux switch-client -t "$TMUX_SESSION" + else + tmux attach-session -t "$TMUX_SESSION" + fi +} + +clean_run() { + log "Removing stopped containers from the compose project..." + "${COMPOSE[@]}" -f "$COMPOSE_FILE" rm -f + ok "Stopped containers removed (data volumes under ./_data/ untouched)" +} + +wipe_run() { + warn "DESTRUCTIVE: this will tear down everything AND delete ./_data/" + warn " postgres database, valkey state, neo4j graph, api jwt keys - all gone." + warn " Next start will require 'make dev-setup' from scratch." + kill_run + if [ -d ./_data ]; then + log "Removing ./_data/ ..." + rm -rf ./_data + fi + ok "Wipe complete. Run 'make dev-setup' for a fresh environment." +} + +kill_run() { + kill_tmux_session + services_down + clean_run + ok "Dev stack stopped (tmux killed, containers stopped + removed)" +} + +launch_run() { + require_uv + kill_tmux_session + remove_repo_compose_containers + clear_dev_port_conflicts + + log "Launching dev stack in tmux inside the current terminal" + log " api:${DJANGO_PORT} pg:${POSTGRES_PORT} valkey:${VALKEY_PORT} neo4j:${NEO4J_PORT} neo4j-http:${NEO4J_HTTP_PORT}" + all_run + attach_run +} + +setup() { + services_up + deps + migrate + fixtures + ok "Setup complete." + cat < + +One-window dev (tmux inside the terminal): + all api + worker + postgres logs in 3 panes (detached, + blocks until API responds, + ends with parseable api_url= / *_log= / attach_cmd= / stop_cmd= lines) + attach Reattach to the existing dev session + Attaches tmux inside the current terminal pane. + Pane output is also written to _data/logs/{api,worker,postgres}.log + (ANSI-stripped, truncated per run) - usable without attaching + kill Stop tmux + stop containers + remove them (full teardown) + launch Use fixed ports, clear conflicts, then run 'all' and attach tmux + +Platform support: + macOS Supported + Linux Should work when Docker, tmux, and uv are available + Windows Requires script changes before it can be supported + +State (containers postgres + valkey + neo4j): + up Start postgres + valkey + neo4j (waits for healthchecks) + down Stop tmux + postgres + valkey + neo4j (keeps containers around) + status Show container status + clean Remove all stopped containers in the project (data volumes preserved) + wipe Full nuke: kill + clean + delete ./_data/ + +Python (native, foreground - usually launched via 'all'): + api Run Django API (runserver) + worker Run Celery worker + beat Run Celery beat scheduler + +One-shots: + setup up + deps + migrate + fixtures (first-time / fresh DB) + deps uv sync inside api/ + migrate Apply migrations to admin DB + fixtures Load dev fixtures + +Typical flow: + make dev-setup # first time only + make dev # daily dev + make dev-stop # when done +EOF +} + +case "${1:-help}" in + all) shift; if [ "$#" -gt 0 ]; then warn "'all $*' is not supported. Use: $0 all"; exit 1; fi; all_run ;; + attach) attach_run ;; + kill) kill_run ;; + launch) launch_run ;; + clean) clean_run ;; + wipe) wipe_run ;; + up) services_up ;; + down) services_down ;; + status) services_status ;; + api) api_run ;; + worker) worker_run ;; + beat) beat_run ;; + setup) setup ;; + deps) deps ;; + migrate) migrate ;; + fixtures) fixtures ;; + help|-h|--help|*) usage ;; +esac diff --git a/skills/README.md b/skills/README.md index 47bf7e5562..cfc65527a9 100644 --- a/skills/README.md +++ b/skills/README.md @@ -23,12 +23,12 @@ Run the setup script to configure skills for all supported AI coding assistants: This creates symlinks so each tool finds skills in its expected location: -| Tool | Symlink Created | -|------|-----------------| -| Claude Code / OpenCode | `.claude/skills/` | -| Codex (OpenAI) | `.codex/skills/` | -| GitHub Copilot | `.github/skills/` | -| Gemini CLI | `.gemini/skills/` | +| Tool | Created by setup | +|------|------------------| +| Claude Code | `.claude/skills/` symlink and `CLAUDE.md` | +| Gemini CLI | `.gemini/skills/` symlink and `GEMINI.md` | +| Codex (OpenAI) | `.codex/skills/` symlink (uses `AGENTS.md` natively) | +| GitHub Copilot | `.github/copilot-instructions.md` symlink to `AGENTS.md` | After running setup, restart your AI coding assistant to load the skills. diff --git a/skills/prowler-tour/SKILL.md b/skills/prowler-tour/SKILL.md new file mode 100644 index 0000000000..8511581a47 --- /dev/null +++ b/skills/prowler-tour/SKILL.md @@ -0,0 +1,99 @@ +--- +name: prowler-tour +description: > + Keeps product-tour definitions aligned with the UI features they describe. + Trigger: When modifying UI components that have associated tours, editing tour + definition files, or renaming data-tour-id attributes. +license: Apache-2.0 +metadata: + author: prowler-cloud + version: "1.0" + scope: [root, ui] + auto_invoke: + - "Editing a UI file containing data-tour-id attributes" + - "Adding, updating, or removing a tour definition (*.tour.ts)" + - "Renaming or removing a data-tour-id attribute value" + - "Changing button labels or section headings on a tour-covered page" + - "Restructuring routes or layouts covered by a tour" +allowed-tools: Read, Glob, Grep +--- + +# prowler-tour + +**Report-only.** This skill never edits tour files or UI files; it inspects +the change, reports drift it finds between tours and the covered UI, and +recommends actions for the developer to apply. + +## Early-exit rule + +Run this check first. Most UI edits are not tour-related — exit cheaply. + +1. Glob `ui/lib/tours/*.tour.ts`. +2. For each tour, check whether any `coversFiles` glob pattern matches any + file in the current change. +3. If no tour matches, respond **exactly**: + + > No tour affected — skipping alignment check + + and exit. Do not proceed to the checklist. +4. If at least one tour matches, continue to "Drift checklist" for that tour. + +## Drift checklist + +For each affected tour, evaluate every item. Skip items that obviously do +not apply, but list explicitly which items were checked. + +1. **Orphan selectors** — every step's `target` (which composes to + `data-tour-id="-"`) must resolve to a real element + in the codebase. Grep `ui/` for the expected attribute value; report + any step whose target is missing. +2. **Renamed selectors** — a `data-tour-id` attribute was edited in this + change. Match it back to any tour step referencing the old value. +3. **Outdated copy** — a popover `title`/`description` references a button + label, heading, or term that no longer exists on the covered page. +4. **Obsolete steps** — a step describes a section, panel, or workflow + that was removed. +5. **Missing steps** — a new feature was added on the covered surface + without a corresponding step (e.g. a new panel, a new primary action, + a new wizard stage). +6. **Reordered flow** — the user's path through the feature changed (e.g. + query builder moved before scan selection) and the step order no + longer reflects it. + +## Version-bump decision tree + +Apply per tour after listing drift: + +- **NO bump** when the change is cosmetic. Examples: fix a typo, soften + copy, rename a `data-tour-id` selector while keeping the same step, + swap one screenshot for another, tighten wording. +- **BUMP `version`** when the user-visible flow changes materially. + Examples: a new step was added or removed; the order changed; an + anchored target was retargeted to a different panel; the tour now + covers a new feature on the surface. + +When in doubt, ask: "Would a user who already saw the previous version +miss something useful by not seeing this one?" If yes, bump. + +## Output format + +When emitting a report, follow the exact structure in +`references/output-format.md`. The structure is mandatory because the +report is consumed downstream and tolerates no field reordering. + +## What this skill MUST NOT do + +- Do not edit `*.tour.ts` files. This skill is report-only. +- Do not edit UI files to add or rename `data-tour-id` attributes. +- Do not invent new tours. Authoring a new tour is a separate, deliberate + decision — the developer makes it, not the skill. +- Do not flag drift in tours whose `coversFiles` do not match any file + in the current change. Stick to the early-exit rule. + +## See also + +- `references/output-format.md` — exact report template (read when + emitting a report). +- `references/tours-architecture.md` — code map for the tour abstraction + under `ui/lib/tours/`. +- `assets/tour-template.ts` — boilerplate for authoring a new `*.tour.ts`. diff --git a/skills/prowler-tour/assets/tour-template.ts b/skills/prowler-tour/assets/tour-template.ts new file mode 100644 index 0000000000..cc264a4678 --- /dev/null +++ b/skills/prowler-tour/assets/tour-template.ts @@ -0,0 +1,51 @@ +// @ts-nocheck -- template only; resolves once copied into `ui/lib/tours/` +/** + * Tour template — copy this file to `ui/lib/tours/.tour.ts` and + * fill in the placeholders. See `references/tours-architecture.md` for the + * design context. + * + * Conventions: + * - Declare via `defineTour({...})` (NOT `: TourDefinition`) so TS + * preserves the literal union of `target` values. `useDriverTour` uses + * that union to validate `stepHandlers` keys and `waitForStep` args. + * - `id` is kebab-case and unique across all tours. + * - Anchored steps reference DOM via `data-tour-id="-"`; + * the hook composes the CSS selector automatically. + * - `coversFiles` lists the globs that describe the tour's surface; the + * `prowler-tour` skill consumes this to decide whether to evaluate + * drift on a given change. + * - Material flow changes bump `version`; cosmetic edits do not. + */ +import { + defineTour, + TOUR_STEP_ALIGNMENTS, + TOUR_STEP_SIDES, +} from "@/lib/tours/tour-types"; + +export const yourTour = defineTour({ + id: "your-tour-id", + version: 1, + coversFiles: [ + // List the UI files this tour describes, using globs under `ui/`. + // Example: "ui/app/(prowler)/your-feature/**" + ], + steps: [ + { + // Modal step — no anchor. Use for intros, outros, and any step + // that does not point at a specific DOM element. + title: "Welcome", + description: "Short, plain-English description.", + }, + { + // Anchored step. The hook resolves + // `[data-tour-id="your-tour-id-step-name"]` lazily, so the element + // can be conditionally rendered as long as it exists when the step + // becomes active. + target: "step-name", + side: TOUR_STEP_SIDES.BOTTOM, + align: TOUR_STEP_ALIGNMENTS.START, + title: "Where the action is", + description: "Tell the user what to look at here and why.", + }, + ], +}); diff --git a/skills/prowler-tour/references/output-format.md b/skills/prowler-tour/references/output-format.md new file mode 100644 index 0000000000..ecd82c4d8e --- /dev/null +++ b/skills/prowler-tour/references/output-format.md @@ -0,0 +1,31 @@ +# Tour Alignment Report — output format + +The report is consumed downstream. Field names, order, and headings are +load-bearing — do not rename, reorder, or omit them. + +## Template + +```text +## Tour Alignment Report +**Tour:** `@v` +**Files touched:** + +### Drift detected +- + +### Recommended actions +1. + +### Version bump verdict +- +``` + +## Rules + +- One report per affected tour. If multiple tours are affected, separate + reports with a `---` line. +- If no drift is detected for an affected tour, still emit the report: + put "No drift detected." under "Drift detected" and "None required." + under "Recommended actions". The verdict line is still mandatory. +- The verdict is exactly one of `BUMP` or `NO bump` — see the + version-bump decision tree in `SKILL.md`. diff --git a/skills/prowler-tour/references/tours-architecture.md b/skills/prowler-tour/references/tours-architecture.md new file mode 100644 index 0000000000..9a5c8f49e0 --- /dev/null +++ b/skills/prowler-tour/references/tours-architecture.md @@ -0,0 +1,44 @@ +# Tours Architecture + +The product-tour abstraction lives under [`ui/lib/tours/`](../../../ui/lib/tours/). +This skill operates on tour definitions that follow this architecture. + +## Code map + +| File | Purpose | +|---|---| +| `ui/lib/tours/tour-types.ts` | Public type surface: `TourDefinition`, `TourStep`, `TourId`, `TourCompletionRecord`, completion-state const map. Also exports `defineTour(...)` — the required authoring helper that preserves literal step `target`s so `useDriverTour` can type-check `stepHandlers` keys and `waitForStep` arguments. | +| `ui/lib/tours/tour-config.ts` | `baseDriverConfig`, `getDriverConfig(theme, overrides?)`, overlay-color map. | +| `ui/lib/tours/store/tour-completion-store.ts` | Persistence interface — the swap point for future API adapters. | +| `ui/lib/tours/store/local-storage-adapter.ts` | The only adapter in the PoC. Key format: `prowler.tour..v`. | +| `ui/lib/tours/use-driver-tour.ts` | React hook. Initializes driver.js, derives `overlayColor` from `useTheme()`, persists completion. | +| `ui/lib/tours/.tour.ts` | One file per tour. Declared via `defineTour({...})` (not `: TourDefinition`) and imported by the page that opts the user in. | +| `ui/styles/tours.css` | `.driver-popover.prowler-theme` — every color resolved via `var(--...)` from `globals.css`. | + +## Selector convention + +Tour steps anchor via `data-tour-id="-"`. The hook +composes the CSS selector at runtime; tour authors only provide the step +name in `step.target`. Class-based, ID-based, structural selectors are +forbidden — they couple tours to styling decisions that legitimately +change. + +## Identity and versioning + +A tour is `{ id, version }`. The localStorage key composes both. A +**material content change** bumps `version`; cosmetic edits do not. The +decision tree lives in the parent SKILL.md. + +## Persistence scope + +Per-user, cross-tenant. A user who completed `attack-paths@v1` in tenant +A does not see the tour again in tenant B, even if they can access the +feature there. The future `UserTourState` model (documented in +`design.md`, not built) is FK to `User`, not `Membership`. + +## Drift = #1 risk + +Without the maintenance skill + the optional CI gate +(`ui/scripts/check-tour-alignment.mjs`), tours decay silently as the +covered UI evolves. The parent SKILL.md enumerates the six drift +categories the skill checks for. diff --git a/tests/config/config_test.py b/tests/config/config_test.py index 1af6e32df2..2a7aecd330 100644 --- a/tests/config/config_test.py +++ b/tests/config/config_test.py @@ -471,6 +471,32 @@ class Test_Config: all_frameworks = get_available_compliance_frameworks() assert "csa_ccm_4.0" in all_frameworks + @mock.patch("prowler.config.config._get_ep_compliance_dirs") + def test_get_available_compliance_frameworks_dedupes_ep_collisions_with_builtins( + self, mock_dirs + ): + """Entry-point compliance frameworks that collide with a built-in + name must appear only once in the available frameworks list. + Built-in wins silently — same policy as the universal frameworks + loop and as Compliance.get_bulk.""" + import json + import tempfile + + with tempfile.TemporaryDirectory() as tmpdir: + # cis_2.0_aws ships as a built-in under prowler/compliance/aws/ + json_path = os.path.join(tmpdir, "cis_2.0_aws.json") + with open(json_path, "w") as f: + json.dump({"Framework": "CIS", "Provider": "aws"}, f) + + mock_dirs.return_value = {"aws": tmpdir} + + frameworks = get_available_compliance_frameworks("aws") + + assert frameworks.count("cis_2.0_aws") == 1, ( + f"Expected cis_2.0_aws to appear exactly once, got " + f"{frameworks.count('cis_2.0_aws')} occurrences in: {frameworks}" + ) + def test_load_and_validate_config_file_aws(self): path = pathlib.Path(os.path.dirname(os.path.realpath(__file__))) config_test_file = f"{path}/fixtures/config.yaml" @@ -508,6 +534,32 @@ class Test_Config: assert load_and_validate_config_file("azure", config_test_file) == {} assert load_and_validate_config_file("kubernetes", config_test_file) == {} + def test_load_and_validate_config_file_namespaced_non_listed_provider(self): + path = pathlib.Path(os.path.dirname(os.path.realpath(__file__))) + config_test_file = f"{path}/fixtures/config_namespaced_external.yaml" + # github is a built-in not in the legacy hardcoded list; namespaced format must unwrap it. + assert load_and_validate_config_file("github", config_test_file) == { + "token": "abc", + "org": "prowler-cloud", + } + + def test_load_and_validate_config_file_namespaced_external_provider(self): + path = pathlib.Path(os.path.dirname(os.path.realpath(__file__))) + config_test_file = f"{path}/fixtures/config_namespaced_external.yaml" + # External plug-in provider: namespaced format must unwrap its block. + assert load_and_validate_config_file("custom_plugin", config_test_file) == { + "setting": "value", + "nested": {"key": 42}, + } + + def test_load_and_validate_config_file_namespaced_missing_provider(self): + path = pathlib.Path(os.path.dirname(os.path.realpath(__file__))) + config_test_file = f"{path}/fixtures/config_namespaced_external.yaml" + # Provider with no section in a namespaced file must return empty config, + # not the full file (prevents cross-provider config leakage). + assert load_and_validate_config_file("aws", config_test_file) == {} + assert load_and_validate_config_file("gcp", config_test_file) == {} + def test_load_and_validate_config_file_invalid_config_file_path(self, caplog): provider = "aws" config_file_path = "invalid/path/to/fixer_config.yaml" diff --git a/tests/config/fixtures/config_namespaced_external.yaml b/tests/config/fixtures/config_namespaced_external.yaml new file mode 100644 index 0000000000..ec9f75c698 --- /dev/null +++ b/tests/config/fixtures/config_namespaced_external.yaml @@ -0,0 +1,8 @@ +# Namespaced config covering a non-listed built-in (github) and an external plugin. +github: + token: abc + org: prowler-cloud +custom_plugin: + setting: value + nested: + key: 42 diff --git a/tests/dashboard/__init__.py b/tests/dashboard/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/dashboard/common_methods_test.py b/tests/dashboard/common_methods_test.py new file mode 100644 index 0000000000..b2137589c5 --- /dev/null +++ b/tests/dashboard/common_methods_test.py @@ -0,0 +1,81 @@ +import pandas as pd +from dash import dash_table + +from dashboard.common_methods import get_section_containers_generic + + +def _datatable_column_ids(component): + """Collect the column ids of every DataTable in a Dash component tree.""" + if isinstance(component, dash_table.DataTable): + return [[c["id"] for c in component.columns]] + children = getattr(component, "children", None) + if children is None: + return [] + if not isinstance(children, (list, tuple)): + children = [children] + return [cols for child in children for cols in _datatable_column_ids(child)] + + +def _df(**extra): + data = { + "REQUIREMENTS_ID": ["req1"], + "STATUS": ["PASS"], + "CHECKID": ["check1"], + "REGION": ["us-east-1"], + "ACCOUNTID": ["123"], + "RESOURCEID": ["res1"], + } + data.update(extra) + return pd.DataFrame(data) + + +class TestGetSectionContainersGeneric: + def test_one_container_per_section(self): + """One outer container per distinct section value.""" + df = pd.DataFrame( + { + "REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A", "Sec A", "Sec B"], + "REQUIREMENTS_ID": ["req1", "req2", "req3"], + "STATUS": ["PASS", "FAIL", "PASS"], + "CHECKID": ["c1", "c2", "c3"], + "REGION": ["-"] * 3, + "ACCOUNTID": ["123"] * 3, + "RESOURCEID": ["r1", "r2", "r3"], + } + ) + result = get_section_containers_generic( + df, "REQUIREMENTS_ATTRIBUTES_SECTION", "REQUIREMENTS_ID" + ) + assert len(result.children) == 2 + + def test_inner_title_includes_id_and_description(self): + """Inner accordion title is ' - '.""" + df = _df( + REQUIREMENTS_ATTRIBUTES_SECTION=["Sec A"], + REQUIREMENTS_DESCRIPTION=["Ensure MFA"], + ) + rendered = str( + get_section_containers_generic( + df, "REQUIREMENTS_ATTRIBUTES_SECTION", "REQUIREMENTS_ID" + ) + ) + assert "req1 - Ensure MFA" in rendered + + def test_arbitrary_ids_do_not_crash(self): + """Non-numeric ids are sorted lexicographically without raising.""" + df = pd.DataFrame( + { + "REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A"] * 3, + "REQUIREMENTS_ID": ["AC-2(1)", "foo-bar", "step.1.2"], + "STATUS": ["PASS", "FAIL", "PASS"], + "CHECKID": ["c1", "c2", "c3"], + "REGION": ["-"] * 3, + "ACCOUNTID": ["123"] * 3, + "RESOURCEID": ["r1", "r2", "r3"], + } + ) + result = get_section_containers_generic( + df, "REQUIREMENTS_ATTRIBUTES_SECTION", "REQUIREMENTS_ID" + ) + tables = _datatable_column_ids(result) + assert tables and all("CHECKID" in cols for cols in tables) diff --git a/tests/dashboard/compliance/__init__.py b/tests/dashboard/compliance/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/dashboard/compliance/generic_test.py b/tests/dashboard/compliance/generic_test.py new file mode 100644 index 0000000000..4e36833ada --- /dev/null +++ b/tests/dashboard/compliance/generic_test.py @@ -0,0 +1,204 @@ +import pandas as pd +from dash import dash_table, html + +from dashboard.compliance.generic import get_table + + +def _make_minimal_df(**extra_cols): + """Create a minimal valid DataFrame for get_table tests.""" + data = { + "REQUIREMENTS_ID": ["req1"], + "STATUS": ["PASS"], + "CHECKID": ["check1"], + "REGION": ["us-east-1"], + "ACCOUNTID": ["123456789"], + "RESOURCEID": ["res1"], + } + data.update(extra_cols) + return pd.DataFrame(data) + + +def _datatable_column_ids(component): + """Collect the column ids of every DataTable in a Dash component tree.""" + if isinstance(component, dash_table.DataTable): + return [[c["id"] for c in component.columns]] + children = getattr(component, "children", None) + if children is None: + return [] + if not isinstance(children, (list, tuple)): + children = [children] + return [cols for child in children for cols in _datatable_column_ids(child)] + + +class TestGetTable: + def test_groups_by_section(self): + """SC-001a: df with REQUIREMENTS_ATTRIBUTES_SECTION returns Div grouped by section.""" + data = pd.DataFrame( + { + "REQUIREMENTS_ATTRIBUTES_SECTION": [ + "Section A", + "Section A", + "Section A", + "Section B", + "Section B", + ], + "REQUIREMENTS_ID": [ + "ctrl-alpha", + "ctrl-alpha", + "ctrl-alpha", + "ctrl-beta", + "ctrl-beta", + ], + "STATUS": ["PASS", "FAIL", "PASS", "FAIL", "FAIL"], + "CHECKID": ["check1", "check2", "check3", "check4", "check5"], + "REGION": ["us-east-1"] * 5, + "ACCOUNTID": ["123"] * 5, + "RESOURCEID": ["res1", "res2", "res3", "res4", "res5"], + } + ) + result = get_table(data) + assert isinstance(result, html.Div) + assert result.className == "compliance-data-layout" + assert len(result.children) == 2 # one container per distinct section + + def test_flat_fallback_no_attributes(self): + """SC-001b: No REQUIREMENTS_ATTRIBUTES_* cols → grouped by REQUIREMENTS_ID.""" + data = pd.DataFrame( + { + "REQUIREMENTS_ID": ["req1", "req1", "req2"], + "STATUS": ["PASS", "FAIL", "FAIL"], + "CHECKID": ["check1", "check2", "check3"], + "REGION": ["us-east-1"] * 3, + "ACCOUNTID": ["123"] * 3, + "RESOURCEID": ["res1", "res2", "res3"], + } + ) + result = get_table(data) + assert isinstance(result, html.Div) + assert result.className == "compliance-data-layout" + # 2 distinct REQUIREMENTS_ID values → 2 group containers + assert len(result.children) == 2 + + def test_arbitrary_ids_no_crash(self): + """ADR-2 / R1 regression guard: non-numeric REQUIREMENTS_IDs must not raise ValueError. + + get_section_containers_cis sorts by version_tuple which calls int() on each + dotted/dashed segment and crashes on IDs like 'AC-2(1)'. Selecting format4 + (no version sort) is the fix. This test is a permanent guard against regression. + """ + data = pd.DataFrame( + { + "REQUIREMENTS_ID": ["AC-2(1)", "foo-bar", "step.1.2"], + "STATUS": ["PASS", "FAIL", "PASS"], + "CHECKID": ["check1", "check2", "check3"], + "REGION": ["us-east-1"] * 3, + "ACCOUNTID": ["123"] * 3, + "RESOURCEID": ["res1", "res2", "res3"], + } + ) + # Must not raise ValueError + result = get_table(data) + assert isinstance(result, html.Div) + + def test_discovers_multiple_attribute_columns(self): + """SC-005a: Multiple REQUIREMENTS_ATTRIBUTES_* cols present → no AttributeError; + component tree is non-empty.""" + data = pd.DataFrame( + { + "REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A", "Sec B"], + "REQUIREMENTS_ATTRIBUTES_CATEGORY": ["Cat 1", "Cat 2"], + "REQUIREMENTS_ATTRIBUTES_CONTROL_ID": ["C1", "C2"], + "REQUIREMENTS_ID": ["req1", "req2"], + "STATUS": ["PASS", "FAIL"], + "CHECKID": ["check1", "check2"], + "REGION": ["us-east-1"] * 2, + "ACCOUNTID": ["123"] * 2, + "RESOURCEID": ["res1", "res2"], + } + ) + result = get_table(data) + assert isinstance(result, html.Div) + assert result.children # non-empty component tree + + def test_novel_attribute_column_names(self): + """SC-005b: Novel attr col names without a SECTION col → first attr col used as + grouping; returns a valid html.Div without any code change required.""" + data = pd.DataFrame( + { + "REQUIREMENTS_ATTRIBUTES_DOMAIN": ["Domain A", "Domain B"], + "REQUIREMENTS_ATTRIBUTES_SUBDOMAIN": ["Sub 1", "Sub 2"], + "REQUIREMENTS_ID": ["req1", "req2"], + "STATUS": ["PASS", "FAIL"], + "CHECKID": ["check1", "check2"], + "REGION": ["us-east-1"] * 2, + "ACCOUNTID": ["123"] * 2, + "RESOURCEID": ["res1", "res2"], + } + ) + result = get_table(data) + assert isinstance(result, html.Div) + assert len(result.children) > 0 + + def test_manual_only_requirements(self): + """SC-008a: All rows have STATUS='MANUAL' → returns html.Div with non-empty + children; result is not the 'No data found' string.""" + data = pd.DataFrame( + { + "REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A", "Sec B"], + "REQUIREMENTS_ID": ["req1", "req2"], + "STATUS": ["MANUAL", "MANUAL"], + "CHECKID": ["check1", "check2"], + "REGION": ["us-east-1"] * 2, + "ACCOUNTID": ["123"] * 2, + "RESOURCEID": ["res1", "res2"], + } + ) + result = get_table(data) + assert isinstance(result, html.Div) + assert not isinstance(result, str) + assert result.children # non-empty + + def test_empty_dataframe(self): + """SC-009a: Zero rows with correct column schema → valid html.Div; no exception.""" + data = pd.DataFrame( + { + "REQUIREMENTS_ATTRIBUTES_SECTION": pd.Series([], dtype=str), + "REQUIREMENTS_ID": pd.Series([], dtype=str), + "STATUS": pd.Series([], dtype=str), + "CHECKID": pd.Series([], dtype=str), + "REGION": pd.Series([], dtype=str), + "ACCOUNTID": pd.Series([], dtype=str), + "RESOURCEID": pd.Series([], dtype=str), + } + ) + result = get_table(data) + assert isinstance(result, html.Div) + + def test_get_table_returns_html_div(self): + """SC-012a: Smoke test — isinstance(get_table(df), html.Div) is True.""" + data = _make_minimal_df( + REQUIREMENTS_ATTRIBUTES_SECTION=["Sec A"], + ) + result = get_table(data) + assert isinstance(result, html.Div) + + +class TestNestedRendering: + def test_section_and_requirement_id_are_separate_levels(self): + """Section is the outer level; requirement id + description the inner.""" + data = _make_minimal_df( + REQUIREMENTS_ATTRIBUTES_SECTION=["3 Compute Services"], + REQUIREMENTS_DESCRIPTION=["Ensure only MFA enabled identities"], + ) + rendered = str(get_table(data)) + assert "3 Compute Services" in rendered + assert "req1 - Ensure only MFA enabled identities" in rendered + + def test_checks_table_is_nested_under_requirement(self): + """The checks table sits at the innermost level.""" + data = _make_minimal_df( + REQUIREMENTS_ATTRIBUTES_SECTION=["Sec A"], + REQUIREMENTS_DESCRIPTION=["Some requirement"], + ) + tables = _datatable_column_ids(get_table(data)) + assert tables and all("CHECKID" in cols for cols in tables) diff --git a/tests/dashboard/pages/__init__.py b/tests/dashboard/pages/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/dashboard/pages/compliance_dispatch_test.py b/tests/dashboard/pages/compliance_dispatch_test.py new file mode 100644 index 0000000000..78bd88ce68 --- /dev/null +++ b/tests/dashboard/pages/compliance_dispatch_test.py @@ -0,0 +1,179 @@ +from unittest.mock import MagicMock, patch + +import pandas as pd +import pytest +from dash import html + +from dashboard.pages.compliance import _dispatch_compliance_renderer + + +def _make_dispatch_df(**extra_cols): + """Minimal DataFrame with the columns required by the dedup step.""" + data = { + "REQUIREMENTS_ID": ["req1", "req2"], + "REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A", "Sec A"], + "STATUS": ["PASS", "FAIL"], + "CHECKID": ["check1", "check2"], + "RESOURCEID": ["res1", "res2"], + "STATUSEXTENDED": ["", ""], + "REGION": ["us-east-1", "us-east-1"], + "ACCOUNTID": ["123456789", "123456789"], + } + data.update(extra_cols) + return pd.DataFrame(data) + + +class TestDispatchComplianceRenderer: + def test_builtin_name_uses_builtin_module(self): + """SC-002a: analytics_input='cis_4_0_aws' resolves real builtin module; + returns (html.Div, DataFrame) 2-tuple.""" + data = pd.DataFrame( + { + "REQUIREMENTS_ID": ["1.1", "1.2"], + "REQUIREMENTS_DESCRIPTION": ["Description 1", "Description 2"], + "REQUIREMENTS_ATTRIBUTES_SECTION": ["Section A", "Section A"], + "CHECKID": ["check1", "check2"], + "STATUS": ["PASS", "FAIL"], + "REGION": ["us-east-1", "us-east-1"], + "ACCOUNTID": ["123456789", "123456789"], + "RESOURCEID": ["res1", "res2"], + "STATUSEXTENDED": ["Pass", "Fail"], + } + ) + table, result_data = _dispatch_compliance_renderer(data, "cis_4_0_aws") + assert isinstance(table, html.Div) + assert isinstance(result_data, pd.DataFrame) + + def test_unknown_name_falls_back_to_generic(self): + """SC-003a: Unknown analytics_input raises ModuleNotFoundError → generic + fallback is called with the deduped dataframe.""" + data = _make_dispatch_df() + sentinel = MagicMock( + return_value=html.Div([], className="compliance-data-layout") + ) + + with patch("dashboard.compliance.generic.get_table", sentinel): + table, result_data = _dispatch_compliance_renderer(data, "myfw_dynprovider") + + sentinel.assert_called_once() + assert isinstance(table, html.Div) + assert isinstance(result_data, pd.DataFrame) + + def test_import_error_is_not_swallowed(self): + """SC-003b: ImportError (NOT ModuleNotFoundError) is re-raised; except clause + is exact — only ModuleNotFoundError routes to generic.""" + data = _make_dispatch_df() + + with patch( + "dashboard.pages.compliance.importlib.import_module", + side_effect=ImportError("custom error"), + ): + with pytest.raises(ImportError, match="custom error"): + _dispatch_compliance_renderer(data, "anything") + + def test_get_table_error_in_generic_surfaces(self): + """SC-004a: ValueError from generic.get_table propagates (not swallowed); + get_table is called OUTSIDE the try block.""" + data = _make_dispatch_df() + + with patch( + "dashboard.compliance.generic.get_table", + side_effect=ValueError("boom"), + ): + with pytest.raises(ValueError, match="boom"): + _dispatch_compliance_renderer(data, "myfw_dynprovider") + + def test_get_table_error_in_builtin_surfaces(self): + """REQ-004 / ADR-1: RuntimeError from a builtin get_table propagates; + proving get_table is called outside the try block.""" + data = _make_dispatch_df() + mock_module = MagicMock() + mock_module.get_table.side_effect = RuntimeError("table error") + + with patch( + "dashboard.pages.compliance.importlib.import_module", + return_value=mock_module, + ): + with pytest.raises(RuntimeError, match="table error"): + _dispatch_compliance_renderer(data, "some_builtin_fw") + + def test_dedup_applied_before_get_table(self): + """ADR-1: Duplicate rows (identical CHECKID/STATUS/RESOURCEID/STATUSEXTENDED) + are dropped; returned data has the deduplicated row count.""" + # Row 0 and row 1 are identical in all dedup-key columns; row 2 is unique. + data = pd.DataFrame( + { + "REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A", "Sec A", "Sec B"], + "REQUIREMENTS_ID": ["req1", "req1", "req2"], + "STATUS": ["PASS", "PASS", "FAIL"], + "CHECKID": ["check1", "check1", "check2"], + "RESOURCEID": ["res1", "res1", "res2"], + "STATUSEXTENDED": ["", "", ""], + "REGION": ["us-east-1"] * 3, + "ACCOUNTID": ["123"] * 3, + } + ) + mock_module = MagicMock() + mock_module.get_table.return_value = html.Div([]) + + with patch( + "dashboard.pages.compliance.importlib.import_module", + return_value=mock_module, + ): + table, result_data = _dispatch_compliance_renderer(data, "some_fw") + + assert len(result_data) == 2 # one duplicate removed + + def test_muted_column_added_to_dedup_when_present(self): + """ADR-1 edge case: When MUTED column is present, it is included in the dedup + subset at index 2; rows differing only in MUTED are kept as distinct rows.""" + # Both rows share CHECKID/STATUS/RESOURCEID/STATUSEXTENDED but differ in MUTED. + # With MUTED in dedup_columns, both rows are kept (2 rows after dedup). + # Without MUTED in dedup_columns, they would be collapsed to 1 row. + data = pd.DataFrame( + { + "REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A", "Sec A"], + "REQUIREMENTS_ID": ["req1", "req1"], + "STATUS": ["PASS", "PASS"], + "CHECKID": ["check1", "check1"], + "RESOURCEID": ["res1", "res1"], + "STATUSEXTENDED": ["", ""], + "MUTED": ["True", "False"], + "REGION": ["us-east-1", "us-east-1"], + "ACCOUNTID": ["123", "123"], + } + ) + mock_module = MagicMock() + mock_module.get_table.return_value = html.Div([]) + + with patch( + "dashboard.pages.compliance.importlib.import_module", + return_value=mock_module, + ): + table, result_data = _dispatch_compliance_renderer(data, "some_fw") + + # MUTED at idx 2 means these two rows have different dedup keys → both kept + assert len(result_data) == 2 + + def test_returns_table_and_data_tuple(self): + """ADR-1 interface contract: _dispatch_compliance_renderer returns a + 2-tuple (table, deduped_data).""" + data = pd.DataFrame( + { + "REQUIREMENTS_ID": ["1.1", "1.2"], + "REQUIREMENTS_DESCRIPTION": ["Desc 1", "Desc 2"], + "REQUIREMENTS_ATTRIBUTES_SECTION": ["Section A", "Section A"], + "CHECKID": ["check1", "check2"], + "STATUS": ["PASS", "FAIL"], + "REGION": ["us-east-1", "us-east-1"], + "ACCOUNTID": ["123456789", "123456789"], + "RESOURCEID": ["res1", "res2"], + "STATUSEXTENDED": ["", ""], + } + ) + result = _dispatch_compliance_renderer(data, "cis_4_0_aws") + assert isinstance(result, tuple) + assert len(result) == 2 + table, deduped_data = result + assert isinstance(table, html.Div) + assert isinstance(deduped_data, pd.DataFrame) diff --git a/tests/dashboard/pages/conftest.py b/tests/dashboard/pages/conftest.py new file mode 100644 index 0000000000..a5c674c7de --- /dev/null +++ b/tests/dashboard/pages/conftest.py @@ -0,0 +1,7 @@ +import dash + +# Initialize a minimal Dash app so that dashboard page modules can call +# dash.register_page() during import without raising PageError. +# This module-level initialization runs during pytest collection, before +# any test file in this directory is imported. +_test_app = dash.Dash("prowler_test_app", use_pages=True, pages_folder="") diff --git a/tests/dashboard/pages/scope_columns_test.py b/tests/dashboard/pages/scope_columns_test.py new file mode 100644 index 0000000000..a8aea34221 --- /dev/null +++ b/tests/dashboard/pages/scope_columns_test.py @@ -0,0 +1,60 @@ +import pandas as pd + +from dashboard.pages.compliance import _ensure_scope_columns + + +def _df(columns): + """Build a one-row DataFrame preserving the given column order.""" + return pd.DataFrame({col: ["x"] for col in columns}) + + +class TestEnsureScopeColumns: + def test_aws_account_and_region_preserved(self): + """A provider that already emits ACCOUNTID and REGION is left untouched.""" + df = _df(["PROVIDER", "DESCRIPTION", "ACCOUNTID", "REGION", "ASSESSMENTDATE"]) + result = _ensure_scope_columns(df) + assert "ACCOUNTID" in result.columns + assert "REGION" in result.columns + assert result["ACCOUNTID"].iloc[0] == "x" + + def test_okta_single_scope_column_becomes_accountid(self): + """Okta's ORGANIZATIONDOMAIN becomes ACCOUNTID; REGION falls back.""" + df = _df(["PROVIDER", "DESCRIPTION", "ORGANIZATIONDOMAIN", "ASSESSMENTDATE"]) + df["ORGANIZATIONDOMAIN"] = ["trial-123.okta.com"] + result = _ensure_scope_columns(df) + assert "ACCOUNTID" in result.columns + assert "ORGANIZATIONDOMAIN" not in result.columns + assert result["ACCOUNTID"].iloc[0] == "trial-123.okta.com" + assert result["REGION"].iloc[0] == "-" + + def test_two_unknown_scope_columns_map_to_account_and_region(self): + """Two scope columns map positionally to ACCOUNTID and REGION.""" + df = _df(["PROVIDER", "DESCRIPTION", "TENANCYID", "LOCATION", "ASSESSMENTDATE"]) + df["TENANCYID"] = ["tenant-1"] + df["LOCATION"] = ["eu-west-1"] + result = _ensure_scope_columns(df) + assert result["ACCOUNTID"].iloc[0] == "tenant-1" + assert result["REGION"].iloc[0] == "eu-west-1" + + def test_no_scope_columns_fall_back_to_dash(self): + """No scope columns → both ACCOUNTID and REGION fall back to '-'.""" + df = _df(["PROVIDER", "DESCRIPTION", "ASSESSMENTDATE"]) + result = _ensure_scope_columns(df) + assert result["ACCOUNTID"].iloc[0] == "-" + assert result["REGION"].iloc[0] == "-" + + def test_missing_anchors_still_fall_back_to_dash(self): + """Without DESCRIPTION/ASSESSMENTDATE anchors, both fall back to '-'.""" + df = _df(["PROVIDER", "FOO", "BAR"]) + result = _ensure_scope_columns(df) + assert result["ACCOUNTID"].iloc[0] == "-" + assert result["REGION"].iloc[0] == "-" + + def test_existing_accountid_does_not_consume_region_scope(self): + """An existing ACCOUNTID is kept; the leftover scope becomes REGION.""" + df = _df(["PROVIDER", "DESCRIPTION", "ACCOUNTID", "LOCATION", "ASSESSMENTDATE"]) + df["ACCOUNTID"] = ["acc-1"] + df["LOCATION"] = ["us-east-2"] + result = _ensure_scope_columns(df) + assert result["ACCOUNTID"].iloc[0] == "acc-1" + assert result["REGION"].iloc[0] == "us-east-2" diff --git a/tests/lib/check/compliance_check_test.py b/tests/lib/check/compliance_check_test.py index 73f93e3b38..631c134302 100644 --- a/tests/lib/check/compliance_check_test.py +++ b/tests/lib/check/compliance_check_test.py @@ -540,7 +540,9 @@ class TestCompliance: ): object = mock.Mock() object.path = "/path/to/compliance" - object.name = "framework1_aws" + # list_compliance_modules yields dotted module names; get_bulk matches + # the last segment exactly against the provider. + object.name = "prowler.compliance.aws" mock_list_modules.return_value = [object] mock_listdir.return_value = ["framework1_aws.json"] diff --git a/tests/lib/check/models_test.py b/tests/lib/check/models_test.py index 71fd1f718b..f17e359441 100644 --- a/tests/lib/check/models_test.py +++ b/tests/lib/check/models_test.py @@ -95,6 +95,38 @@ class TestCheckMetada: "/path/to/accessanalyzer_enabled/accessanalyzer_enabled.metadata.json" ) + @mock.patch("prowler.lib.check.models.logger") + @mock.patch("prowler.lib.check.models.load_check_metadata") + @mock.patch("prowler.lib.check.models.recover_checks_from_provider") + def test_get_bulk_builtin_wins_on_check_id_collision( + self, mock_recover_checks, mock_load_metadata, mock_logger + ): + """Regression guard: when an entry-point plug-in re-registers a + built-in CheckID, the BUILT-IN metadata wins (first-write-wins) and + the plug-in is IGNORED. The override is surfaced via a warning so + the user knows their plug-in duplicate is being skipped and can + rename it. Matches the precedence in `_resolve_check_module`. See + PR #10700 review (HugoPBrito).""" + # Built-in first, plug-in last (matches recover_checks_from_provider order) + mock_recover_checks.return_value = [ + ("accessanalyzer_enabled", "/builtin/accessanalyzer_enabled"), + ("accessanalyzer_enabled", "/plugin/accessanalyzer_enabled"), + ] + + builtin_metadata = mock.MagicMock(CheckID="accessanalyzer_enabled") + plugin_metadata = mock.MagicMock(CheckID="accessanalyzer_enabled") + mock_load_metadata.side_effect = [builtin_metadata, plugin_metadata] + + result = CheckMetadata.get_bulk(provider="aws") + + # Built-in wins (first-write-wins on CheckID), plug-in is ignored + assert result["accessanalyzer_enabled"] is builtin_metadata + # Override is surfaced via warning naming the plug-in metadata file + mock_logger.warning.assert_called_once() + warning_msg = mock_logger.warning.call_args.args[0] + assert "accessanalyzer_enabled" in warning_msg + assert "/plugin/accessanalyzer_enabled" in warning_msg + @mock.patch("prowler.lib.check.models.load_check_metadata") @mock.patch("prowler.lib.check.models.recover_checks_from_provider") def test_list(self, mock_recover_checks, mock_load_metadata): diff --git a/tests/lib/check/tool_wrapper_test.py b/tests/lib/check/tool_wrapper_test.py new file mode 100644 index 0000000000..5f4f0c7f88 --- /dev/null +++ b/tests/lib/check/tool_wrapper_test.py @@ -0,0 +1,124 @@ +"""Unit tests for prowler.lib.check.tool_wrapper. + +Covers the leaf helper directly (Provider.is_tool_wrapper_provider delegates +to it). Tests the frozenset fast path, the entry-point fallback for external +plug-ins, the broken-plug-in path, the no-match path, and the module-level +cache. +""" + +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture(autouse=True) +def _clear_ep_class_cache(): + """Reset the leaf module's cache between tests so they stay independent.""" + from prowler.lib.check import tool_wrapper + + tool_wrapper._ep_class_cache.clear() + yield + tool_wrapper._ep_class_cache.clear() + + +def _make_entry_point(name, cls): + """Create a mock entry point whose `load()` returns `cls`.""" + ep = MagicMock() + ep.name = name + ep.load.return_value = cls + return ep + + +class TestIsToolWrapperProvider: + """is_tool_wrapper_provider: frozenset + entry-point fallback.""" + + @pytest.mark.parametrize("name", ["iac", "llm", "image"]) + def test_returns_true_for_builtin_tool_wrappers(self, name): + from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider + + assert is_tool_wrapper_provider(name) is True + + @pytest.mark.parametrize("name", ["aws", "azure", "gcp", "github", "kubernetes"]) + def test_returns_false_for_regular_builtins(self, name): + from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider + + assert is_tool_wrapper_provider(name) is False + + @patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points") + def test_returns_true_for_external_plugin_with_flag(self, mock_eps): + from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider + + cls = MagicMock(is_external_tool_provider=True) + mock_eps.return_value = [_make_entry_point("custom_wrapper", cls)] + + assert is_tool_wrapper_provider("custom_wrapper") is True + + @patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points") + def test_returns_false_for_external_plugin_without_flag(self, mock_eps): + from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider + + cls = MagicMock(is_external_tool_provider=False) + mock_eps.return_value = [_make_entry_point("vanilla_external", cls)] + + assert is_tool_wrapper_provider("vanilla_external") is False + + @patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points") + def test_returns_false_for_unknown_provider(self, mock_eps): + from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider + + mock_eps.return_value = [] + + assert is_tool_wrapper_provider("does-not-exist") is False + + @patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points") + def test_builtin_name_shortcircuits_before_loading_same_name_plugin(self, mock_eps): + """A plug-in registered under a built-in's name cannot flip the + built-in onto the tool-wrapper path, and its module is never loaded.""" + from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider + + malicious = _make_entry_point("aws", MagicMock(is_external_tool_provider=True)) + mock_eps.return_value = [malicious] + + # `aws` is a built-in, so classification short-circuits to False... + assert is_tool_wrapper_provider("aws") is False + # ...and the shadowing plug-in's code is never executed via ep.load(). + malicious.load.assert_not_called() + + +class TestLoadEpClass: + """_load_ep_class: cache, broken plug-ins, no-match.""" + + @patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points") + def test_caches_result_across_calls(self, mock_eps): + from prowler.lib.check.tool_wrapper import _load_ep_class + + cls = MagicMock(is_external_tool_provider=True) + mock_eps.return_value = [_make_entry_point("cached_one", cls)] + + first = _load_ep_class("cached_one") + second = _load_ep_class("cached_one") + + assert first is cls + assert second is cls + # entry_points consulted only on the first call + assert mock_eps.call_count == 1 + + @patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points") + def test_returns_none_for_broken_plugin(self, mock_eps): + from prowler.lib.check.tool_wrapper import _load_ep_class + + broken_ep = MagicMock() + broken_ep.name = "broken" + broken_ep.load.side_effect = ImportError("plug-in is broken") + mock_eps.return_value = [broken_ep] + + assert _load_ep_class("broken") is None + + @patch("prowler.lib.check.tool_wrapper.importlib.metadata.entry_points") + def test_returns_none_when_no_entry_point_matches(self, mock_eps): + from prowler.lib.check.tool_wrapper import _load_ep_class + + cls = MagicMock() + mock_eps.return_value = [_make_entry_point("other_provider", cls)] + + assert _load_ep_class("missing_provider") is None diff --git a/tests/lib/check/universal_compliance_models_test.py b/tests/lib/check/universal_compliance_models_test.py index 5a3e4aae56..c8f614488c 100644 --- a/tests/lib/check/universal_compliance_models_test.py +++ b/tests/lib/check/universal_compliance_models_test.py @@ -1,5 +1,7 @@ import json import os +import tempfile +from unittest.mock import MagicMock, patch import pytest from pydantic.v1 import ValidationError @@ -23,6 +25,7 @@ from prowler.lib.check.compliance_models import ( TableLabels, UniversalComplianceRequirement, adapt_legacy_to_universal, + get_bulk_compliance_frameworks_universal, load_compliance_framework_universal, ) from tests.lib.outputs.compliance.fixtures import ( @@ -1116,3 +1119,121 @@ class TestAttributesMetadataValidation: ], attributes_metadata=self._metadata(enum=["high", "low"]), ) + + +class TestGetBulkUniversalEntryPoints: + """Entry-point discovery for universal (multi-provider) compliance frameworks.""" + + @staticmethod + def _write_universal_json(directory, filename, framework, display_name): + data = { + "framework": framework, + "name": display_name, + "version": "1.0", + "description": "External multi-provider framework", + "requirements": [ + { + "id": "1", + "name": "Requirement 1", + "description": "desc", + "checks": {"fakeexternal": ["check_a"]}, + } + ], + } + with open(os.path.join(directory, filename), "w") as f: + json.dump(data, f) + + @staticmethod + def _entry_point(path): + module = MagicMock() + module.__path__ = [path] + ep = MagicMock() + ep.name = "fakeexternal" + ep.group = "prowler.compliance.universal" + ep.load.return_value = module + return ep + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + @patch("prowler.lib.check.compliance_models.list_compliance_modules") + def test_includes_external_universal_framework(self, mock_list_modules, mock_ep): + mock_list_modules.return_value = [] + with tempfile.TemporaryDirectory() as ep_dir: + self._write_universal_json( + ep_dir, "customuniversal_1.0.json", "CustomUniversal", "Custom" + ) + mock_ep.return_value = [self._entry_point(ep_dir)] + + bulk = get_bulk_compliance_frameworks_universal("fakeexternal") + + mock_ep.assert_called_with(group="prowler.compliance.universal") + assert "customuniversal_1.0" in bulk + assert bulk["customuniversal_1.0"].framework == "CustomUniversal" + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + @patch("prowler.lib.check.compliance_models.list_compliance_modules") + def test_builtin_wins_over_external_on_name_collision( + self, mock_list_modules, mock_ep + ): + with ( + tempfile.TemporaryDirectory() as root, + tempfile.TemporaryDirectory() as ep_dir, + ): + builtin_sub = os.path.join(root, "builtinprov") + os.makedirs(builtin_sub) + self._write_universal_json( + builtin_sub, "shared_1.0.json", "SharedFramework", "Built-in" + ) + builtin_module = MagicMock() + builtin_module.module_finder.path = root + builtin_module.name = "prowler.compliance.builtinprov" + mock_list_modules.return_value = [builtin_module] + + self._write_universal_json( + ep_dir, "shared_1.0.json", "SharedFramework", "External" + ) + mock_ep.return_value = [self._entry_point(ep_dir)] + + bulk = get_bulk_compliance_frameworks_universal("fakeexternal") + + assert "shared_1.0" in bulk + assert bulk["shared_1.0"].name == "Built-in" + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + @patch("prowler.lib.check.compliance_models.list_compliance_modules") + def test_loads_all_frameworks_in_a_single_entry_point_path( + self, mock_list_modules, mock_ep + ): + """All JSONs in one entry-point directory are added, not collapsed to one.""" + mock_list_modules.return_value = [] + with tempfile.TemporaryDirectory() as ep_dir: + self._write_universal_json(ep_dir, "fw_a_1.0.json", "FwA", "Framework A") + self._write_universal_json(ep_dir, "fw_b_1.0.json", "FwB", "Framework B") + mock_ep.return_value = [self._entry_point(ep_dir)] + + bulk = get_bulk_compliance_frameworks_universal("fakeexternal") + + assert "fw_a_1.0" in bulk + assert "fw_b_1.0" in bulk + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + @patch("prowler.lib.check.compliance_models.list_compliance_modules") + def test_merges_frameworks_from_multiple_packages_same_provider( + self, mock_list_modules, mock_ep + ): + """Two packages under the same provider name are both discovered.""" + mock_list_modules.return_value = [] + with ( + tempfile.TemporaryDirectory() as dir_a, + tempfile.TemporaryDirectory() as dir_b, + ): + self._write_universal_json(dir_a, "pkg_a_1.0.json", "PkgA", "Package A") + self._write_universal_json(dir_b, "pkg_b_1.0.json", "PkgB", "Package B") + mock_ep.return_value = [ + self._entry_point(dir_a), + self._entry_point(dir_b), + ] + + bulk = get_bulk_compliance_frameworks_universal("fakeexternal") + + assert "pkg_a_1.0" in bulk + assert "pkg_b_1.0" in bulk diff --git a/tests/lib/outputs/compliance/asd_essential_eight/asd_essential_eight_table_test.py b/tests/lib/outputs/compliance/asd_essential_eight/asd_essential_eight_table_test.py new file mode 100644 index 0000000000..6382dc1d2c --- /dev/null +++ b/tests/lib/outputs/compliance/asd_essential_eight/asd_essential_eight_table_test.py @@ -0,0 +1,132 @@ +import re +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.asd_essential_eight.asd_essential_eight import ( + get_asd_essential_eight_table, +) + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _make_compliance(provider, sections, framework="ASD-Essential-Eight"): + """Build a per-check compliance covering the given sections.""" + return SimpleNamespace( + Framework=framework, + Provider=provider, + Requirements=[ + SimpleNamespace(Attributes=[SimpleNamespace(Section=section)]) + for section in sections + ], + ) + + +class TestASDEssentialEightTable: + """Test cases verifying multi-section counting and provider-column attribution for the ASD Essential Eight compliance table.""" + + def test_multi_section_fail_not_undercounted(self, capsys, tmp_path): + """A single FAIL check mapped to several sections must show FAIL(1) in + every section, not just the first one seen.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_asd_essential_eight_table( + findings, + bulk_metadata, + "asd_essential_eight_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # Both IAM and Logging must report FAIL(1); before the fix Logging was + # undercounted because the per-section count was gated by the global + # dedup list. + assert captured.out.count("FAIL(1)") == 2 + + def test_multi_section_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED check mapped to several sections must increase the + per-section Muted count in every section, not only the first one.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + # A real FAIL is needed so the results table is rendered at all. + _make_finding("check_b", "FAIL"), + ] + + get_asd_essential_eight_table( + findings, + bulk_metadata, + "asd_essential_eight_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = re.sub(r"\x1b\[[0-9;]*m", "", captured.out) + # The muted check belongs to both IAM and Logging, so the Muted column + # must read 1 in both rows. + muted_cells = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_cells) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched ASD-Essential-Eight + compliance, never from a different framework that happens to be the last + entry in the check's compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_asd_essential_eight_table( + findings, + bulk_metadata, + "asd_essential_eight_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + # The provider of the unrelated trailing framework must NOT leak into + # the rendered table. + assert "leaked_provider" not in captured.out diff --git a/tests/lib/outputs/compliance/c5/__init__.py b/tests/lib/outputs/compliance/c5/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/outputs/compliance/c5/c5_table_test.py b/tests/lib/outputs/compliance/c5/c5_table_test.py new file mode 100644 index 0000000000..c423f5af0b --- /dev/null +++ b/tests/lib/outputs/compliance/c5/c5_table_test.py @@ -0,0 +1,130 @@ +import re +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.c5.c5 import get_c5_table + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _make_compliance(provider, sections, framework="C5"): + """Build a per-check compliance covering the given sections.""" + return SimpleNamespace( + Framework=framework, + Provider=provider, + Requirements=[ + SimpleNamespace(Attributes=[SimpleNamespace(Section=section)]) + for section in sections + ], + ) + + +class TestC5Table: + """Verify multi-section counting and provider-column attribution for the compliance table.""" + + def test_multi_section_fail_not_undercounted(self, capsys, tmp_path): + """A single FAIL check mapped to several sections must show FAIL(1) in + every section, not just the first one seen.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_c5_table( + findings, + bulk_metadata, + "c5_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # Both IAM and Logging must report FAIL(1); before the fix Logging was + # undercounted because the per-section count was gated by the global + # dedup list. + assert captured.out.count("FAIL(1)") == 2 + + def test_multi_section_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED check mapped to several sections must increase the + per-section Muted count in every section, not only the first one.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + # A real FAIL is needed so the results table is rendered at all. + _make_finding("check_b", "FAIL"), + ] + + get_c5_table( + findings, + bulk_metadata, + "c5_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = re.sub(r"\x1b\[[0-9;]*m", "", captured.out) + # The muted check belongs to both IAM and Logging, so the Muted column + # must read 1 in both rows. + muted_cells = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_cells) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched C5 compliance, never + from a different framework that happens to be the last entry in the + check's compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_c5_table( + findings, + bulk_metadata, + "c5_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + # The provider of the unrelated trailing framework must NOT leak into + # the rendered table. + assert "leaked_provider" not in captured.out diff --git a/tests/lib/outputs/compliance/ccc/ccc_table_test.py b/tests/lib/outputs/compliance/ccc/ccc_table_test.py new file mode 100644 index 0000000000..f647aff3d0 --- /dev/null +++ b/tests/lib/outputs/compliance/ccc/ccc_table_test.py @@ -0,0 +1,130 @@ +import re +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.ccc.ccc import get_ccc_table + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _make_compliance(provider, sections, framework="CCC"): + """Build a per-check compliance covering the given sections.""" + return SimpleNamespace( + Framework=framework, + Provider=provider, + Requirements=[ + SimpleNamespace(Attributes=[SimpleNamespace(Section=section)]) + for section in sections + ], + ) + + +class TestCCCTable: + """Test cases verifying multi-section counting and provider-column attribution for the compliance table.""" + + def test_multi_section_fail_not_undercounted(self, capsys, tmp_path): + """A single FAIL check mapped to several sections must show FAIL(1) in + every section, not just the first one seen.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_ccc_table( + findings, + bulk_metadata, + "ccc_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # Both IAM and Logging must report FAIL(1); before the fix Logging was + # undercounted because the per-section count was gated by the global + # dedup list. + assert captured.out.count("FAIL(1)") == 2 + + def test_multi_section_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED check mapped to several sections must increase the + per-section Muted count in every section, not only the first one.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + # A real FAIL is needed so the results table is rendered at all. + _make_finding("check_b", "FAIL"), + ] + + get_ccc_table( + findings, + bulk_metadata, + "ccc_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = re.sub(r"\x1b\[[0-9;]*m", "", captured.out) + # The muted check belongs to both IAM and Logging, so the Muted column + # must read 1 in both rows. + muted_cells = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_cells) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched CCC compliance, never + from a different framework that happens to be the last entry in the + check's compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_ccc_table( + findings, + bulk_metadata, + "ccc_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + # The provider of the unrelated trailing framework must NOT leak into + # the rendered table. + assert "leaked_provider" not in captured.out diff --git a/tests/lib/outputs/compliance/cis/cis_table_test.py b/tests/lib/outputs/compliance/cis/cis_table_test.py new file mode 100644 index 0000000000..c47e1387a0 --- /dev/null +++ b/tests/lib/outputs/compliance/cis/cis_table_test.py @@ -0,0 +1,162 @@ +import re +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.cis.cis import get_cis_table + + +def _strip_ansi(text): + return re.sub(r"\x1b\[[0-9;]*m", "", text) + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _attr(section, profile="Level 1"): + return SimpleNamespace(Section=section, Profile=profile) + + +def _make_compliance(provider, attributes, version="1.4", framework="CIS"): + """Build a per-check CIS compliance with the given (section, profile) attrs.""" + return SimpleNamespace( + Framework=framework, + Version=version, + Provider=provider, + Requirements=[SimpleNamespace(Attributes=attributes)], + ) + + +def _make_compliance_multi_req(provider, attributes, version="1.4", framework="CIS"): + """Build a per-check CIS compliance where each attr is its own requirement, + simulating a check that appears in several requirements.""" + return SimpleNamespace( + Framework=framework, + Version=version, + Provider=provider, + Requirements=[SimpleNamespace(Attributes=[attr]) for attr in attributes], + ) + + +class TestCISTable: + """Verify multi-section counting and provider-column attribution for the CIS compliance table.""" + + def test_muted_multi_section_not_undercounted(self, capsys, tmp_path): + """A single MUTED finding mapped to several sections must increment the + per-section Muted column for every section, not only the first seen. + + CIS counts FAIL/PASS through Level 1/Level 2 buckets, so only the Muted + per-section count was affected by the undercount bug. + """ + bulk_metadata = { + # check_a is muted and belongs to two sections at once. + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", [_attr("1 IAM"), _attr("2 Logging")]) + ] + ), + # A real (non-muted) finding so the table is rendered. + "check_b": SimpleNamespace( + Compliance=[_make_compliance("aws", [_attr("1 IAM")])] + ), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + _make_finding("check_b", "PASS"), + ] + + get_cis_table( + findings, + bulk_metadata, + "cis_1.4_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # Both section rows must carry a Muted count of 1 in their last cell. + # Before the fix only the first section seen got incremented. + muted_one_rows = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_one_rows) == 2 + + def test_same_section_level_not_double_counted(self, capsys, tmp_path): + """A single finding whose check maps to several requirements that share + the same section and profile must count once for that section/level, + not once per requirement (FAIL(1), never FAIL(2)).""" + bulk_metadata = { + # check_a is a single FAIL mapped to two requirements, both in the + # same section "1 IAM" and the same profile "Level 1". + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance_multi_req("aws", [_attr("1 IAM"), _attr("1 IAM")]) + ] + ), + # A second finding in another section so the table renders. + "check_b": SimpleNamespace( + Compliance=[_make_compliance("aws", [_attr("2 Logging")])] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_cis_table( + findings, + bulk_metadata, + "cis_1.4_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # The "1 IAM" row must show FAIL(1) for Level 1, never FAIL(2). + assert "FAIL(1)" in plain + assert "FAIL(2)" not in plain + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched CIS compliance, not + from a different framework that trails it in the compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", [_attr("1 IAM")]), + _make_compliance( + "gcp", [_attr("Other")], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", [_attr("1 IAM")]), + _make_compliance( + "gcp", [_attr("Other")], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_cis_table( + findings, + bulk_metadata, + "cis_1.4_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + # The trailing unrelated framework's provider must not leak in. + assert "gcp" not in captured.out diff --git a/tests/lib/outputs/compliance/display_compliance_table_test.py b/tests/lib/outputs/compliance/display_compliance_table_test.py index 0d2cd5313b..bd854d6d7d 100644 --- a/tests/lib/outputs/compliance/display_compliance_table_test.py +++ b/tests/lib/outputs/compliance/display_compliance_table_test.py @@ -96,25 +96,19 @@ class TestDispatchStartswith: @pytest.mark.parametrize( "framework_name", - [ - "csa_ccm_4.0_aws", - "csa_ccm_4.0_azure", - "csa_ccm_4.0_gcp", - "csa_ccm_4.0_oraclecloud", - "csa_ccm_4.0_alibabacloud", - ], + ["c5_aws", "c5_azure", "c5_gcp"], ) - @patch(f"{MODULE}.get_csa_table") - def test_csa_dispatch(self, mock_fn, framework_name): + @patch(f"{MODULE}.get_c5_table") + def test_c5_dispatch(self, mock_fn, framework_name): display_compliance_table(compliance_framework=framework_name, **_COMMON) mock_fn.assert_called_once() @pytest.mark.parametrize( "framework_name", - ["c5_aws", "c5_azure", "c5_gcp"], + ["okta_idaas_stig_v1r2_okta"], ) - @patch(f"{MODULE}.get_c5_table") - def test_c5_dispatch(self, mock_fn, framework_name): + @patch(f"{MODULE}.get_okta_idaas_stig_table") + def test_okta_idaas_stig_dispatch(self, mock_fn, framework_name): display_compliance_table(compliance_framework=framework_name, **_COMMON) mock_fn.assert_called_once() diff --git a/tests/lib/outputs/compliance/ens/ens_table_test.py b/tests/lib/outputs/compliance/ens/ens_table_test.py new file mode 100644 index 0000000000..2373885dbc --- /dev/null +++ b/tests/lib/outputs/compliance/ens/ens_table_test.py @@ -0,0 +1,235 @@ +import re +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.ens.ens import get_ens_table + + +def _strip_ansi(text): + return re.sub(r"\x1b\[[0-9;]*m", "", text) + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _attr(marco, categoria, tipo="requisito", nivel="alto"): + return SimpleNamespace(Marco=marco, Categoria=categoria, Tipo=tipo, Nivel=nivel) + + +def _make_compliance(provider, attributes, framework="ENS"): + """Build a per-check ENS compliance with the given marco/categoria attrs.""" + return SimpleNamespace( + Framework=framework, + Provider=provider, + Requirements=[SimpleNamespace(Attributes=attributes)], + ) + + +class TestENSTable: + """Test cases for ENS compliance table rendering. + + Verify multi-marco counting and provider-column attribution for the + compliance table. + """ + + def test_no_cumple_marked_in_every_marco(self, capsys, tmp_path): + """A single failing finding mapped to several marcos must mark every + one of them as NO CUMPLE, not only the first marco seen.""" + bulk_metadata = { + # check_a fails and belongs to two distinct marcos/categorias. + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance( + "aws", + [ + _attr("operacional", "control de acceso"), + _attr("organizativo", "politica de seguridad"), + ], + ) + ] + ), + # A passing finding so the overview total reaches 2. + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", [_attr("operacional", "control de acceso")]) + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_ens_table( + findings, + bulk_metadata, + "ens_rd2022_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # Both marco rows the failing finding maps to must read NO CUMPLE. + # Before the fix only the first marco was marked, the second stayed + # CUMPLE. Anchor the assertion to the actual marco rows (not the + # overview header line which also mentions NO CUMPLE). + op_row = [ + line + for line in plain.splitlines() + if "operacional/control de acceso" in line + ] + org_row = [ + line + for line in plain.splitlines() + if "organizativo/politica de seguridad" in line + ] + assert len(op_row) == 1 and "NO CUMPLE" in op_row[0] + assert len(org_row) == 1 and "NO CUMPLE" in org_row[0] + + def test_recomendacion_does_not_set_no_cumple(self, capsys, tmp_path): + """A FAIL on a 'recomendacion' attribute must not flip a marco to + NO CUMPLE (this path is intentionally excluded from the fix).""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance( + "aws", + [ + _attr( + "operacional", "control de acceso", tipo="recomendacion" + ) + ], + ) + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", [_attr("organizativo", "politica")]) + ] + ), + # A regular (non-recomendacion) check so the results table renders + # at least one marco row and the assertion below is not vacuous. + "check_c": SimpleNamespace( + Compliance=[ + _make_compliance("aws", [_attr("operacional", "continuidad")]) + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + _make_finding("check_c", "PASS"), + ] + + get_ens_table( + findings, + bulk_metadata, + "ens_rd2022_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # The recomendacion FAIL must not appear as a NO CUMPLE marco row in the + # results table (the overview header line is allowed to mention it). + marco_rows = [ + line + for line in plain.splitlines() + if "operacional" in line or "organizativo" in line + ] + # Guard against a vacuous pass: the table must actually render rows. + assert marco_rows + assert all("NO CUMPLE" not in line for line in marco_rows) + + def test_muted_multi_marco_not_undercounted(self, capsys, tmp_path): + """A single MUTED finding mapped to several marcos must increment the + per-marco Muted column for every marco, not only the first seen.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance( + "aws", + [ + _attr("operacional", "control de acceso"), + _attr("organizativo", "politica de seguridad"), + ], + ) + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", [_attr("operacional", "control de acceso")]) + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + _make_finding("check_b", "FAIL"), + ] + + get_ens_table( + findings, + bulk_metadata, + "ens_rd2022_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # Both marco rows the muted finding maps to must report a Muted count of + # 1 in their last cell. + muted_one_rows = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_one_rows) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Proveedor column must come from the matched ENS compliance, not + from a different framework that trails it in the compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance( + "aws", [_attr("operacional", "control de acceso")] + ), + _make_compliance( + "gcp", [_attr("x", "y")], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance( + "aws", [_attr("operacional", "control de acceso")] + ), + _make_compliance( + "gcp", [_attr("x", "y")], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_ens_table( + findings, + bulk_metadata, + "ens_rd2022_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + assert "gcp" not in captured.out diff --git a/tests/lib/outputs/compliance/fixtures.py b/tests/lib/outputs/compliance/fixtures.py index f085460abd..a8fc7aa7e5 100644 --- a/tests/lib/outputs/compliance/fixtures.py +++ b/tests/lib/outputs/compliance/fixtures.py @@ -16,6 +16,7 @@ from prowler.lib.check.compliance_models import ( Mitre_Requirement_Attribute_Azure, Mitre_Requirement_Attribute_GCP, Prowler_ThreatScore_Requirement_Attribute, + STIG_Requirement_Attribute, ) CIS_1_4_AWS = Compliance( @@ -1258,3 +1259,47 @@ ASD_ESSENTIAL_EIGHT_AWS = Compliance( ), ], ) + +OKTA_IDAAS_STIG_OKTA = Compliance( + Framework="Okta-IDaaS-STIG", + Name="DISA Okta Identity as a Service (IDaaS) STIG V1R2", + Version="1R2", + Provider="Okta", + Description="Defense Information Systems Agency (DISA) Security Technical Implementation Guide (STIG) for Okta Identity as a Service (IDaaS).", + Requirements=[ + Compliance_Requirement( + Id="OKTA-APP-000020", + Name="Okta must log out a session after a 15-minute period of inactivity.", + Description="A session timeout lock is a temporary action taken when a user stops work and moves away from the immediate vicinity of the information system.", + Attributes=[ + STIG_Requirement_Attribute( + Section="CAT II (Medium)", + Severity="medium", + RuleID="SV-273186r1098825_rule", + StigID="OKTA-APP-000020", + CCI=["CCI-000057", "CCI-001133"], + CheckText="Verify the Global Session Policy logs out a session after 15 minutes of inactivity.", + FixText="From the Admin Console configure the Global Session Policy idle timeout to 15 minutes.", + ) + ], + Checks=["signon_global_session_idle_timeout_15min"], + ), + Compliance_Requirement( + Id="OKTA-APP-000650", + Name="Okta must enforce a minimum 15-character password length.", + Description="The shorter the password, the lower the number of possible combinations that need to be tested before the password is compromised.", + Attributes=[ + STIG_Requirement_Attribute( + Section="CAT II (Medium)", + Severity="medium", + RuleID="SV-273209r1098894_rule", + StigID="OKTA-APP-000650", + CCI=["CCI-000205"], + CheckText="Verify the password policy enforces a minimum length of 15 characters.", + FixText="From the Admin Console set the minimum password length to 15 characters.", + ) + ], + Checks=[], + ), + ], +) diff --git a/tests/lib/outputs/compliance/generic/generic_aws_test.py b/tests/lib/outputs/compliance/generic/generic_aws_test.py index 335d1860ea..335a9ad17d 100644 --- a/tests/lib/outputs/compliance/generic/generic_aws_test.py +++ b/tests/lib/outputs/compliance/generic/generic_aws_test.py @@ -9,6 +9,7 @@ from prowler.lib.check.compliance_models import ( Compliance, Compliance_Requirement, Generic_Compliance_Requirement_Attribute, + ISO27001_2013_Requirement_Attribute, ) from prowler.lib.outputs.compliance.generic.generic import GenericCompliance from prowler.lib.outputs.compliance.generic.models import GenericComplianceModel @@ -198,3 +199,47 @@ class TestAWSGenericCompliance: ), f"Expected 1 row driven by framework JSON, got {len(rows)}" assert rows[0].Requirements_Id == "req_in_framework" assert rows[0].CheckId == "service_check_in_framework" + + def test_transform_tolerates_framework_specific_attribute_schema(self): + """GenericCompliance is the documented last-resort renderer, so it must not + crash on a framework whose attribute schema lacks the universal fields + (Section, SubSection, SubGroup, Service, Type, Comment). ISO27001 declares + none of them; missing fields must render as None instead of raising + AttributeError and dropping the whole CSV.""" + framework_name = "ISO27001-2013-External" + compliance = Compliance( + Framework=framework_name, + Name=framework_name, + Provider="external", + Version="", + Description="Framework shipping a provider-specific attribute schema", + Requirements=[ + Compliance_Requirement( + Id="A.5.1.1", + Description="Policies for information security", + Attributes=[ + ISO27001_2013_Requirement_Attribute( + Category="Information security policies", + Objetive_ID="A.5.1", + Objetive_Name="Management direction", + Check_Summary="Policy is defined", + ) + ], + Checks=["service_test_check_id"], + ) + ], + ) + + findings = [generate_finding_output(check_id="service_test_check_id")] + + output = GenericCompliance(findings, compliance) + + rows = [row for row in output.data if row.Status != "MANUAL"] + assert len(rows) == 1 + assert rows[0].Requirements_Id == "A.5.1.1" + assert rows[0].Requirements_Attributes_Section is None + assert rows[0].Requirements_Attributes_SubSection is None + assert rows[0].Requirements_Attributes_SubGroup is None + assert rows[0].Requirements_Attributes_Service is None + assert rows[0].Requirements_Attributes_Type is None + assert rows[0].Requirements_Attributes_Comment is None diff --git a/tests/lib/outputs/compliance/kisa_ismsp/__init__.py b/tests/lib/outputs/compliance/kisa_ismsp/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/outputs/compliance/kisa_ismsp/kisa_ismsp_table_test.py b/tests/lib/outputs/compliance/kisa_ismsp/kisa_ismsp_table_test.py new file mode 100644 index 0000000000..be0fe25ad5 --- /dev/null +++ b/tests/lib/outputs/compliance/kisa_ismsp/kisa_ismsp_table_test.py @@ -0,0 +1,137 @@ +import re +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.kisa_ismsp.kisa_ismsp import get_kisa_ismsp_table + +# The generator matches a compliance when its Framework starts with "KISA" and +# its Version is contained in the compliance_framework argument. +COMPLIANCE_FRAMEWORK = "kisa-isms-p-2023_aws" + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _make_compliance( + provider, sections, framework="KISA-ISMS-P", version="kisa-isms-p-2023" +): + """Build a per-check compliance covering the given sections.""" + return SimpleNamespace( + Framework=framework, + Version=version, + Provider=provider, + Requirements=[ + SimpleNamespace(Attributes=[SimpleNamespace(Section=section)]) + for section in sections + ], + ) + + +class TestKISAISMSPTable: + """Verify multi-section counting and provider-column attribution for the KISA ISMS-P compliance table.""" + + def test_multi_section_fail_not_undercounted(self, capsys, tmp_path): + """A single FAIL check mapped to several sections must show FAIL(1) in + every section, not just the first one seen.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_kisa_ismsp_table( + findings, + bulk_metadata, + COMPLIANCE_FRAMEWORK, + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # Both IAM and Logging must report FAIL(1); before the fix Logging was + # undercounted because the per-section count was gated by the global + # dedup list. + assert captured.out.count("FAIL(1)") == 2 + + def test_multi_section_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED check mapped to several sections must increase the + per-section Muted count in every section, not only the first one.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + # A real FAIL is needed so the results table is rendered at all. + _make_finding("check_b", "FAIL"), + ] + + get_kisa_ismsp_table( + findings, + bulk_metadata, + COMPLIANCE_FRAMEWORK, + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = re.sub(r"\x1b\[[0-9;]*m", "", captured.out) + # The muted check belongs to both IAM and Logging, so the Muted column + # must read 1 in both rows. + muted_cells = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_cells) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched KISA compliance, never + from a different framework that happens to be the last entry in the + check's compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_kisa_ismsp_table( + findings, + bulk_metadata, + COMPLIANCE_FRAMEWORK, + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + # The provider of the unrelated trailing framework must NOT leak into + # the rendered table. + assert "leaked_provider" not in captured.out diff --git a/tests/lib/outputs/compliance/mitre_attack/__init__.py b/tests/lib/outputs/compliance/mitre_attack/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/outputs/compliance/mitre_attack/mitre_attack_table_test.py b/tests/lib/outputs/compliance/mitre_attack/mitre_attack_table_test.py new file mode 100644 index 0000000000..3bd69b44e8 --- /dev/null +++ b/tests/lib/outputs/compliance/mitre_attack/mitre_attack_table_test.py @@ -0,0 +1,140 @@ +import re +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.mitre_attack.mitre_attack import ( + get_mitre_attack_table, +) + +# The generator matches a compliance when "MITRE-ATTACK" is in its Framework and +# its Version is contained in the compliance_framework argument. +COMPLIANCE_FRAMEWORK = "mitre_attack_aws" + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _make_compliance( + provider, tactics, framework="MITRE-ATTACK", version="mitre_attack" +): + """Build a per-check compliance covering the given tactics.""" + return SimpleNamespace( + Framework=framework, + Version=version, + Provider=provider, + Requirements=[SimpleNamespace(Tactics=tactics)], + ) + + +class TestMitreAttackTable: + """Test multi-section counting and provider-column attribution for the compliance table.""" + + def test_multi_tactic_fail_not_undercounted(self, capsys, tmp_path): + """A single FAIL check mapped to several tactics must show FAIL(1) in + every tactic, not just the first one seen.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["Persistence", "Execution"])] + ), + "check_b": SimpleNamespace( + Compliance=[_make_compliance("aws", ["Persistence"])] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_mitre_attack_table( + findings, + bulk_metadata, + COMPLIANCE_FRAMEWORK, + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # Both Persistence and Execution must report FAIL(1); before the fix + # Execution was undercounted because the per-tactic count was gated by + # the global dedup list. + assert captured.out.count("FAIL(1)") == 2 + + def test_multi_tactic_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED check mapped to several tactics must increase the + per-tactic Muted count in every tactic, not only the first one.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["Persistence", "Execution"])] + ), + "check_b": SimpleNamespace( + Compliance=[_make_compliance("aws", ["Persistence"])] + ), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + # A second finding is needed so the table is rendered at all. + _make_finding("check_b", "FAIL"), + ] + + get_mitre_attack_table( + findings, + bulk_metadata, + COMPLIANCE_FRAMEWORK, + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = re.sub(r"\x1b\[[0-9;]*m", "", captured.out) + # The muted check belongs to both Persistence and Execution, so the + # Muted column must read 1 in both rows. + muted_cells = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_cells) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched MITRE-ATTACK + compliance, never from a different framework that happens to be the last + entry in the check's compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["Persistence"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["Persistence"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_mitre_attack_table( + findings, + bulk_metadata, + COMPLIANCE_FRAMEWORK, + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + # The provider of the unrelated trailing framework must NOT leak into + # the rendered table. + assert "leaked_provider" not in captured.out diff --git a/tests/lib/outputs/compliance/okta_idaas_stig/__init__.py b/tests/lib/outputs/compliance/okta_idaas_stig/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_okta_test.py b/tests/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_okta_test.py new file mode 100644 index 0000000000..fa616c906e --- /dev/null +++ b/tests/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_okta_test.py @@ -0,0 +1,139 @@ +from datetime import datetime +from io import StringIO +from unittest import mock + +from freezegun import freeze_time +from mock import patch + +from prowler.lib.outputs.compliance.okta_idaas_stig.models import OktaIDaaSSTIGModel +from prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig_okta import ( + OktaIDaaSSTIG, +) +from tests.lib.outputs.compliance.fixtures import OKTA_IDAAS_STIG_OKTA +from tests.lib.outputs.fixtures.fixtures import generate_finding_output + +OKTA_ORG_DOMAIN = "dev-12345.okta.com" + + +class TestOktaIDaaSSTIG: + def test_output_transform(self): + findings = [ + generate_finding_output( + provider="okta", + account_uid=OKTA_ORG_DOMAIN, + account_name=OKTA_ORG_DOMAIN, + region="global", + service_name="signon", + check_id="signon_global_session_idle_timeout_15min", + resource_uid="okta-global-session-policy", + resource_name="Default Policy", + compliance={"Okta-IDaaS-STIG-1R2": ["OKTA-APP-000020"]}, + ) + ] + + output = OktaIDaaSSTIG(findings, OKTA_IDAAS_STIG_OKTA) + output_data = output.data[0] + assert isinstance(output_data, OktaIDaaSSTIGModel) + assert output_data.Provider == "okta" + assert output_data.Framework == OKTA_IDAAS_STIG_OKTA.Framework + assert output_data.Name == OKTA_IDAAS_STIG_OKTA.Name + assert output_data.OrganizationDomain == OKTA_ORG_DOMAIN + assert output_data.Description == OKTA_IDAAS_STIG_OKTA.Description + assert output_data.Requirements_Id == OKTA_IDAAS_STIG_OKTA.Requirements[0].Id + assert ( + output_data.Requirements_Name == OKTA_IDAAS_STIG_OKTA.Requirements[0].Name + ) + assert ( + output_data.Requirements_Description + == OKTA_IDAAS_STIG_OKTA.Requirements[0].Description + ) + assert ( + output_data.Requirements_Attributes_Section + == OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].Section + ) + assert ( + output_data.Requirements_Attributes_Severity + == OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].Severity.value + ) + assert ( + output_data.Requirements_Attributes_RuleID + == OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].RuleID + ) + assert ( + output_data.Requirements_Attributes_StigID + == OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].StigID + ) + assert ( + output_data.Requirements_Attributes_CCI + == OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].CCI + ) + assert ( + output_data.Requirements_Attributes_CheckText + == OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].CheckText + ) + assert ( + output_data.Requirements_Attributes_FixText + == OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].FixText + ) + assert output_data.Status == "PASS" + assert output_data.StatusExtended == "" + assert output_data.ResourceId == "okta-global-session-policy" + assert output_data.ResourceName == "Default Policy" + assert output_data.CheckId == "signon_global_session_idle_timeout_15min" + assert output_data.Muted is False + # Test manual check + output_data_manual = output.data[1] + assert output_data_manual.Provider == "okta" + assert output_data_manual.Framework == OKTA_IDAAS_STIG_OKTA.Framework + assert output_data_manual.Name == OKTA_IDAAS_STIG_OKTA.Name + assert output_data_manual.OrganizationDomain == "" + assert ( + output_data_manual.Requirements_Id + == OKTA_IDAAS_STIG_OKTA.Requirements[1].Id + ) + assert ( + output_data_manual.Requirements_Attributes_Severity + == OKTA_IDAAS_STIG_OKTA.Requirements[1].Attributes[0].Severity.value + ) + assert ( + output_data_manual.Requirements_Attributes_StigID + == OKTA_IDAAS_STIG_OKTA.Requirements[1].Attributes[0].StigID + ) + assert output_data_manual.Status == "MANUAL" + assert output_data_manual.StatusExtended == "Manual check" + assert output_data_manual.ResourceId == "manual_check" + assert output_data_manual.ResourceName == "Manual check" + assert output_data_manual.CheckId == "manual" + assert output_data_manual.Muted is False + + @freeze_time("2025-01-01 00:00:00") + @mock.patch( + "prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig_okta.timestamp", + "2025-01-01 00:00:00", + ) + def test_batch_write_data_to_file(self): + mock_file = StringIO() + findings = [ + generate_finding_output( + provider="okta", + account_uid=OKTA_ORG_DOMAIN, + account_name=OKTA_ORG_DOMAIN, + region="global", + service_name="signon", + check_id="signon_global_session_idle_timeout_15min", + resource_uid="okta-global-session-policy", + resource_name="Default Policy", + compliance={"Okta-IDaaS-STIG-1R2": ["OKTA-APP-000020"]}, + ) + ] + output = OktaIDaaSSTIG(findings, OKTA_IDAAS_STIG_OKTA) + output._file_descriptor = mock_file + + with patch.object(mock_file, "close", return_value=None): + output.batch_write_data_to_file() + + mock_file.seek(0) + content = mock_file.read() + expected_csv = f"PROVIDER;DESCRIPTION;ORGANIZATIONDOMAIN;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_NAME;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SEVERITY;REQUIREMENTS_ATTRIBUTES_RULEID;REQUIREMENTS_ATTRIBUTES_STIGID;REQUIREMENTS_ATTRIBUTES_CCI;REQUIREMENTS_ATTRIBUTES_CHECKTEXT;REQUIREMENTS_ATTRIBUTES_FIXTEXT;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED;FRAMEWORK;NAME\r\nokta;Defense Information Systems Agency (DISA) Security Technical Implementation Guide (STIG) for Okta Identity as a Service (IDaaS).;{OKTA_ORG_DOMAIN};{datetime.now()};OKTA-APP-000020;Okta must log out a session after a 15-minute period of inactivity.;A session timeout lock is a temporary action taken when a user stops work and moves away from the immediate vicinity of the information system.;CAT II (Medium);medium;SV-273186r1098825_rule;OKTA-APP-000020;['CCI-000057', 'CCI-001133'];Verify the Global Session Policy logs out a session after 15 minutes of inactivity.;From the Admin Console configure the Global Session Policy idle timeout to 15 minutes.;PASS;;okta-global-session-policy;Default Policy;signon_global_session_idle_timeout_15min;False;Okta-IDaaS-STIG;DISA Okta Identity as a Service (IDaaS) STIG V1R2\r\nokta;Defense Information Systems Agency (DISA) Security Technical Implementation Guide (STIG) for Okta Identity as a Service (IDaaS).;;{datetime.now()};OKTA-APP-000650;Okta must enforce a minimum 15-character password length.;The shorter the password, the lower the number of possible combinations that need to be tested before the password is compromised.;CAT II (Medium);medium;SV-273209r1098894_rule;OKTA-APP-000650;['CCI-000205'];Verify the password policy enforces a minimum length of 15 characters.;From the Admin Console set the minimum password length to 15 characters.;MANUAL;Manual check;manual_check;Manual check;manual;False;Okta-IDaaS-STIG;DISA Okta Identity as a Service (IDaaS) STIG V1R2\r\n" + + assert content == expected_csv diff --git a/tests/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_table_test.py b/tests/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_table_test.py new file mode 100644 index 0000000000..3017ea4f92 --- /dev/null +++ b/tests/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_table_test.py @@ -0,0 +1,136 @@ +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig import ( + get_okta_idaas_stig_table, +) + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _make_compliance(provider, sections, framework="Okta-IDaaS-STIG"): + """Build a per-check compliance covering the given sections.""" + return SimpleNamespace( + Framework=framework, + Provider=provider, + Requirements=[ + SimpleNamespace(Attributes=[SimpleNamespace(Section=section)]) + for section in sections + ], + ) + + +class TestOktaIDaaSSTIGTable: + """Test cases for Okta IDaaS STIG compliance table rendering.""" + + def test_multi_section_fail_not_undercounted(self, capsys, tmp_path): + """A single FAIL check mapped to several sections must show FAIL(1) in + every section, not just the first one seen.""" + bulk_metadata = { + # check_a belongs to two sections at once. + "check_a": SimpleNamespace( + Compliance=[_make_compliance("okta", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("okta", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_okta_idaas_stig_table( + findings, + bulk_metadata, + "okta_idaas_stig_1r2", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # Both IAM and Logging must report FAIL(1); before the fix Logging + # was undercounted and rendered as plain PASS. + assert captured.out.count("FAIL(1)") == 2 + + def test_multi_section_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED check mapped to several sections must increase the + per-section Muted count in every section, not only the first one.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("okta", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("okta", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + # A real FAIL is needed so the results table is rendered at all. + _make_finding("check_b", "FAIL"), + ] + + get_okta_idaas_stig_table( + findings, + bulk_metadata, + "okta_idaas_stig_1r2", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # The muted check belongs to both IAM and Logging, so the Muted column + # must read 1 in both rows. Before the fix only the first section seen + # was incremented, leaving the second at 0. + # Strip ANSI color codes before counting the bare values per row. + import re + + plain = re.sub(r"\x1b\[[0-9;]*m", "", captured.out) + # Each section row ends with its Muted value in its own cell; both rows + # must carry a Muted count of 1. + muted_cells = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_cells) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched Okta-IDaaS-STIG + compliance, never from a different framework that happens to be the + last entry in the check's compliance list.""" + # check_a maps to Okta-IDaaS-STIG (provider "okta") but its compliance + # list ends with a *different* framework whose provider is "aws". With + # the bug the leaked loop variable made the table render "aws". + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("okta", ["IAM"]), + _make_compliance("aws", ["Other"], framework="OtherFramework"), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("okta", ["IAM"]), + _make_compliance("aws", ["Other"], framework="OtherFramework"), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_okta_idaas_stig_table( + findings, + bulk_metadata, + "okta_idaas_stig_1r2", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "okta" in captured.out + # The provider of the unrelated trailing framework must NOT leak into + # the rendered table. + assert "aws" not in captured.out diff --git a/tests/lib/outputs/compliance/process_universal_test.py b/tests/lib/outputs/compliance/process_universal_test.py index fa8b737ddd..4dc957ed4f 100644 --- a/tests/lib/outputs/compliance/process_universal_test.py +++ b/tests/lib/outputs/compliance/process_universal_test.py @@ -12,6 +12,7 @@ Also validates that print_compliance_frameworks and print_compliance_requirement work with universal ComplianceFramework objects (dict checks, None provider). """ +import csv import json import os from datetime import datetime, timezone @@ -124,6 +125,41 @@ def _make_universal_framework(name="TestFW", version="1.0", with_table_config=Tr ) +def _make_framework_with_manual(name="MixedFW", version="1.0"): + """Framework with one aws-covered requirement and one manual one. + + The manual requirement has no aws checks, so for provider ``aws`` it is + emitted as a manual row/event — used to assert manual requirements are + not duplicated when the writer is reused across streaming batches. + """ + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="Covered requirement", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a"]}, + ), + UniversalComplianceRequirement( + id="2.1", + description="Manual requirement", + attributes={"Section": "GOV"}, + checks={"aws": []}, + ), + ] + metadata = [AttributeMetadata(key="Section", type="str")] + outputs = OutputsConfig(table_config=TableConfig(group_by="Section")) + return ComplianceFramework( + framework=name, + name=f"{name} Framework", + provider="AWS", + version=version, + description="Test framework", + requirements=reqs, + attributes_metadata=metadata, + outputs=outputs, + ) + + # ── Tests ──────────────────────────────────────────────────────────── @@ -728,3 +764,243 @@ class TestIdempotency: # FW1 writer instances unchanged assert second_writers[0] is first_writers[0] assert second_writers[1] is first_writers[1] + + +class TestStreamingBatches: + """Streaming-aware behaviour: ``from_cli`` / ``is_last`` / ``_flush``. + + Regression coverage for the API streaming path where the helper is + invoked once per finding batch: before the fix only the first batch + was written (batches 2..N silently dropped) and manual requirements + were re-emitted on every batch. + """ + + def _run_batches(self, tmp_path, fw, key, batches): + """Invoke the helper once per (findings, is_last) batch, sharing + ``generated_outputs`` so writers are reused like the API does.""" + generated = {"compliance": []} + for findings, is_last in batches: + process_universal_compliance_frameworks( + input_compliance_frameworks={key}, + universal_frameworks={key: fw}, + finding_outputs=findings, + output_directory=str(tmp_path), + output_filename="out", + provider="aws", + generated_outputs=generated, + from_cli=False, + is_last=is_last, + ) + return generated + + def test_defaults_preserve_cli_single_call(self, tmp_path): + """Defaults (``from_cli=True``, ``is_last=True``): a single call + still finalizes a valid, closed OCSF JSON array (CLI unchanged).""" + fw = _make_universal_framework() + generated = {"compliance": []} + process_universal_compliance_frameworks( + input_compliance_frameworks={"test_fw_1.0"}, + universal_frameworks={"test_fw_1.0": fw}, + finding_outputs=[_make_finding("check_a")], + output_directory=str(tmp_path), + output_filename="out", + provider="aws", + generated_outputs=generated, + ) + ocsf_path = tmp_path / "compliance" / "out_test_fw_1.0.ocsf.json" + data = json.loads(ocsf_path.read_text()) + assert isinstance(data, list) and len(data) >= 1 + + def test_multibatch_csv_keeps_every_batch(self, tmp_path): + """Findings from batches 2..N must not be dropped (the bug).""" + fw = _make_universal_framework() + f1 = _make_finding("check_a", status="PASS") + f2 = _make_finding("check_a", status="FAIL") + generated = self._run_batches( + tmp_path, fw, "fw_1.0", [([f1], False), ([f2], True)] + ) + content = (tmp_path / "compliance" / "out_fw_1.0.csv").read_text() + assert "check_a is PASS" in content # batch 1 + assert "check_a is FAIL" in content # batch 2 — regression + # writer reused, not recreated: still just 1 CSV + 1 OCSF + assert len(generated["compliance"]) == 2 + + def test_multibatch_ocsf_valid_array_with_every_batch(self, tmp_path): + """OCSF is a valid (closed) JSON array holding every batch's + events only after the ``is_last=True`` call.""" + fw = _make_universal_framework() + f1 = _make_finding("check_a", status="PASS") + f2 = _make_finding("check_a", status="FAIL") + self._run_batches(tmp_path, fw, "fw_1.0", [([f1], False), ([f2], True)]) + data = json.loads( + (tmp_path / "compliance" / "out_fw_1.0.ocsf.json").read_text() + ) + assert isinstance(data, list) + assert len(data) >= 2 # one event per batch finding + + def test_manual_requirement_not_duplicated_across_batches(self, tmp_path): + """Manual requirement is emitted once (first batch, via __init__), + never re-emitted when the writer is reused (``include_manual=False``).""" + fw = _make_framework_with_manual() + f1 = _make_finding("check_a", status="PASS") + f2 = _make_finding("check_a", status="FAIL") + self._run_batches(tmp_path, fw, "fw_1.0", [([f1], False), ([f2], True)]) + rows = list( + csv.DictReader( + (tmp_path / "compliance" / "out_fw_1.0.csv").read_text().splitlines(), + delimiter=";", + ) + ) + manual_rows = [r for r in rows if r["STATUS"] == "MANUAL"] + assert len(manual_rows) == 1 + assert manual_rows[0]["REQUIREMENTS_ID"] == "2.1" + + ocsf = json.loads( + (tmp_path / "compliance" / "out_fw_1.0.ocsf.json").read_text() + ) + manual_events = [ + e + for e in ocsf + if (e.get("compliance") or {}).get("requirements") == ["2.1"] + ] + assert len(manual_events) == 1 + + def test_writer_reused_not_recreated_across_batches(self, tmp_path): + """Three batches still yield exactly one CSV + one OCSF writer, + and the same instances are reused throughout.""" + fw = _make_universal_framework() + generated = self._run_batches( + tmp_path, + fw, + "fw_1.0", + [ + ([_make_finding("check_a")], False), + ([_make_finding("check_a")], False), + ([_make_finding("check_a")], True), + ], + ) + assert len(generated["compliance"]) == 2 + assert isinstance(generated["compliance"][0], UniversalComplianceOutput) + assert isinstance(generated["compliance"][1], OCSFComplianceOutput) + + def test_label_without_version_still_outputs(self, tmp_path): + """Empty framework version → label is the framework name only; + the helper still produces both artifacts without error.""" + fw = _make_universal_framework(version="") + generated = {"compliance": []} + processed = process_universal_compliance_frameworks( + input_compliance_frameworks={"fw"}, + universal_frameworks={"fw": fw}, + finding_outputs=[_make_finding("check_a")], + output_directory=str(tmp_path), + output_filename="out", + provider="aws", + generated_outputs=generated, + from_cli=False, + is_last=True, + ) + assert processed == {"fw"} + assert len(generated["compliance"]) == 2 + assert (tmp_path / "compliance" / "out_fw.csv").exists() + assert (tmp_path / "compliance" / "out_fw.ocsf.json").exists() + + +def _csa_like_framework() -> ComplianceFramework: + """Build a CSA CCM-style universal framework with checks across providers.""" + requirement = UniversalComplianceRequirement( + id="A&A-01", + description="Audit and Assurance", + attributes={"Section": "Audit"}, + checks={ + "aws": ["aws_check"], + "azure": ["azure_check"], + "gcp": ["gcp_check"], + }, + ) + return ComplianceFramework( + framework="CSA_CCM", + name="CSA Cloud Controls Matrix", + version="4.0", + description="Multi-provider framework", + requirements=[requirement], + attributes_metadata=[AttributeMetadata(key="Section", type="str")], + outputs=OutputsConfig(table_config=TableConfig(group_by="Section")), + ) + + +class TestMultiProviderUniversalFramework: + """A top-level CSA-CCM-style framework produces a CSV+OCSF pair scoped + to the provider it is invoked with.""" + + @pytest.mark.parametrize( + "provider,check_id", + [ + ("aws", "aws_check"), + ("azure", "azure_check"), + ("gcp", "gcp_check"), + ], + ) + def test_per_provider_outputs_isolated(self, tmp_path, provider, check_id): + framework = _csa_like_framework() + generated = {"compliance": []} + + process_universal_compliance_frameworks( + input_compliance_frameworks={"csa_ccm_4.0"}, + universal_frameworks={"csa_ccm_4.0": framework}, + finding_outputs=[_make_finding(check_id, provider=provider)], + output_directory=str(tmp_path), + output_filename="prowler_output", + provider=provider, + generated_outputs=generated, + ) + + ocsf_path = tmp_path / "compliance" / "prowler_output_csa_ccm_4.0.ocsf.json" + events = json.loads(ocsf_path.read_text()) + assert isinstance(events, list) + non_manual = [event for event in events if event.get("status_code") != "MANUAL"] + assert len(non_manual) == 1 + assert non_manual[0]["compliance"]["checks"][0]["uid"] == check_id + + +class TestMitreStyleOCSFOutput: + """MITRE attrs wrapped as `{"_raw_attributes": [...]}` must not leak + the marker key through the OCSF pipeline.""" + + def test_mitre_raw_attributes_pass_through_pipeline(self, tmp_path): + mitre_requirement = UniversalComplianceRequirement( + id="T1078", + description="Valid Accounts", + attributes={ + "_raw_attributes": [{"AWSService": "IAM", "Category": "Initial Access"}] + }, + checks={"aws": ["check_a"]}, + ) + framework = ComplianceFramework( + framework="MITRE", + name="MITRE ATT&CK", + version="14", + description="Mitre", + requirements=[mitre_requirement], + outputs=OutputsConfig(table_config=TableConfig(group_by="AWSService")), + ) + generated = {"compliance": []} + + process_universal_compliance_frameworks( + input_compliance_frameworks={"mitre_attack_aws"}, + universal_frameworks={"mitre_attack_aws": framework}, + finding_outputs=[_make_finding("check_a", "PASS")], + output_directory=str(tmp_path), + output_filename="out", + provider="aws", + generated_outputs=generated, + ) + + ocsf_path = tmp_path / "compliance" / "out_mitre_attack_aws.ocsf.json" + events = json.loads(ocsf_path.read_text()) + assert isinstance(events, list) and len(events) >= 1 + for event in events: + requirement_attrs = (event.get("unmapped") or {}).get( + "requirement_attributes", {} + ) + assert "_raw_attributes" not in requirement_attrs + assert "raw_attributes" not in requirement_attrs diff --git a/tests/lib/outputs/compliance/prowler_threatscore/__init__.py b/tests/lib/outputs/compliance/prowler_threatscore/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_table_test.py b/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_table_test.py new file mode 100644 index 0000000000..d754395dc3 --- /dev/null +++ b/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_table_test.py @@ -0,0 +1,147 @@ +import re +from types import SimpleNamespace +from unittest import mock + +from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore import ( + get_prowler_threatscore_table, +) + +# Patch target for the Compliance.get_bulk lookup used to render pillars without +# findings; the tests don't exercise that path so it returns nothing. +COMPLIANCE_PATH = ( + "prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore.Compliance" +) + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _make_compliance(provider, pillars, framework="ProwlerThreatScore"): + """Build a per-check compliance covering the given pillars (Section).""" + return SimpleNamespace( + Framework=framework, + Provider=provider, + Requirements=[ + SimpleNamespace( + Attributes=[SimpleNamespace(Section=pillar, LevelOfRisk=5, Weight=100)] + ) + for pillar in pillars + ], + ) + + +class TestProwlerThreatScoreTable: + """Verify multi-section counting and provider-column attribution for the compliance table.""" + + def test_multi_pillar_fail_not_undercounted(self, capsys, tmp_path): + """A single FAIL check mapped to several pillars must show FAIL(1) in + every pillar, not just the first one seen.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Encryption"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + with mock.patch(COMPLIANCE_PATH) as compliance_mock: + compliance_mock.get_bulk.return_value = {} + get_prowler_threatscore_table( + findings, + bulk_metadata, + "prowler_threatscore_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # Both IAM and Encryption must report FAIL(1); before the fix Encryption + # was undercounted because the per-pillar count was gated by the global + # dedup list. + assert captured.out.count("FAIL(1)") == 2 + + def test_multi_pillar_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED check mapped to several pillars must increase the + per-pillar Muted count in every pillar, not only the first one.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Encryption"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + # A real FAIL is needed so the results table is rendered at all. + _make_finding("check_b", "FAIL"), + ] + + with mock.patch(COMPLIANCE_PATH) as compliance_mock: + compliance_mock.get_bulk.return_value = {} + get_prowler_threatscore_table( + findings, + bulk_metadata, + "prowler_threatscore_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = re.sub(r"\x1b\[[0-9;]*m", "", captured.out) + # The muted check belongs to both IAM and Encryption, so the Muted + # column must read 1 in both rows. + muted_cells = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_cells) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched ProwlerThreatScore + compliance, never from a different framework that happens to be the last + entry in the check's compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + with mock.patch(COMPLIANCE_PATH) as compliance_mock: + compliance_mock.get_bulk.return_value = {} + get_prowler_threatscore_table( + findings, + bulk_metadata, + "prowler_threatscore_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + # The provider of the unrelated trailing framework must NOT leak into + # the rendered table. + assert "leaked_provider" not in captured.out diff --git a/tests/lib/outputs/compliance/universal/ocsf_compliance_test.py b/tests/lib/outputs/compliance/universal/ocsf_compliance_test.py index 71429fc1ca..db6b78de28 100644 --- a/tests/lib/outputs/compliance/universal/ocsf_compliance_test.py +++ b/tests/lib/outputs/compliance/universal/ocsf_compliance_test.py @@ -202,6 +202,26 @@ class TestOCSFComplianceOutput: assert cf.status_code == "MANUAL" assert cf.finding_info.uid == "manual-MANUAL-1" + def test_include_manual_false_skips_manual(self): + """``_transform(..., include_manual=False)`` emits check events but + NOT manual requirement events. The streaming caller passes ``False`` + for batches 2..N so manual events are not duplicated.""" + covered = _simple_requirement("REQ-1", ["check_a"]) + manual = _simple_requirement("MANUAL-1", checks=[]) + fw = _make_framework([covered, manual]) + findings = [_make_finding("check_a")] + + output = OCSFComplianceOutput(findings=findings, framework=fw, provider="aws") + # __init__ transforms with include_manual=True (default) → manual present + assert any(cf.status_code == "MANUAL" for cf in output.data) + + # A subsequent batch re-transforms with include_manual=False + output._data.clear() + output._transform(findings, fw, "TestFW-1.0", include_manual=False) + + assert len(output.data) == 1 # only the check event, no manual + assert all(cf.status_code != "MANUAL" for cf in output.data) + def test_multi_provider_checks_dict(self): req = UniversalComplianceRequirement( id="REQ-1", @@ -631,3 +651,103 @@ class TestNoTopLevelOCSFImport: import prowler.lib.outputs.compliance.universal.ocsf_compliance as mod assert "OCSF" not in dir(mod) + + +def _mitre_requirement(req_id="T1078", entries=None): + """Build a MITRE-style requirement with `_raw_attributes` wrapping.""" + return UniversalComplianceRequirement( + id=req_id, + description="Valid Accounts", + attributes={ + "_raw_attributes": entries + or [{"AWSService": "IAM", "Category": "Initial Access"}] + }, + checks={"aws": ["check_a"]}, + ) + + +class TestMitreRawAttributes: + """MITRE attrs wrapped as `{"_raw_attributes": [...]}` must not leak + the marker key into the OCSF payload.""" + + def test_raw_attributes_key_not_in_unmapped(self): + framework = _make_framework([_mitre_requirement()]) + findings = [_make_finding("check_a", "PASS")] + + output = OCSFComplianceOutput( + findings=findings, framework=framework, provider="aws" + ) + + requirement_attrs = (output.data[0].unmapped or {}).get( + "requirement_attributes", {} + ) + assert "_raw_attributes" not in requirement_attrs + assert "raw_attributes" not in requirement_attrs + + def test_finding_serializes_with_raw_attributes(self): + framework = _make_framework( + [ + _mitre_requirement( + entries=[ + {"AWSService": "IAM", "Category": "Initial Access"}, + {"AWSService": "STS", "Category": "Privilege Escalation"}, + ] + ) + ] + ) + findings = [_make_finding("check_a", "PASS")] + + output = OCSFComplianceOutput( + findings=findings, framework=framework, provider="aws" + ) + compliance_finding = output.data[0] + if hasattr(compliance_finding, "model_dump_json"): + payload = json.loads(compliance_finding.model_dump_json(exclude_none=True)) + else: + payload = json.loads(compliance_finding.json(exclude_none=True)) + assert payload["compliance"]["requirements"] == ["T1078"] + + +class TestProviderFiltering: + """OCSF writer scopes findings against `requirement.checks[provider]`.""" + + def test_check_for_other_provider_not_emitted(self): + azure_only_requirement = UniversalComplianceRequirement( + id="REQ-1", + description="Azure-only requirement", + attributes={}, + checks={"azure": ["check_a"]}, + ) + framework = _make_framework([azure_only_requirement]) + findings = [_make_finding("check_a", "PASS", provider="aws")] + + output = OCSFComplianceOutput( + findings=findings, framework=framework, provider="aws" + ) + + assert all( + compliance_finding.status_code == "MANUAL" + for compliance_finding in output.data + ) + + def test_no_provider_aggregates_all_checks(self): + multi_provider_requirement = UniversalComplianceRequirement( + id="REQ-1", + description="Multi-provider requirement", + attributes={}, + checks={"aws": ["check_a"], "azure": ["check_b"]}, + ) + framework = _make_framework([multi_provider_requirement]) + findings = [ + _make_finding("check_a", "PASS", provider="aws"), + _make_finding("check_b", "FAIL", provider="azure"), + ] + + output = OCSFComplianceOutput( + findings=findings, framework=framework, provider=None + ) + + statuses = sorted( + compliance_finding.status_code for compliance_finding in output.data + ) + assert statuses == ["FAIL", "PASS"] diff --git a/tests/lib/outputs/compliance/universal/universal_output_test.py b/tests/lib/outputs/compliance/universal/universal_output_test.py index 391a9015d7..d0fdf0f178 100644 --- a/tests/lib/outputs/compliance/universal/universal_output_test.py +++ b/tests/lib/outputs/compliance/universal/universal_output_test.py @@ -122,6 +122,43 @@ class TestManualRequirements: assert manual_rows[0].dict()["Requirements_Id"] == "manual-1" assert manual_rows[0].dict()["ResourceId"] == "manual_check" + def test_include_manual_false_skips_manual_rows(self, tmp_path): + """``_transform(..., include_manual=False)`` emits finding rows but + NOT manual requirements. The streaming caller passes ``False`` for + batches 2..N so manual rows are not duplicated across batches.""" + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a"]}, + ), + UniversalComplianceRequirement( + id="manual-1", + description="manual check", + attributes={"Section": "Governance"}, + checks={}, + ), + ] + metadata = [AttributeMetadata(key="Section", type="str")] + fw = _make_framework(reqs, metadata, TableConfig(group_by="Section")) + findings = [_make_finding("check_a", "PASS", {"TestFW-1.0": ["1.1"]})] + + output = UniversalComplianceOutput( + findings=findings, + framework=fw, + file_path=str(tmp_path / "t.csv"), + ) + # __init__ transforms with include_manual=True (default) → manual present + assert any(r.dict()["Status"] == "MANUAL" for r in output.data) + + # A subsequent batch re-transforms with include_manual=False + output._data.clear() + output._transform(findings, fw, "TestFW-1.0", include_manual=False) + + assert len(output.data) == 1 # only the finding row, no manual + assert all(r.dict()["Status"] != "MANUAL" for r in output.data) + class TestMITREExtraColumns: def test_mitre_columns_present(self, tmp_path): diff --git a/tests/lib/outputs/compliance/universal/universal_table_test.py b/tests/lib/outputs/compliance/universal/universal_table_test.py index 7598c43c8b..abe3d0c3d0 100644 --- a/tests/lib/outputs/compliance/universal/universal_table_test.py +++ b/tests/lib/outputs/compliance/universal/universal_table_test.py @@ -1,3 +1,4 @@ +import re from types import SimpleNamespace from unittest.mock import MagicMock @@ -26,6 +27,10 @@ def _make_finding(check_id, status="PASS", muted=False): return finding +def _strip_ansi(text): + return re.sub(r"\x1b\[[0-9;]*m", "", text) + + def _make_framework(requirements, table_config, provider="AWS"): return ComplianceFramework( framework="TestFW", @@ -39,6 +44,8 @@ def _make_framework(requirements, table_config, provider="AWS"): class TestBuildRequirementCheckMap: + """Test cases for building the requirement-to-check map of a framework.""" + def test_basic(self): reqs = [ UniversalComplianceRequirement( @@ -103,6 +110,8 @@ class TestBuildRequirementCheckMap: class TestGetGroupKey: + """Test cases for resolving the group key of a requirement.""" + def test_normal_field(self): req = UniversalComplianceRequirement( id="1.1", @@ -124,7 +133,9 @@ class TestGetGroupKey: class TestGroupedMode: - def test_grouped_rendering(self, capsys): + """Test cases for grouped-mode universal compliance table rendering.""" + + def test_grouped_rendering(self, capsys, tmp_path): reqs = [ UniversalComplianceRequirement( id="1.1", @@ -156,7 +167,7 @@ class TestGroupedMode: bulk_metadata, "test_fw", "output", - "/tmp", + str(tmp_path), False, framework=fw, ) @@ -167,9 +178,118 @@ class TestGroupedMode: assert "PASS" in captured.out assert "FAIL" in captured.out + def test_grouped_multi_section_no_undercount(self, capsys, tmp_path): + """A single check mapped to several sections must be counted in + every section it belongs to, not only the first one seen.""" + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a", "check_b"]}, + ), + UniversalComplianceRequirement( + id="2.1", + description="test2", + attributes={"Section": "Logging"}, + checks={"aws": ["check_a"]}, + ), + ] + tc = TableConfig(group_by="Section") + fw = _make_framework(reqs, tc) + + # check_a (FAIL) belongs to both IAM and Logging sections; check_b + # (PASS, IAM only) is added so the overview total reaches 2 and the + # results table is rendered. + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + bulk_metadata = { + "check_a": MagicMock(Compliance=[]), + "check_b": MagicMock(Compliance=[]), + } + + get_universal_table( + findings, + bulk_metadata, + "test_fw", + "output", + str(tmp_path), + False, + framework=fw, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # Both the IAM and Logging rows must report FAIL(1). Before the fix the + # second section seen (Logging) was undercounted to FAIL(0) and rendered + # as PASS. Anchor each occurrence to its own table row so an unrelated + # "FAIL(1)" elsewhere cannot mask an undercount. + iam_row = [ + line for line in plain.splitlines() if "IAM" in line and "FAIL(1)" in line + ] + logging_row = [ + line + for line in plain.splitlines() + if "Logging" in line and "FAIL(1)" in line + ] + assert len(iam_row) == 1 + assert len(logging_row) == 1 + + def test_grouped_multi_section_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED finding mapped to several groups must be counted in + the per-group Muted column of every group it belongs to.""" + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a", "check_b"]}, + ), + UniversalComplianceRequirement( + id="2.1", + description="test2", + attributes={"Section": "Logging"}, + checks={"aws": ["check_a"]}, + ), + ] + tc = TableConfig(group_by="Section") + fw = _make_framework(reqs, tc) + + # check_a is MUTED and belongs to both IAM and Logging; check_b is a + # plain FAIL so the overview total reaches 2 and the table is rendered. + findings = [ + _make_finding("check_a", "FAIL", muted=True), + _make_finding("check_b", "FAIL"), + ] + bulk_metadata = { + "check_a": MagicMock(Compliance=[]), + "check_b": MagicMock(Compliance=[]), + } + + get_universal_table( + findings, + bulk_metadata, + "test_fw", + "output", + str(tmp_path), + False, + framework=fw, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # The muted finding belongs to both sections, so both the IAM row and + # the Logging row must carry a Muted count of 1 in their last cell. + muted_one_rows = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_one_rows) == 2 + class TestSplitMode: - def test_split_rendering(self, capsys): + """Test cases for split-mode universal compliance table rendering.""" + + def test_split_rendering(self, capsys, tmp_path): reqs = [ UniversalComplianceRequirement( id="1.1", @@ -204,7 +324,7 @@ class TestSplitMode: bulk_metadata, "test_fw", "output", - "/tmp", + str(tmp_path), False, framework=fw, ) @@ -214,9 +334,119 @@ class TestSplitMode: assert "Level 1" in captured.out assert "Level 2" in captured.out + def test_split_muted_multi_section_not_undercounted(self, capsys, tmp_path): + """In split mode a single MUTED finding mapped to several groups must + be counted in the Muted column of every group it belongs to.""" + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "Storage", "Profile": "Level 1"}, + checks={"aws": ["check_a", "check_b"]}, + ), + UniversalComplianceRequirement( + id="2.1", + description="test2", + attributes={"Section": "Logging", "Profile": "Level 1"}, + checks={"aws": ["check_a"]}, + ), + ] + tc = TableConfig( + group_by="Section", + split_by=SplitByConfig(field="Profile", values=["Level 1", "Level 2"]), + ) + fw = _make_framework(reqs, tc) + + # check_a is MUTED and belongs to both Storage and Logging; check_b is a + # plain FAIL so the table is rendered. + findings = [ + _make_finding("check_a", "FAIL", muted=True), + _make_finding("check_b", "FAIL"), + ] + bulk_metadata = { + "check_a": MagicMock(Compliance=[]), + "check_b": MagicMock(Compliance=[]), + } + + get_universal_table( + findings, + bulk_metadata, + "test_fw", + "output", + str(tmp_path), + False, + framework=fw, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # Both section rows must carry a Muted count of 1 (last cell). Before the + # fix only the first group seen incremented Muted, leaving the other 0. + muted_one_rows = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_one_rows) == 2 + + def test_split_same_group_value_not_double_counted(self, capsys, tmp_path): + """A single finding whose check maps to several requirements that share + the same group and split value must count once for that group/split, + not once per requirement (FAIL(1), never FAIL(2)).""" + reqs = [ + # check_a appears in two requirements, both Storage / Level 1. + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "Storage", "Profile": "Level 1"}, + checks={"aws": ["check_a"]}, + ), + UniversalComplianceRequirement( + id="1.2", + description="test2", + attributes={"Section": "Storage", "Profile": "Level 1"}, + checks={"aws": ["check_a"]}, + ), + # A second group so the table renders with more than one finding. + UniversalComplianceRequirement( + id="2.1", + description="test3", + attributes={"Section": "Logging", "Profile": "Level 1"}, + checks={"aws": ["check_b"]}, + ), + ] + tc = TableConfig( + group_by="Section", + split_by=SplitByConfig(field="Profile", values=["Level 1", "Level 2"]), + ) + fw = _make_framework(reqs, tc) + + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + bulk_metadata = { + "check_a": MagicMock(Compliance=[]), + "check_b": MagicMock(Compliance=[]), + } + + get_universal_table( + findings, + bulk_metadata, + "test_fw", + "output", + str(tmp_path), + False, + framework=fw, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # The Storage row must show FAIL(1) for Level 1, never FAIL(2). + assert "FAIL(1)" in plain + assert "FAIL(2)" not in plain + class TestScoredMode: - def test_scored_rendering(self, capsys): + """Test cases for scored-mode universal compliance table rendering.""" + + def test_scored_rendering(self, capsys, tmp_path): reqs = [ UniversalComplianceRequirement( id="1.1", @@ -251,7 +481,7 @@ class TestScoredMode: bulk_metadata, "test_fw", "output", - "/tmp", + str(tmp_path), False, framework=fw, ) @@ -261,9 +491,68 @@ class TestScoredMode: assert "Score" in captured.out assert "Threat Score" in captured.out + def test_scored_multi_section_fail_not_undercounted(self, capsys, tmp_path): + """In scored mode a single FAIL finding mapped to several groups must + show FAIL(1) in every group it belongs to, not only the first one.""" + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM", "LevelOfRisk": 5, "Weight": 100}, + checks={"aws": ["check_a", "check_b"]}, + ), + UniversalComplianceRequirement( + id="2.1", + description="test2", + attributes={"Section": "Logging", "LevelOfRisk": 3, "Weight": 50}, + checks={"aws": ["check_a"]}, + ), + ] + tc = TableConfig( + group_by="Section", + scoring=ScoringConfig(risk_field="LevelOfRisk", weight_field="Weight"), + ) + fw = _make_framework(reqs, tc) + + # check_a (FAIL) belongs to both IAM and Logging; check_b (PASS, IAM + # only) raises the overview total to 2 so the table is rendered. + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + bulk_metadata = { + "check_a": MagicMock(Compliance=[]), + "check_b": MagicMock(Compliance=[]), + } + + get_universal_table( + findings, + bulk_metadata, + "test_fw", + "output", + str(tmp_path), + False, + framework=fw, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + iam_row = [ + line for line in plain.splitlines() if "IAM" in line and "FAIL(1)" in line + ] + logging_row = [ + line + for line in plain.splitlines() + if "Logging" in line and "FAIL(1)" in line + ] + assert len(iam_row) == 1 + assert len(logging_row) == 1 + class TestCustomLabels: - def test_ens_spanish_labels(self, capsys): + """Test cases for custom-label universal compliance table rendering.""" + + def test_ens_spanish_labels(self, capsys, tmp_path): reqs = [ UniversalComplianceRequirement( id="1.1", @@ -300,7 +589,7 @@ class TestCustomLabels: bulk_metadata, "test_fw", "output", - "/tmp", + str(tmp_path), False, framework=fw, ) @@ -311,7 +600,9 @@ class TestCustomLabels: class TestMultiProviderDictChecks: - def test_only_aws_checks_matched(self, capsys): + """Test cases for multi-provider dict checks in the universal table.""" + + def test_only_aws_checks_matched(self, capsys, tmp_path): """With dict checks and provider='aws', only AWS checks match findings.""" reqs = [ UniversalComplianceRequirement( @@ -352,7 +643,7 @@ class TestMultiProviderDictChecks: bulk_metadata, "multi_cloud", "output", - "/tmp", + str(tmp_path), False, framework=fw, provider="aws", @@ -366,7 +657,9 @@ class TestMultiProviderDictChecks: class TestNoTableConfig: - def test_returns_early_without_table_config(self, capsys): + """Test cases for the universal table when no table config is present.""" + + def test_returns_early_without_table_config(self, capsys, tmp_path): fw = ComplianceFramework( framework="TestFW", name="Test", @@ -374,11 +667,11 @@ class TestNoTableConfig: description="Test", requirements=[], ) - get_universal_table([], {}, "test", "out", "/tmp", False, framework=fw) + get_universal_table([], {}, "test", "out", str(tmp_path), False, framework=fw) captured = capsys.readouterr() assert captured.out == "" - def test_returns_early_without_framework(self, capsys): - get_universal_table([], {}, "test", "out", "/tmp", False, framework=None) + def test_returns_early_without_framework(self, capsys, tmp_path): + get_universal_table([], {}, "test", "out", str(tmp_path), False, framework=None) captured = capsys.readouterr() assert captured.out == "" diff --git a/tests/lib/outputs/jira/jira_test.py b/tests/lib/outputs/jira/jira_test.py index 03656891c8..76352cb297 100644 --- a/tests/lib/outputs/jira/jira_test.py +++ b/tests/lib/outputs/jira/jira_test.py @@ -339,6 +339,88 @@ class TestJiraIntegration: with pytest.raises(JiraRefreshTokenError): self.jira_integration.refresh_access_token() + @patch("prowler.lib.outputs.jira.jira.requests.post") + @patch.object(Jira, "get_cloud_id", return_value="test_cloud_id") + def test_get_auth_sends_timeout(self, mock_get_cloud_id, mock_post): + """get_auth must pass a request timeout to avoid hanging on an unresponsive Jira.""" + # To disable vulture + mock_get_cloud_id = mock_get_cloud_id + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "access_token": "test_access_token", + "refresh_token": "test_refresh_token", + "expires_in": 3600, + } + mock_post.return_value = mock_response + + self.jira_integration.get_auth("test_auth_code") + + assert mock_post.call_args.kwargs["timeout"] == Jira.REQUEST_TIMEOUT + + @patch("prowler.lib.outputs.jira.jira.requests.get") + def test_get_cloud_id_sends_timeout(self, mock_get): + """get_cloud_id (OAuth path) must pass a request timeout.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = [{"id": "test_cloud_id"}] + mock_get.return_value = mock_response + + self.jira_integration.get_cloud_id("test_access_token") + + assert mock_get.call_args.kwargs["timeout"] == Jira.REQUEST_TIMEOUT + + @patch("prowler.lib.outputs.jira.jira.requests.get") + def test_get_cloud_id_basic_auth_sends_timeout(self, mock_get): + """get_cloud_id (basic-auth tenant_info path) must pass a request timeout.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"cloudId": "test_cloud_id"} + mock_get.return_value = mock_response + + self.jira_integration_basic_auth.get_cloud_id(domain=self.domain) + + assert mock_get.call_args.kwargs["timeout"] == Jira.REQUEST_TIMEOUT + + @patch("prowler.lib.outputs.jira.jira.requests.post") + def test_refresh_access_token_sends_timeout(self, mock_post): + """refresh_access_token must pass a request timeout.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "access_token": "new_access_token", + "refresh_token": "new_refresh_token", + "expires_in": 3600, + } + mock_post.return_value = mock_response + + self.jira_integration.refresh_access_token() + + assert mock_post.call_args.kwargs["timeout"] == Jira.REQUEST_TIMEOUT + + @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_projects_sends_timeout( + self, mock_get, mock_cloud_id, mock_get_access_token + ): + """get_projects must pass a request timeout.""" + # To disable vulture + mock_cloud_id = mock_cloud_id + mock_get_access_token = mock_get_access_token + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = [{"key": "PROJ1", "name": "Project One"}] + mock_get.return_value = mock_response + + self.jira_integration.get_projects() + + assert mock_get.call_args.kwargs["timeout"] == Jira.REQUEST_TIMEOUT + @patch.object(Jira, "get_auth", return_value=None) @patch.object( Jira, @@ -1004,6 +1086,89 @@ class TestJiraIntegration: for mark in node.get("marks", []) ) + @staticmethod + def _find_empty_text_nodes(node) -> List[str]: + # ADF forbids empty text nodes; collect any to assert the document is valid. + empties: List[str] = [] + + def walk(current) -> None: + if isinstance(current, dict): + if current.get("type") == "text" and current.get("text", "") == "": + empties.append(current.get("text", "")) + for value in current.values(): + walk(value) + elif isinstance(current, list): + for item in current: + walk(item) + + walk(node) + return empties + + def test_get_adf_description_empty_resource_name_has_no_empty_text_nodes(self): + # A resource without a name (e.g. an AWS-managed IAM policy) used to emit an + # empty ADF text node, making Jira reject the issue with 400 INVALID_INPUT. + adf_description = self.jira_integration.get_adf_description( + check_id="CHECK-1", + check_title="Sample check", + severity="CRITICAL", + severity_color="#FF0000", + status="FAIL", + status_color="#FF0000", + status_extended="Some status", + provider="aws", + region="eu-west-1", + resource_uid="arn:aws:iam::aws:policy/AdministratorAccess", + resource_name="", + recommendation_text="", + ) + + assert self._find_empty_text_nodes(adf_description) == [] + + table = adf_description["content"][1] + resource_name_row = self._find_table_row(table["content"], "Resource Name") + value_cell = resource_name_row["content"][1] + assert self._collect_text_from_cell(value_cell) == "-" + + @pytest.mark.parametrize( + "field, header", + [ + ("check_id", "Check Id"), + ("check_title", "Check Title"), + ("status_extended", "Status Extended"), + ("provider", "Provider"), + ("region", "Region"), + ("resource_uid", "Resource UID"), + ("resource_name", "Resource Name"), + ], + ) + def test_get_adf_description_empty_plain_text_fields_render_placeholder( + self, field, header + ): + base_kwargs = dict( + check_id="CHECK-1", + check_title="Sample check", + severity="HIGH", + severity_color="#FF0000", + status="FAIL", + status_color="#00FF00", + status_extended="Some status", + provider="aws", + region="us-east-1", + resource_uid="resource-1", + resource_name="resource-name", + recommendation_text="", + ) + base_kwargs[field] = "" + + adf_description = self.jira_integration.get_adf_description(**base_kwargs) + + assert self._find_empty_text_nodes(adf_description) == [] + + table = adf_description["content"][1] + row = self._find_table_row(table["content"], header) + value_cell = row["content"][1] + assert self._collect_text_from_cell(value_cell) == "-" + @patch.object(Jira, "get_access_token", return_value="valid_access_token") @patch.object( Jira, "get_available_issue_types", return_value=["Bug", "Task", "Story"] diff --git a/tests/lib/outputs/ocsf/ocsf_test.py b/tests/lib/outputs/ocsf/ocsf_test.py index 16ff1ce317..f449fd49f7 100644 --- a/tests/lib/outputs/ocsf/ocsf_test.py +++ b/tests/lib/outputs/ocsf/ocsf_test.py @@ -2,8 +2,10 @@ import json from datetime import datetime, timezone from io import StringIO from typing import Optional +from unittest.mock import MagicMock from uuid import UUID +import pytest import requests from freezegun import freeze_time from mock import patch @@ -300,6 +302,36 @@ class TestOCSF: def test_batch_write_data_to_file_without_findings(self): assert not OCSF([])._file_descriptor + def test_batch_write_data_to_file_propagates_oserror(self): + """An I/O error (e.g. ENOSPC) while writing a finding must propagate + instead of being swallowed, so the caller can fail fast.""" + findings = [ + generate_finding_output( + status="FAIL", + severity="low", + muted=False, + region=AWS_REGION_EU_WEST_1, + timestamp=datetime.now(), + resource_details="resource_details", + resource_name="resource_name", + resource_uid="resource-id", + status_extended="status extended", + ) + ] + + output = OCSF(findings) + mock_file = MagicMock() + mock_file.closed = False + # Non-zero so the "[" prelude is skipped and the failure happens on the + # per-finding write, the exact path that hit ENOSPC in production. + mock_file.tell.return_value = 1 + mock_file.write.side_effect = OSError(28, "No space left on device") + output._file_descriptor = mock_file + + with pytest.raises(OSError) as excinfo: + output.batch_write_data_to_file() + assert excinfo.value.errno == 28 + def test_finding_output_cloud_pass_low_muted(self): finding_output = generate_finding_output( status="PASS", diff --git a/tests/lib/outputs/outputs_test.py b/tests/lib/outputs/outputs_test.py index 87901bd290..ca18717847 100644 --- a/tests/lib/outputs/outputs_test.py +++ b/tests/lib/outputs/outputs_test.py @@ -51,9 +51,7 @@ class TestOutputs: def test_parse_html_string(self): string = "CISA: your-systems-3, your-data-1, your-data-2 | CIS-1.4: 2.1.1 | CIS-1.5: 2.1.1 | GDPR: article_32 | AWS-Foundational-Security-Best-Practices: s3 | HIPAA: 164_308_a_1_ii_b, 164_308_a_4_ii_a, 164_312_a_2_iv, 164_312_c_1, 164_312_c_2, 164_312_e_2_ii | GxP-21-CFR-Part-11: 11.10-c, 11.30 | GxP-EU-Annex-11: 7.1-data-storage-damage-protection | NIST-800-171-Revision-2: 3_3_8, 3_5_10, 3_13_11, 3_13_16 | NIST-800-53-Revision-4: sc_28 | NIST-800-53-Revision-5: au_9_3, cm_6_a, cm_9_b, cp_9_d, cp_9_8, pm_11_b, sc_8_3, sc_8_4, sc_13_a, sc_16_1, sc_28_1, si_19_4 | ENS-RD2022: mp.si.2.aws.s3.1 | NIST-CSF-1.1: ds_1 | RBI-Cyber-Security-Framework: annex_i_1_3 | FFIEC: d3-pc-am-b-12 | PCI-3.2.1: s3 | FedRamp-Moderate-Revision-4: sc-13, sc-28 | FedRAMP-Low-Revision-4: sc-13 | KISA-ISMS-P-2023: 2.6.1 | KISA-ISMS-P-2023-korean: 2.6.1" - assert ( - parse_html_string(string) - == """ + assert parse_html_string(string) == """ •CISA: your-systems-3, your-data-1, your-data-2 •CIS-1.4: 2.1.1 @@ -94,7 +92,6 @@ class TestOutputs: •KISA-ISMS-P-2023-korean: 2.6.1 """ - ) def test_unroll_tags(self): dict_list = [ diff --git a/tests/lib/scan/scan_test.py b/tests/lib/scan/scan_test.py index b0fe82b7b2..8037668b10 100644 --- a/tests/lib/scan/scan_test.py +++ b/tests/lib/scan/scan_test.py @@ -51,7 +51,7 @@ def mock_provider(): def mock_execute(): with mock.patch("prowler.lib.scan.scan.execute", autospec=True) as mock_exec: findings = [finding] - mock_exec.side_effect = lambda *args, **kwargs: findings + mock_exec.side_effect = lambda *_args, **_kwargs: findings yield mock_exec @@ -264,10 +264,10 @@ class TestScan: @patch("prowler.lib.scan.scan.update_checks_metadata_with_compliance") @patch("prowler.lib.scan.scan.Compliance.get_bulk") @patch("prowler.lib.scan.scan.CheckMetadata.get_bulk") - @patch("prowler.lib.scan.scan.import_check") + @patch("prowler.lib.scan.scan._resolve_check_module") def test_scan( self, - mock_import_check, + mock_resolve_check_module, mock_get_bulk, mock_compliance_get_bulk, mock_update_checks_metadata, @@ -285,7 +285,7 @@ class TestScan: mock_check_instance.CheckTitle = "Check if IAM Access Analyzer is enabled" mock_check_instance.Categories = [] - mock_import_check.return_value = MagicMock( + mock_resolve_check_module.return_value = MagicMock( accessanalyzer_enabled=mock_check_class ) diff --git a/tests/providers/aws/lib/ip_ranges/ip_ranges_test.py b/tests/providers/aws/lib/ip_ranges/ip_ranges_test.py new file mode 100644 index 0000000000..bce53165d6 --- /dev/null +++ b/tests/providers/aws/lib/ip_ranges/ip_ranges_test.py @@ -0,0 +1,112 @@ +import json +import urllib.error +from ipaddress import IPv4Network, IPv6Network, ip_address +from unittest.mock import MagicMock, patch + +from prowler.providers.aws.lib.ip_ranges.ip_ranges import get_public_ip_networks + +URLOPEN_TARGET = "prowler.providers.aws.lib.ip_ranges.ip_ranges.urllib.request.urlopen" + +SAMPLE_RANGES = { + "prefixes": [ + {"ip_prefix": "54.152.0.0/16", "service": "AMAZON"}, + {"ip_prefix": "3.5.140.0/22", "service": "S3"}, + ], + "ipv6_prefixes": [ + {"ipv6_prefix": "2600:1f00::/24", "service": "AMAZON"}, + ], +} + + +def mock_urlopen(payload): + response = MagicMock() + response.read.return_value = json.dumps(payload).encode() + context_manager = MagicMock() + context_manager.__enter__.return_value = response + context_manager.__exit__.return_value = False + return context_manager + + +class TestGetPublicIPNetworks: + def test_parses_ipv4_and_ipv6_prefixes(self): + with patch(URLOPEN_TARGET, return_value=mock_urlopen(SAMPLE_RANGES)): + networks = get_public_ip_networks() + + assert networks == [ + IPv4Network("54.152.0.0/16"), + IPv4Network("3.5.140.0/22"), + IPv6Network("2600:1f00::/24"), + ] + + def test_known_aws_ip_is_contained(self): + with patch(URLOPEN_TARGET, return_value=mock_urlopen(SAMPLE_RANGES)): + networks = get_public_ip_networks() + + assert any(ip_address("54.152.12.70") in network for network in networks) + + def test_external_ip_is_not_contained(self): + with patch(URLOPEN_TARGET, return_value=mock_urlopen(SAMPLE_RANGES)): + networks = get_public_ip_networks() + + assert not any(ip_address("17.5.7.3") in network for network in networks) + + def test_empty_payload_returns_empty_list(self): + with patch( + "prowler.providers.aws.lib.ip_ranges.ip_ranges.urllib.request.urlopen", + return_value=mock_urlopen({}), + ): + networks = get_public_ip_networks() + + assert networks == [] + + def test_prefixes_missing_cidr_are_skipped(self): + payload = { + "prefixes": [{"ip_prefix": "10.0.0.0/8"}, {"service": "EC2"}], + "ipv6_prefixes": [{"service": "AMAZON"}], + } + with patch(URLOPEN_TARGET, return_value=mock_urlopen(payload)): + networks = get_public_ip_networks() + + assert networks == [IPv4Network("10.0.0.0/8")] + + def test_urlopen_failure_returns_empty_list(self): + with patch(URLOPEN_TARGET, side_effect=urllib.error.URLError("boom")): + networks = get_public_ip_networks() + + assert networks == [] + + def test_timeout_returns_empty_list(self): + with patch(URLOPEN_TARGET, side_effect=TimeoutError("timed out")): + networks = get_public_ip_networks() + + assert networks == [] + + def test_invalid_json_returns_empty_list(self): + response = MagicMock() + response.read.return_value = b"not json" + context_manager = MagicMock() + context_manager.__enter__.return_value = response + context_manager.__exit__.return_value = False + with patch(URLOPEN_TARGET, return_value=context_manager): + networks = get_public_ip_networks() + + assert networks == [] + + def test_malformed_cidr_is_skipped(self): + payload = { + "prefixes": [ + {"ip_prefix": "300.0.0.0/8"}, + {"ip_prefix": "10.0.0.0/8"}, + ], + "ipv6_prefixes": [ + {"ipv6_prefix": "2600::/129"}, + {"ipv6_prefix": "2600:1f00::/24"}, + ], + } + with patch(URLOPEN_TARGET, return_value=mock_urlopen(payload)): + networks = get_public_ip_networks() + + assert networks == [ + IPv4Network("10.0.0.0/8"), + IPv6Network("2600:1f00::/24"), + ] 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 new file mode 100644 index 0000000000..c6e5d7f756 --- /dev/null +++ b/tests/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege_test.py @@ -0,0 +1,275 @@ +from json import dumps +from unittest import mock + +import botocore +from boto3 import client +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +AGENT_ID = "test-agent-id" +AGENT_NAME = "test-agent-name" +AGENT_ARN = ( + f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:agent/{AGENT_ID}" +) +ROLE_NAME = "AmazonBedrockExecutionRoleForAgents_test" +ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/{ROLE_NAME}" +BOUNDARY_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/AgentBoundary" + +ASSUME_ROLE_POLICY_DOCUMENT = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "bedrock.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + ], +} + +BOUNDARY_POLICY_DOCUMENT = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": "bedrock:*", "Resource": "*"}], +} + +NARROW_INLINE_POLICY = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["s3:GetObject"], + "Resource": ["arn:aws:s3:::my-rag-bucket/*"], + } + ], +} + +BROAD_INLINE_POLICY = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": "*", "Resource": "*"}], +} + + +# Mock both ListAgents and GetAgent at the botocore level. moto's bedrock-agent +# support is incomplete for our needs (GetAgent often doesn't echo back the +# role ARN we set), so we control the responses directly. We also need to keep +# IAM calls going to moto. +make_api_call = botocore.client.BaseClient._make_api_call + + +def _mock_bedrock_agent_factory(role_arn): + """Return a mock_make_api_call function that returns role_arn from GetAgent. + + Pass role_arn=None to simulate an agent whose role can't be resolved. + """ + + def _mock_make_api_call(self, operation_name, kwarg): + if operation_name == "ListAgents": + return { + "agentSummaries": [ + {"agentId": AGENT_ID, "agentName": AGENT_NAME}, + ] + } + if operation_name == "GetAgent": + return { + "agent": { + "agentId": AGENT_ID, + "agentName": AGENT_NAME, + "agentResourceRoleArn": role_arn, + } + } + if operation_name == "ListTagsForResource": + return {"tags": {}} + if operation_name == "ListPrompts": + return {"promptSummaries": []} + return make_api_call(self, operation_name, kwarg) + + return _mock_make_api_call + + +def _setup_role( + *, + attached_policy_arns=(), + inline_policies=None, + permissions_boundary=None, +): + """Create an IAM role in moto with the given configuration. Returns the role ARN.""" + iam = client("iam", region_name=AWS_REGION_US_EAST_1) + + if permissions_boundary: + iam.create_policy( + PolicyName="AgentBoundary", + PolicyDocument=dumps(BOUNDARY_POLICY_DOCUMENT), + ) + + create_kwargs = { + "RoleName": ROLE_NAME, + "AssumeRolePolicyDocument": dumps(ASSUME_ROLE_POLICY_DOCUMENT), + } + if permissions_boundary: + create_kwargs["PermissionsBoundary"] = permissions_boundary + iam.create_role(**create_kwargs) + + for policy_arn in attached_policy_arns: + iam.attach_role_policy(RoleName=ROLE_NAME, PolicyArn=policy_arn) + + for policy_name, policy_document in (inline_policies or {}).items(): + iam.put_role_policy( + RoleName=ROLE_NAME, + PolicyName=policy_name, + PolicyDocument=dumps(policy_document), + ) + + return ROLE_ARN + + +def _run_check(role_arn_for_get_agent): + """Build the IAM + BedrockAgent services, patch them in, run the check.""" + from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent + from prowler.providers.aws.services.iam.iam_service import IAM + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "botocore.client.BaseClient._make_api_call", + new=_mock_bedrock_agent_factory(role_arn_for_get_agent), + ): + 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_least_privilege.bedrock_agent_role_least_privilege.bedrock_agent_client", + new=BedrockAgent(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_agent_role_least_privilege.bedrock_agent_role_least_privilege.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_agent_role_least_privilege.bedrock_agent_role_least_privilege import ( + bedrock_agent_role_least_privilege, + ) + + return bedrock_agent_role_least_privilege().execute() + + +class Test_bedrock_agent_role_least_privilege: + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_no_agents(self): + """No agents in the account -> zero findings.""" + from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent + from prowler.providers.aws.services.iam.iam_service import IAM + + 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_least_privilege.bedrock_agent_role_least_privilege.bedrock_agent_client", + new=BedrockAgent(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_agent_role_least_privilege.bedrock_agent_role_least_privilege.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_agent_role_least_privilege.bedrock_agent_role_least_privilege import ( + bedrock_agent_role_least_privilege, + ) + + assert bedrock_agent_role_least_privilege().execute() == [] + + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_agent_role_compliant(self): + """Narrow inline policy + boundary + no *FullAccess attached -> PASS.""" + role_arn = _setup_role( + inline_policies={"NarrowAccess": NARROW_INLINE_POLICY}, + permissions_boundary=BOUNDARY_ARN, + ) + + result = _run_check(role_arn_for_get_agent=role_arn) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "follows least privilege" in result[0].status_extended + assert result[0].resource_id == AGENT_ID + assert result[0].resource_arn == AGENT_ARN + + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_agent_role_full_access_attached(self): + """AmazonBedrockFullAccess attached -> FAIL.""" + role_arn = _setup_role( + attached_policy_arns=("arn:aws:iam::aws:policy/AmazonBedrockFullAccess",), + inline_policies={"NarrowAccess": NARROW_INLINE_POLICY}, + permissions_boundary=BOUNDARY_ARN, + ) + + result = _run_check(role_arn_for_get_agent=role_arn) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "grants full access" in result[0].status_extended + + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_agent_role_administrator_access_attached(self): + """AdministratorAccess attached (no FullAccess suffix) -> FAIL via doc-based admin check.""" + role_arn = _setup_role( + attached_policy_arns=("arn:aws:iam::aws:policy/AdministratorAccess",), + inline_policies={"NarrowAccess": NARROW_INLINE_POLICY}, + permissions_boundary=BOUNDARY_ARN, + ) + + result = _run_check(role_arn_for_get_agent=role_arn) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "managed policy AdministratorAccess grants administrative access" + in result[0].status_extended + ) + + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_agent_role_resource_star_broad_action(self): + """Inline statement with Action:* on Resource:* -> FAIL.""" + role_arn = _setup_role( + inline_policies={"BroadAccess": BROAD_INLINE_POLICY}, + permissions_boundary=BOUNDARY_ARN, + ) + + result = _run_check(role_arn_for_get_agent=role_arn) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "grants administrative access" in result[0].status_extended + + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_agent_role_no_permissions_boundary(self): + """Otherwise clean role but missing permissions boundary -> FAIL.""" + role_arn = _setup_role( + inline_policies={"NarrowAccess": NARROW_INLINE_POLICY}, + permissions_boundary=None, + ) + + result = _run_check(role_arn_for_get_agent=role_arn) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "no permissions boundary configured" in result[0].status_extended + + @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.""" + 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 "could not be resolved" in result[0].status_extended diff --git a/tests/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials_test.py b/tests/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials_test.py index bf4f6381b9..d75d248c7c 100644 --- a/tests/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials_test.py +++ b/tests/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials_test.py @@ -3,466 +3,197 @@ from unittest import mock from moto import mock_aws +from prowler.lib.check.models import Severity from tests.providers.aws.utils import AWS_REGION_US_EAST_1, set_mocked_aws_provider +BEDROCK_SERVICE = "bedrock.amazonaws.com" + + +def _make_user(name="test_user"): + from prowler.providers.aws.services.iam.iam_service import User + + return User( + name=name, + arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/{name}", + attached_policies=[], + inline_policies=[], + ) + + +def _make_credential( + user, + credential_id="test-credential-id", + expiration_delta_days=None, + service_name=BEDROCK_SERVICE, +): + from prowler.providers.aws.services.iam.iam_service import ServiceSpecificCredential + + expiration_date = ( + datetime.now(timezone.utc) + timedelta(days=expiration_delta_days) + if expiration_delta_days is not None + else None + ) + return ServiceSpecificCredential( + arn=( + f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/{user.name}/" + f"credential/{credential_id}" + ), + user=user, + status="Active", + create_date=datetime.now(timezone.utc), + service_user_name=None, + service_credential_alias=None, + expiration_date=expiration_date, + id=credential_id, + service_name=service_name, + region=AWS_REGION_US_EAST_1, + ) + + +def _run_check(credentials): + from prowler.providers.aws.services.iam.iam_service import IAM + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + iam.service_specific_credentials = credentials + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", + new=iam, + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( + bedrock_api_key_no_long_term_credentials, + ) + + check = bedrock_api_key_no_long_term_credentials() + return check.execute() + class Test_bedrock_api_key_no_long_term_credentials: @mock_aws def test_no_bedrock_api_keys(self): - from prowler.providers.aws.services.iam.iam_service import IAM - - 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_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", - new=IAM(aws_provider), - ), - ): - from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( - bedrock_api_key_no_long_term_credentials, - ) - - check = bedrock_api_key_no_long_term_credentials() - result = check.execute() - - assert len(result) == 0 + assert _run_check([]) == [] @mock_aws - def test_bedrock_api_key_with_future_expiration_date(self): - from prowler.providers.aws.services.iam.iam_service import IAM + def test_active_short_expiration_key_fails_high(self): + # Per AWS guidance, every active long-term key is a finding regardless of + # how soon it expires. Short remaining lifetime does not downgrade severity. + credential = _make_credential(_make_user(), expiration_delta_days=30) + result = _run_check([credential]) - aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) - iam = IAM(aws_provider) - - # Mock service-specific credentials - from prowler.providers.aws.services.iam.iam_service import ( - ServiceSpecificCredential, - User, - ) - - # Create a mock user - mock_user = User( - name="test_user", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user", - attached_policies=[], - inline_policies=[], - ) - - # Create a mock service-specific credential with future expiration date - expiration_date = datetime.now(timezone.utc) + timedelta(days=30) - mock_credential = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user/credential/test-credential-id", - user=mock_user, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=expiration_date, - id="test-credential-id", - service_name="bedrock.amazonaws.com", - region=AWS_REGION_US_EAST_1, - ) - - iam.service_specific_credentials = [mock_credential] - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=aws_provider, - ), - mock.patch( - "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", - new=iam, - ), - ): - from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( - bedrock_api_key_no_long_term_credentials, - ) - - check = bedrock_api_key_no_long_term_credentials() - result = check.execute() - - assert len(result) == 1 - assert result[0].status == "FAIL" - assert "will expire in" in result[0].status_extended - assert "test-credential-id" in result[0].status_extended - assert "test_user" in result[0].status_extended - assert result[0].resource_id == "test-credential-id" - assert result[0].region == AWS_REGION_US_EAST_1 + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.high + assert "is active and will expire in" in result[0].status_extended + assert "short-term Bedrock API keys" in result[0].status_extended @mock_aws - def test_bedrock_api_key_with_critical_expiration_date(self): - from prowler.providers.aws.services.iam.iam_service import IAM + def test_active_long_expiration_key_fails_high(self): + credential = _make_credential(_make_user(), expiration_delta_days=365) + result = _run_check([credential]) - aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) - iam = IAM(aws_provider) - - # Mock service-specific credentials - from prowler.providers.aws.services.iam.iam_service import ( - ServiceSpecificCredential, - User, - ) - - # Create a mock user - mock_user = User( - name="test_user", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user", - attached_policies=[], - inline_policies=[], - ) - - # Create a mock service-specific credential with very far future expiration date (>10000 days) - expiration_date = datetime.now(timezone.utc) + timedelta(days=15000) - mock_credential = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user/credential/test-credential-id", - user=mock_user, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=expiration_date, - id="test-credential-id", - service_name="bedrock.amazonaws.com", - region=AWS_REGION_US_EAST_1, - ) - - iam.service_specific_credentials = [mock_credential] - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=aws_provider, - ), - mock.patch( - "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", - new=iam, - ), - ): - from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( - bedrock_api_key_no_long_term_credentials, - ) - - check = bedrock_api_key_no_long_term_credentials() - result = check.execute() - - assert len(result) == 1 - assert result[0].status == "FAIL" - assert "never expires" in result[0].status_extended - assert "test-credential-id" in result[0].status_extended - assert "test_user" in result[0].status_extended - assert result[0].resource_id == "test-credential-id" - assert result[0].region == AWS_REGION_US_EAST_1 - assert check.Severity == "critical" + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.high + assert "is active and will expire in" in result[0].status_extended @mock_aws - def test_bedrock_api_key_with_expired_date(self): - from prowler.providers.aws.services.iam.iam_service import IAM + def test_never_expires_key_fails_critical(self): + # >10000 days approximates AWS's "no expiration" sentinel (~100 years). + credential = _make_credential(_make_user(), expiration_delta_days=15000) + result = _run_check([credential]) - aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) - iam = IAM(aws_provider) - - # Mock service-specific credentials - from prowler.providers.aws.services.iam.iam_service import ( - ServiceSpecificCredential, - User, - ) - - # Create a mock user - mock_user = User( - name="test_user", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user", - attached_policies=[], - inline_policies=[], - ) - - # Create a mock service-specific credential with past expiration date - expiration_date = datetime.now(timezone.utc) - timedelta(days=30) - mock_credential = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user/credential/test-credential-id", - user=mock_user, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=expiration_date, - id="test-credential-id", - service_name="bedrock.amazonaws.com", - region=AWS_REGION_US_EAST_1, - ) - - iam.service_specific_credentials = [mock_credential] - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=aws_provider, - ), - mock.patch( - "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", - new=iam, - ), - ): - from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( - bedrock_api_key_no_long_term_credentials, - ) - - check = bedrock_api_key_no_long_term_credentials() - result = check.execute() - - assert len(result) == 1 - assert result[0].status == "PASS" - assert "has expired" in result[0].status_extended - assert "test-credential-id" in result[0].status_extended - assert "test_user" in result[0].status_extended - assert result[0].resource_id == "test-credential-id" - assert result[0].region == AWS_REGION_US_EAST_1 + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.critical + assert "configured to never expire" in result[0].status_extended + assert "short-term Bedrock API keys" in result[0].status_extended @mock_aws - def test_bedrock_api_key_without_expiration_date_ignored(self): - from prowler.providers.aws.services.iam.iam_service import IAM + def test_already_expired_key_passes(self): + credential = _make_credential(_make_user(), expiration_delta_days=-30) + result = _run_check([credential]) - aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) - iam = IAM(aws_provider) - - # Mock service-specific credentials - from prowler.providers.aws.services.iam.iam_service import ( - ServiceSpecificCredential, - User, - ) - - # Create a mock user - mock_user = User( - name="test_user", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user", - attached_policies=[], - inline_policies=[], - ) - - # Create a mock service-specific credential without expiration date (should be ignored) - mock_credential = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user/credential/test-credential-id", - user=mock_user, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=None, # No expiration date - should be ignored - id="test-credential-id", - service_name="bedrock.amazonaws.com", - region=AWS_REGION_US_EAST_1, - ) - - iam.service_specific_credentials = [mock_credential] - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=aws_provider, - ), - mock.patch( - "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", - new=iam, - ), - ): - from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( - bedrock_api_key_no_long_term_credentials, - ) - - check = bedrock_api_key_no_long_term_credentials() - result = check.execute() - - assert len(result) == 0 + assert len(result) == 1 + assert result[0].status == "PASS" + assert "has already expired" in result[0].status_extended @mock_aws - def test_non_bedrock_api_key_ignored(self): - from prowler.providers.aws.services.iam.iam_service import IAM - - aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) - iam = IAM(aws_provider) - - # Mock service-specific credentials - from prowler.providers.aws.services.iam.iam_service import ( - ServiceSpecificCredential, - User, - ) - - # Create a mock user - mock_user = User( - name="test_user", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user", - attached_policies=[], - inline_policies=[], - ) - - # Create a mock service-specific credential for a different service - expiration_date = datetime.now(timezone.utc) + timedelta(days=30) - mock_credential = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user/credential/test-credential-id", - user=mock_user, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=expiration_date, - id="test-credential-id", - service_name="codecommit.amazonaws.com", # Different service - region=AWS_REGION_US_EAST_1, - ) - - iam.service_specific_credentials = [mock_credential] - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=aws_provider, - ), - mock.patch( - "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", - new=iam, - ), - ): - from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( - bedrock_api_key_no_long_term_credentials, - ) - - check = bedrock_api_key_no_long_term_credentials() - result = check.execute() - - assert len(result) == 0 + def test_key_without_expiration_date_ignored(self): + credential = _make_credential(_make_user(), expiration_delta_days=None) + assert _run_check([credential]) == [] @mock_aws - def test_multiple_bedrock_api_keys_mixed_scenarios(self): - from prowler.providers.aws.services.iam.iam_service import IAM - - aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) - iam = IAM(aws_provider) - - # Mock service-specific credentials - from prowler.providers.aws.services.iam.iam_service import ( - ServiceSpecificCredential, - User, + def test_non_bedrock_service_ignored(self): + credential = _make_credential( + _make_user(), + expiration_delta_days=30, + service_name="codecommit.amazonaws.com", ) + assert _run_check([credential]) == [] - # Create mock users - mock_user1 = User( - name="test_user1", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user1", - attached_policies=[], - inline_policies=[], + @mock_aws + def test_mixed_scenarios(self): + user1, user2, user3 = ( + _make_user("u1"), + _make_user("u2"), + _make_user("u3"), ) - - mock_user2 = User( - name="test_user2", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user2", - attached_policies=[], - inline_policies=[], - ) - - mock_user3 = User( - name="test_user3", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user3", - attached_policies=[], - inline_policies=[], - ) - - # Create a mock service-specific credential with future expiration date - expiration_date1 = datetime.now(timezone.utc) + timedelta(days=30) - mock_credential1 = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user1/credential/test-credential-id-1", - user=mock_user1, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=expiration_date1, - id="test-credential-id-1", - service_name="bedrock.amazonaws.com", - region=AWS_REGION_US_EAST_1, - ) - - # Create a mock service-specific credential with critical expiration date - expiration_date2 = datetime.now(timezone.utc) + timedelta(days=15000) - mock_credential2 = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user2/credential/test-credential-id-2", - user=mock_user2, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=expiration_date2, - id="test-credential-id-2", - service_name="bedrock.amazonaws.com", - region=AWS_REGION_US_EAST_1, - ) - - # Create a mock service-specific credential with expired date - expiration_date3 = datetime.now(timezone.utc) - timedelta(days=30) - mock_credential3 = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user3/credential/test-credential-id-3", - user=mock_user3, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=expiration_date3, - id="test-credential-id-3", - service_name="bedrock.amazonaws.com", - region=AWS_REGION_US_EAST_1, - ) - - iam.service_specific_credentials = [ - mock_credential1, - mock_credential2, - mock_credential3, + credentials = [ + _make_credential(user1, "active-key", expiration_delta_days=191), + _make_credential(user2, "never-key", expiration_delta_days=15000), + _make_credential(user3, "expired-key", expiration_delta_days=-30), ] + result = _run_check(credentials) - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=aws_provider, + assert len(result) == 3 + by_id = {r.resource_id: r for r in result} + + assert by_id["active-key"].status == "FAIL" + assert by_id["active-key"].check_metadata.Severity == Severity.high + + assert by_id["never-key"].status == "FAIL" + assert by_id["never-key"].check_metadata.Severity == Severity.critical + + assert by_id["expired-key"].status == "PASS" + + @mock_aws + def test_severity_does_not_leak_never_then_active(self): + """Regression: a never-expires key processed before an active key must + not bleed `critical` severity into the active finding.""" + credentials = [ + _make_credential( + _make_user("u-never"), "never-key", expiration_delta_days=15000 ), - mock.patch( - "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", - new=iam, + _make_credential( + _make_user("u-active"), "active-key", expiration_delta_days=191 ), - ): - from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( - bedrock_api_key_no_long_term_credentials, - ) + ] + result = _run_check(credentials) - check = bedrock_api_key_no_long_term_credentials() - result = check.execute() + by_id = {r.resource_id: r for r in result} + assert by_id["never-key"].check_metadata.Severity == Severity.critical + assert by_id["active-key"].check_metadata.Severity == Severity.high - assert len(result) == 3 + @mock_aws + def test_severity_does_not_leak_active_then_never(self): + """Regression: same as above with the reverse iteration order.""" + credentials = [ + _make_credential( + _make_user("u-active"), "active-key", expiration_delta_days=191 + ), + _make_credential( + _make_user("u-never"), "never-key", expiration_delta_days=15000 + ), + ] + result = _run_check(credentials) - # Check the credential with future expiration date (FAIL) - fail_result1 = next( - r for r in result if r.resource_id == "test-credential-id-1" - ) - assert fail_result1.status == "FAIL" - assert "will expire in" in fail_result1.status_extended - assert "test-credential-id-1" in fail_result1.status_extended - assert "test_user1" in fail_result1.status_extended - - # Check the credential with critical expiration date (FAIL) - fail_result2 = next( - r for r in result if r.resource_id == "test-credential-id-2" - ) - assert fail_result2.status == "FAIL" - assert "never expires" in fail_result2.status_extended - assert "test-credential-id-2" in fail_result2.status_extended - assert "test_user2" in fail_result2.status_extended - - # Check the credential with expired date (PASS) - pass_result = next( - r for r in result if r.resource_id == "test-credential-id-3" - ) - assert pass_result.status == "PASS" - assert "has expired" in pass_result.status_extended - assert "test-credential-id-3" in pass_result.status_extended - assert "test_user3" in pass_result.status_extended + by_id = {r.resource_id: r for r in result} + assert by_id["active-key"].check_metadata.Severity == Severity.high + assert by_id["never-key"].check_metadata.Severity == Severity.critical 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 66c2099843..928ff2b47c 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 @@ -674,3 +674,185 @@ class Test_cloudwatch_changes_to_network_acls_alarm_configured: result = check.execute() assert len(result) == 0 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + 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_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = ReplaceNetworkAclAssociation) || ($.eventName = ReplaceNetworkAclEntry) || ($.eventName = DeleteNetworkAclEntry) || ($.eventName = DeleteNetworkAcl) || ($.eventName = CreateNetworkAclEntry) || ($.eventName = CreateNetworkAcl) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + 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, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured import ( + cloudwatch_changes_to_network_acls_alarm_configured, + ) + + check = cloudwatch_changes_to_network_acls_alarm_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_substring_only_no_match(self): + 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_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = CreateNetworkAclEntry) || ($.eventName = DeleteNetworkAclEntry) || ($.eventName = ReplaceNetworkAclEntry) || ($.eventName = ReplaceNetworkAclAssociation) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + 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, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured import ( + cloudwatch_changes_to_network_acls_alarm_configured, + ) + + check = cloudwatch_changes_to_network_acls_alarm_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No CloudWatch log groups found with metric filters or alarms associated." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured_test.py index afe0f7d3ce..50c8663437 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured_test.py @@ -616,3 +616,95 @@ class Test_cloudwatch_changes_to_network_gateways_alarm_configured: ) assert result[0].region == AWS_REGION_US_EAST_1 assert result[0].resource_tags == [{}] + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + 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_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = DetachInternetGateway) || ($.eventName = DeleteInternetGateway) || ($.eventName = CreateInternetGateway) || ($.eventName = AttachInternetGateway) || ($.eventName = DeleteCustomerGateway) || ($.eventName = CreateCustomerGateway) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + 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, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_gateways_alarm_configured.cloudwatch_changes_to_network_gateways_alarm_configured.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_gateways_alarm_configured.cloudwatch_changes_to_network_gateways_alarm_configured.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_gateways_alarm_configured.cloudwatch_changes_to_network_gateways_alarm_configured.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_gateways_alarm_configured.cloudwatch_changes_to_network_gateways_alarm_configured import ( + cloudwatch_changes_to_network_gateways_alarm_configured, + ) + + check = cloudwatch_changes_to_network_gateways_alarm_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured_test.py index 7ec6e32c56..929793eb34 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured_test.py @@ -596,3 +596,185 @@ class Test_cloudwatch_changes_to_network_route_tables_alarm_configured: == 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 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + 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_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventSource = ec2.amazonaws.com) && ($.eventName = DisassociateRouteTable) || ($.eventName = DeleteRoute) || ($.eventName = DeleteRouteTable) || ($.eventName = ReplaceRouteTableAssociation) || ($.eventName = ReplaceRoute) || ($.eventName = CreateRouteTable) || ($.eventName = CreateRoute) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + 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, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured import ( + cloudwatch_changes_to_network_route_tables_alarm_configured, + ) + + check = cloudwatch_changes_to_network_route_tables_alarm_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_substring_only_no_match(self): + 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_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventSource = ec2.amazonaws.com) && ($.eventName = CreateRouteTable) || ($.eventName = ReplaceRouteTableAssociation) || ($.eventName = DeleteRouteTable) || ($.eventName = DisassociateRouteTable) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + 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, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured import ( + cloudwatch_changes_to_network_route_tables_alarm_configured, + ) + + check = cloudwatch_changes_to_network_route_tables_alarm_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No CloudWatch log groups found with metric filters or alarms associated." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured_test.py index 31f27030ff..57d33cb3fe 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured_test.py @@ -596,3 +596,185 @@ class Test_cloudwatch_changes_to_vpcs_alarm_configured: == 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 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + 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_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = EnableVpcClassicLink) || ($.eventName = DisableVpcClassicLink) || ($.eventName = DetachClassicLinkVpc) || ($.eventName = AttachClassicLinkVpc) || ($.eventName = RejectVpcPeeringConnection) || ($.eventName = DeleteVpcPeeringConnection) || ($.eventName = CreateVpcPeeringConnection) || ($.eventName = AcceptVpcPeeringConnection) || ($.eventName = ModifyVpcAttribute) || ($.eventName = DeleteVpc) || ($.eventName = CreateVpc) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + 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, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured import ( + cloudwatch_changes_to_vpcs_alarm_configured, + ) + + check = cloudwatch_changes_to_vpcs_alarm_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_substring_only_no_match(self): + 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_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = ModifyVpcAttribute) || ($.eventName = AcceptVpcPeeringConnection) || ($.eventName = CreateVpcPeeringConnection) || ($.eventName = DeleteVpcPeeringConnection) || ($.eventName = RejectVpcPeeringConnection) || ($.eventName = AttachClassicLinkVpc) || ($.eventName = DetachClassicLinkVpc) || ($.eventName = DisableVpcClassicLink) || ($.eventName = EnableVpcClassicLink) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + 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, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured import ( + cloudwatch_changes_to_vpcs_alarm_configured, + ) + + check = cloudwatch_changes_to_vpcs_alarm_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No CloudWatch log groups found with metric filters or alarms associated." + ) 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 30982553b2..62ca14d4ba 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 @@ -665,3 +665,97 @@ class Test_cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_c result = check.execute() assert len(result) == 0 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + 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_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventSource = config.amazonaws.com) && (($.eventName = PutConfigurationRecorder) || ($.eventName = PutDeliveryChannel) || ($.eventName = DeleteDeliveryChannel) || ($.eventName = StopConfigurationRecorder)) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + 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, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + 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_and_alarm_for_aws_config_configuration_changes_enabled.cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "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.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "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.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from 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 import ( + cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled, + ) + + check = ( + cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled() + ) + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/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_test.py b/tests/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_test.py index fcb98fda3e..3b555bf05b 100644 --- a/tests/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_test.py +++ b/tests/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_test.py @@ -610,3 +610,97 @@ class Test_cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_c == 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 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + 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_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = StopLogging) || ($.eventName = StartLogging) || ($.eventName = DeleteTrail) || ($.eventName = UpdateTrail) || ($.eventName = CreateTrail) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + 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, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + 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_and_alarm_for_cloudtrail_configuration_changes_enabled.cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "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.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "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.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from 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 import ( + cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled, + ) + + check = ( + cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled() + ) + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures_test.py index 66b9d8116b..201be76f6d 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures_test.py @@ -596,3 +596,95 @@ class Test_cloudwatch_log_metric_filter_authentication_failures: == 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 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + 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_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.errorMessage = Failed authentication) && ($.eventName = ConsoleLogin) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + 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, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + 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_authentication_failures.cloudwatch_log_metric_filter_authentication_failures.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_authentication_failures.cloudwatch_log_metric_filter_authentication_failures.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_authentication_failures.cloudwatch_log_metric_filter_authentication_failures.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_authentication_failures.cloudwatch_log_metric_filter_authentication_failures import ( + cloudwatch_log_metric_filter_authentication_failures, + ) + + check = cloudwatch_log_metric_filter_authentication_failures() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes_test.py index 3aaf475cbe..534c50c906 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes_test.py @@ -596,3 +596,95 @@ class Test_cloudwatch_log_metric_filter_aws_organizations_changes: == 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 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + 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_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventSource = organizations.amazonaws.com) && ($.eventName = UpdatePolicy) || ($.eventName = UpdateOrganizationalUnit) || ($.eventName = RemoveAccountFromOrganization) || ($.eventName = MoveAccount) || ($.eventName = DisablePolicyType) || ($.eventName = DetachPolicy) || ($.eventName = LeaveOrganization) || ($.eventName = InviteAccountToOrganization) || ($.eventName = EnablePolicyType) || ($.eventName = EnableAllFeatures) || ($.eventName = DeletePolicy) || ($.eventName = DeleteOrganizationalUnit) || ($.eventName = DeleteOrganization) || ($.eventName = DeclineHandshake) || ($.eventName = CreatePolicy) || ($.eventName = CreateOrganizationalUnit) || ($.eventName = CreateOrganization) || ($.eventName = CreateAccount) || ($.eventName = CancelHandshake) || ($.eventName = AttachPolicy) || ($.eventName = AcceptHandshake) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + 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, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + 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_aws_organizations_changes.cloudwatch_log_metric_filter_aws_organizations_changes.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_aws_organizations_changes.cloudwatch_log_metric_filter_aws_organizations_changes.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_aws_organizations_changes.cloudwatch_log_metric_filter_aws_organizations_changes.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_aws_organizations_changes.cloudwatch_log_metric_filter_aws_organizations_changes import ( + cloudwatch_log_metric_filter_aws_organizations_changes, + ) + + check = cloudwatch_log_metric_filter_aws_organizations_changes() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/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_test.py b/tests/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_test.py index 7eb1cae331..f13263cf9f 100644 --- a/tests/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_test.py +++ b/tests/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_test.py @@ -610,3 +610,97 @@ class Test_cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk == 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 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + 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_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventSource = kms.amazonaws.com) && (($.eventName = ScheduleKeyDeletion) || ($.eventName = DisableKey)) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + 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, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + 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_disable_or_scheduled_deletion_of_kms_cmk.cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "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.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "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.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from 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 import ( + cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk, + ) + + check = ( + cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk() + ) + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes_test.py index 7d2d631f11..aa88f5164d 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes_test.py @@ -596,3 +596,95 @@ class Test_cloudwatch_log_metric_filter_for_s3_bucket_policy_changes: == 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 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + 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_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventSource = s3.amazonaws.com) && (($.eventName = DeleteBucketReplication) || ($.eventName = DeleteBucketLifecycle) || ($.eventName = DeleteBucketCors) || ($.eventName = DeleteBucketPolicy) || ($.eventName = PutBucketReplication) || ($.eventName = PutBucketLifecycle) || ($.eventName = PutBucketCors) || ($.eventName = PutBucketPolicy) || ($.eventName = PutBucketAcl)) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + 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, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + 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_for_s3_bucket_policy_changes.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes import ( + cloudwatch_log_metric_filter_for_s3_bucket_policy_changes, + ) + + check = cloudwatch_log_metric_filter_for_s3_bucket_policy_changes() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes_test.py index 06e36688d6..fd3e86d313 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes_test.py @@ -596,3 +596,95 @@ class Test_cloudwatch_log_metric_filter_unauthorized_api_calls: == 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 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + 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_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = DetachGroupPolicy) || ($.eventName = AttachGroupPolicy) || ($.eventName = DetachUserPolicy) || ($.eventName = AttachUserPolicy) || ($.eventName = DetachRolePolicy) || ($.eventName = AttachRolePolicy) || ($.eventName = DeletePolicyVersion) || ($.eventName = CreatePolicyVersion) || ($.eventName = DeletePolicy) || ($.eventName = CreatePolicy) || ($.eventName = PutUserPolicy) || ($.eventName = PutRolePolicy) || ($.eventName = PutGroupPolicy) || ($.eventName = DeleteUserPolicy) || ($.eventName = DeleteRolePolicy) || ($.eventName = DeleteGroupPolicy) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + 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, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + 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_policy_changes.cloudwatch_log_metric_filter_policy_changes.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_policy_changes.cloudwatch_log_metric_filter_policy_changes.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_policy_changes.cloudwatch_log_metric_filter_policy_changes.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_policy_changes.cloudwatch_log_metric_filter_policy_changes import ( + cloudwatch_log_metric_filter_policy_changes, + ) + + check = cloudwatch_log_metric_filter_policy_changes() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes_test.py index ede85e8e6e..35dee7b516 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes_test.py @@ -599,3 +599,95 @@ class Test_cloudwatch_log_metric_filter_unauthorized_api_calls: == 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 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + 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_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = DeleteSecurityGroup) || ($.eventName = CreateSecurityGroup) || ($.eventName = RevokeSecurityGroupEgress) || ($.eventName = RevokeSecurityGroupIngress) || ($.eventName = AuthorizeSecurityGroupEgress) || ($.eventName = AuthorizeSecurityGroupIngress) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + 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, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + 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_security_group_changes.cloudwatch_log_metric_filter_security_group_changes.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_security_group_changes.cloudwatch_log_metric_filter_security_group_changes.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_security_group_changes.cloudwatch_log_metric_filter_security_group_changes.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_security_group_changes.cloudwatch_log_metric_filter_security_group_changes import ( + cloudwatch_log_metric_filter_security_group_changes, + ) + + check = cloudwatch_log_metric_filter_security_group_changes() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa_test.py index df67472cdd..6944860d75 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa_test.py @@ -596,3 +596,95 @@ class Test_cloudwatch_log_metric_filter_sign_in_without_mfa: == 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 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + 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_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.additionalEventData.MFAUsed != Yes) && ($.eventName = ConsoleLogin) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + 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, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + 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_sign_in_without_mfa.cloudwatch_log_metric_filter_sign_in_without_mfa.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_sign_in_without_mfa.cloudwatch_log_metric_filter_sign_in_without_mfa.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_sign_in_without_mfa.cloudwatch_log_metric_filter_sign_in_without_mfa.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_sign_in_without_mfa.cloudwatch_log_metric_filter_sign_in_without_mfa import ( + cloudwatch_log_metric_filter_sign_in_without_mfa, + ) + + check = cloudwatch_log_metric_filter_sign_in_without_mfa() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_service_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_service_test.py index 8ae7ed980f..33d4bde7d7 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_service_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_service_test.py @@ -1,3 +1,4 @@ +import pytest from boto3 import client from moto import mock_aws @@ -5,6 +6,9 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( CloudWatch, Logs, ) +from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, +) from tests.providers.aws.utils import ( AWS_ACCOUNT_NUMBER, AWS_REGION_US_EAST_1, @@ -216,3 +220,13 @@ class Test_CloudWatch_Service: assert logs.log_groups[arn].kms_id == "test_kms_id" assert logs.log_groups[arn].region == AWS_REGION_US_EAST_1 assert logs.log_groups[arn].tags == [{}] + + +class Test_build_metric_filter_pattern: + @pytest.mark.parametrize("bad_operator", ["==", "~=", "<", "<>", ">=", ""]) + def test_rejects_unsupported_operator(self, bad_operator): + with pytest.raises(ValueError, match="unsupported operator"): + build_metric_filter_pattern( + event_names=["ConsoleLogin"], + extra_clauses=[("errorMessage", bad_operator, "Failed authentication")], + ) diff --git a/tests/providers/aws/services/codepipeline/codepipeline_project_repo_private/codepipeline_project_repo_private_test.py b/tests/providers/aws/services/codepipeline/codepipeline_project_repo_private/codepipeline_project_repo_private_test.py index d0d1057307..bf27c29632 100644 --- a/tests/providers/aws/services/codepipeline/codepipeline_project_repo_private/codepipeline_project_repo_private_test.py +++ b/tests/providers/aws/services/codepipeline/codepipeline_project_repo_private/codepipeline_project_repo_private_test.py @@ -1,3 +1,4 @@ +import ssl import urllib.error import urllib.request from unittest import mock @@ -67,7 +68,7 @@ class Test_codepipeline_project_repo_private: "Connection": {"ProviderType": "GitHub"} } - def mock_urlopen_side_effect(req, context=None): + def mock_urlopen_side_effect(req, **kwargs): raise urllib.error.HTTPError( url="", code=404, msg="", hdrs={}, fp=None ) @@ -145,7 +146,7 @@ class Test_codepipeline_project_repo_private: mock_response.getcode.return_value = 200 mock_response.geturl.return_value = f"https://github.com/{repo_id}" - def mock_urlopen_side_effect(req, context=None): + def mock_urlopen_side_effect(req, **kwargs): if "github.com" in req.get_full_url(): return mock_response raise urllib.error.HTTPError( @@ -172,6 +173,145 @@ class Test_codepipeline_project_repo_private: assert result[0].resource_tags == [] assert result[0].region == AWS_REGION + def test_pipeline_repo_ssl_verification_failure(self): + """Test that a TLS certificate verification failure is treated as private. + When the probe cannot verify the server certificate (e.g. a MITM + presenting a forged certificate), the repository must not be reported + as public. + """ + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider([AWS_REGION]), + ): + codepipeline_client = mock.MagicMock + pipeline_name = "test-pipeline" + pipeline_arn = f"arn:aws:codepipeline:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:pipeline/{pipeline_name}" + connection_arn = f"arn:aws:codestar-connections:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:connection/test-connection" + repo_id = "prowler-cloud/prowler-private" + + codepipeline_client.pipelines = { + pipeline_arn: Pipeline( + name=pipeline_name, + arn=pipeline_arn, + region=AWS_REGION, + source=Source( + type="CodeStarSourceConnection", + repository_id=repo_id, + configuration={ + "FullRepositoryId": repo_id, + "ConnectionArn": connection_arn, + }, + ), + tags=[], + ) + } + + with ( + mock.patch( + "prowler.providers.aws.services.codepipeline.codepipeline_service.CodePipeline", + codepipeline_client, + ), + mock.patch( + "prowler.providers.aws.services.codepipeline.codepipeline_project_repo_private.codepipeline_project_repo_private.codepipeline_client", + codepipeline_client, + ), + mock.patch("boto3.client") as mock_client, + mock.patch("urllib.request.urlopen") as mock_urlopen, + ): + mock_connection = mock_client.return_value + mock_connection.get_connection.return_value = { + "Connection": {"ProviderType": "GitHub"} + } + + def mock_urlopen_side_effect(req, **kwargs): + raise ssl.SSLError("certificate verify failed") + + mock_urlopen.side_effect = mock_urlopen_side_effect + + from prowler.providers.aws.services.codepipeline.codepipeline_project_repo_private.codepipeline_project_repo_private import ( + codepipeline_project_repo_private, + ) + + check = codepipeline_project_repo_private() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"CodePipeline {pipeline_name} source repository {repo_id} is private." + ) + + def test_pipeline_repo_probe_verifies_certificate_and_sets_timeout(self): + """Test the probe never disables certificate verification and sets a timeout. + Regression test for the SSL verification bypass: the check must not use + an unverified SSL context, and every request must carry a timeout. + """ + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider([AWS_REGION]), + ): + codepipeline_client = mock.MagicMock + pipeline_name = "test-pipeline" + pipeline_arn = f"arn:aws:codepipeline:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:pipeline/{pipeline_name}" + connection_arn = f"arn:aws:codestar-connections:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:connection/test-connection" + repo_id = "prowler-cloud/prowler-private" + + codepipeline_client.pipelines = { + pipeline_arn: Pipeline( + name=pipeline_name, + arn=pipeline_arn, + region=AWS_REGION, + source=Source( + type="CodeStarSourceConnection", + repository_id=repo_id, + configuration={ + "FullRepositoryId": repo_id, + "ConnectionArn": connection_arn, + }, + ), + tags=[], + ) + } + + with ( + mock.patch( + "prowler.providers.aws.services.codepipeline.codepipeline_service.CodePipeline", + codepipeline_client, + ), + mock.patch( + "prowler.providers.aws.services.codepipeline.codepipeline_project_repo_private.codepipeline_project_repo_private.codepipeline_client", + codepipeline_client, + ), + mock.patch("boto3.client") as mock_client, + mock.patch("urllib.request.urlopen") as mock_urlopen, + mock.patch("ssl._create_unverified_context") as mock_unverified, + ): + mock_connection = mock_client.return_value + mock_connection.get_connection.return_value = { + "Connection": {"ProviderType": "GitHub"} + } + + def mock_urlopen_side_effect(req, **kwargs): + raise urllib.error.HTTPError( + url="", code=404, msg="", hdrs={}, fp=None + ) + + mock_urlopen.side_effect = mock_urlopen_side_effect + + from prowler.providers.aws.services.codepipeline.codepipeline_project_repo_private.codepipeline_project_repo_private import ( + HTTP_TIMEOUT, + codepipeline_project_repo_private, + ) + + check = codepipeline_project_repo_private() + check.execute() + + mock_unverified.assert_not_called() + assert mock_urlopen.call_count > 0 + for call in mock_urlopen.call_args_list: + assert call.kwargs.get("timeout") == HTTP_TIMEOUT + def test_pipeline_public_gitlab_repo(self): """Test detection of public GitLab repository in CodePipeline. Tests that the check correctly identifies a public GitLab repository @@ -225,7 +365,7 @@ class Test_codepipeline_project_repo_private: mock_response.getcode.return_value = 200 mock_response.geturl.return_value = f"https://gitlab.com/{repo_id}" - def mock_urlopen_side_effect(req, context=None): + def mock_urlopen_side_effect(req, **kwargs): if "gitlab.com" in req.get_full_url(): return mock_response raise urllib.error.HTTPError( diff --git a/tests/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions_test.py b/tests/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions_test.py new file mode 100644 index 0000000000..f2acf555d1 --- /dev/null +++ b/tests/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions_test.py @@ -0,0 +1,491 @@ +from unittest.mock import patch + +import botocore +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +orig = botocore.client.BaseClient._make_api_call + + +AGG_ARN_TEMPLATE = ( + "arn:aws:config:{region}:" + AWS_ACCOUNT_NUMBER + ":config-aggregator/{name}" +) + + +def _aggregator_payload( + name, region, *, org_aware=True, all_regions=True, aws_regions=None +): + payload = { + "ConfigurationAggregatorName": name, + "ConfigurationAggregatorArn": AGG_ARN_TEMPLATE.format(region=region, name=name), + } + if org_aware: + org_source = { + "RoleArn": f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/AWSConfigRoleForOrganizations", + "AllAwsRegions": all_regions, + } + if not all_regions and aws_regions: + org_source["AwsRegions"] = aws_regions + payload["OrganizationAggregationSource"] = org_source + return payload + + +def make_mock_no_aggregators_no_admin(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + return {"ConfigurationAggregators": []} + if operation_name == "ListDelegatedAdministrators": + return {"DelegatedAdministrators": []} + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_aggregator_not_org_aware(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + return { + "ConfigurationAggregators": [ + _aggregator_payload( + "legacy-agg", + AWS_REGION_EU_WEST_1, + org_aware=False, + ) + ] + } + if operation_name == "ListDelegatedAdministrators": + return {"DelegatedAdministrators": []} + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_org_aggregator_not_all_regions_with_admin(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + return { + "ConfigurationAggregators": [ + _aggregator_payload( + "partial-org-agg", + AWS_REGION_EU_WEST_1, + org_aware=True, + all_regions=False, + aws_regions=[AWS_REGION_EU_WEST_1], + ) + ] + } + if operation_name == "ListDelegatedAdministrators": + return { + "DelegatedAdministrators": [ + { + "Id": "123456789012", + "Arn": f"arn:aws:organizations::{AWS_ACCOUNT_NUMBER}:account/o-abc123/123456789012", + "Email": "admin@example.com", + "Name": "Security", + "Status": "ACTIVE", + "JoinedMethod": "CREATED", + } + ] + } + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_full_pass(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + return { + "ConfigurationAggregators": [ + _aggregator_payload( + "org-aggregator", + AWS_REGION_EU_WEST_1, + org_aware=True, + all_regions=True, + ) + ] + } + if operation_name == "ListDelegatedAdministrators": + return { + "DelegatedAdministrators": [ + { + "Id": "123456789012", + "Arn": f"arn:aws:organizations::{AWS_ACCOUNT_NUMBER}:account/o-abc123/123456789012", + "Email": "admin@example.com", + "Name": "Security", + "Status": "ACTIVE", + "JoinedMethod": "CREATED", + } + ] + } + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_access_denied_on_orgs(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + return { + "ConfigurationAggregators": [ + _aggregator_payload( + "org-aggregator", + AWS_REGION_EU_WEST_1, + org_aware=True, + all_regions=True, + ) + ] + } + if operation_name == "ListDelegatedAdministrators": + raise botocore.exceptions.ClientError( + { + "Error": { + "Code": "AccessDeniedException", + "Message": "User is not authorized to perform: organizations:ListDelegatedAdministrators", + } + }, + operation_name, + ) + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_aggregators_access_denied(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + raise botocore.exceptions.ClientError( + { + "Error": { + "Code": "AccessDeniedException", + "Message": "denied", + } + }, + operation_name, + ) + if operation_name == "ListDelegatedAdministrators": + return {"DelegatedAdministrators": []} + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_aggregators_other_client_error(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + raise botocore.exceptions.ClientError( + { + "Error": { + "Code": "InternalServerError", + "Message": "boom", + } + }, + operation_name, + ) + if operation_name == "ListDelegatedAdministrators": + return {"DelegatedAdministrators": []} + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_aggregators_unexpected_exception(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + raise RuntimeError("simulated transient error") + if operation_name == "ListDelegatedAdministrators": + return {"DelegatedAdministrators": []} + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_delegated_admins_unexpected_exception(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + return { + "ConfigurationAggregators": [ + _aggregator_payload( + "org-aggregator", + AWS_REGION_EU_WEST_1, + org_aware=True, + all_regions=True, + ) + ] + } + if operation_name == "ListDelegatedAdministrators": + raise RuntimeError("simulated transient error") + return orig(self, operation_name, api_params) + + return _mock + + +class Test_config_delegated_admin_and_org_aggregator_all_regions: + @mock_aws + def test_no_aggregators_no_admin(self): + """Test when no aggregators exist in any region and no delegated admin is set.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_no_aggregators_no_admin(), + ): + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ) + + from prowler.providers.aws.services.config.config_service import Config + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions.config_client", + new=Config(aws_provider), + ), + ): + from prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions import ( + config_delegated_admin_and_org_aggregator_all_regions, + ) + + check = config_delegated_admin_and_org_aggregator_all_regions() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "no Organization Aggregator configured in any region" + in result[0].status_extended + ) + assert ( + "no delegated administrator registered for config.amazonaws.com" + in result[0].status_extended + ) + + @mock_aws + def test_aggregator_not_org_aware(self): + """Test when an aggregator exists but is not an organization aggregator.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_aggregator_not_org_aware(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.config.config_service import Config + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions.config_client", + new=Config(aws_provider), + ), + ): + from prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions import ( + config_delegated_admin_and_org_aggregator_all_regions, + ) + + check = config_delegated_admin_and_org_aggregator_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + assert eu_west_1_result.status == "FAIL" + assert ( + "is not an organization aggregator" + in eu_west_1_result.status_extended + ) + + @mock_aws + def test_org_aggregator_not_all_regions_with_admin(self): + """Test org aggregator that doesn't cover all AWS regions (delegated admin set).""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_org_aggregator_not_all_regions_with_admin(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.config.config_service import Config + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions.config_client", + new=Config(aws_provider), + ), + ): + from prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions import ( + config_delegated_admin_and_org_aggregator_all_regions, + ) + + check = config_delegated_admin_and_org_aggregator_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + assert eu_west_1_result.status == "FAIL" + assert ( + "does not cover all AWS regions" in eu_west_1_result.status_extended + ) + + @mock_aws + def test_full_pass(self): + """Test PASS: delegated admin set and org aggregator covering all AWS regions.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_full_pass(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.config.config_service import Config + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions.config_client", + new=Config(aws_provider), + ), + ): + from prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions import ( + config_delegated_admin_and_org_aggregator_all_regions, + ) + + check = config_delegated_admin_and_org_aggregator_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + assert eu_west_1_result.status == "PASS" + assert ( + "is an organization aggregator covering all AWS regions" + in eu_west_1_result.status_extended + ) + assert "delegated admin configured" in eu_west_1_result.status_extended + assert eu_west_1_result.resource_arn == AGG_ARN_TEMPLATE.format( + region=AWS_REGION_EU_WEST_1, name="org-aggregator" + ) + + @mock_aws + def test_access_denied_on_organizations(self): + """Test that AccessDenied on Organizations is reported as unknown admin state.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_access_denied_on_orgs(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.config.config_service import Config + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions.config_client", + new=Config(aws_provider), + ), + ): + from prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions import ( + config_delegated_admin_and_org_aggregator_all_regions, + ) + + check = config_delegated_admin_and_org_aggregator_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + # The check still runs; aggregator coverage is satisfied but the + # delegated-admin status is unknown, so it must FAIL. + assert eu_west_1_result.status == "FAIL" + assert ( + "delegated administrator status for config.amazonaws.com could not be determined" + in eu_west_1_result.status_extended + ) + + @mock_aws + def test_aggregators_access_denied(self): + """AccessDenied on DescribeConfigurationAggregators is swallowed: no aggregators recorded for that region.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_aggregators_access_denied(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + from prowler.providers.aws.services.config.config_service import Config + + service = Config(aws_provider) + assert service.aggregators == {} + + @mock_aws + def test_aggregators_other_client_error(self): + """Non-access ClientError on DescribeConfigurationAggregators is logged at error level.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_aggregators_other_client_error(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + from prowler.providers.aws.services.config.config_service import Config + + service = Config(aws_provider) + assert service.aggregators == {} + + @mock_aws + def test_aggregators_unexpected_exception(self): + """Non-ClientError on DescribeConfigurationAggregators is caught by bare except.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_aggregators_unexpected_exception(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + from prowler.providers.aws.services.config.config_service import Config + + service = Config(aws_provider) + assert service.aggregators == {} + + @mock_aws + def test_delegated_admins_unexpected_exception(self): + """Non-ClientError on ListDelegatedAdministrators must still set lookup_failed.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_delegated_admins_unexpected_exception(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + from prowler.providers.aws.services.config.config_service import Config + + service = Config(aws_provider) + assert service.delegated_administrators_lookup_failed is True + assert service.delegated_administrators == [] diff --git a/tests/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled_test.py b/tests/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled_test.py new file mode 100644 index 0000000000..40d8d91f0d --- /dev/null +++ b/tests/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled_test.py @@ -0,0 +1,254 @@ +from importlib import import_module +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_EU_WEST_1_AZA, + AWS_REGION_EU_WEST_1_AZB, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +CHECK_MODULE = ( + "prowler.providers.aws.services.elbv2." + "elbv2_alb_drop_invalid_header_fields_enabled." + "elbv2_alb_drop_invalid_header_fields_enabled" +) +ELBV2_CLIENT_PATCH = f"{CHECK_MODULE}.elbv2_client" +GLOBAL_PROVIDER_PATCH = ".".join( + [ + "prowler.providers.common.provider.Provider", + "get_global_provider", + ] +) +PASS_STATUS_EXTENDED = " ".join( + [ + "ELBv2 ALB my-lb is configured to drop invalid", + "header fields.", + ] +) +FAIL_STATUS_EXTENDED = ( + "ELBv2 ALB my-lb is not configured to drop invalid header fields." +) + + +def get_check_class(): + return getattr( + import_module(CHECK_MODULE), + "elbv2_alb_drop_invalid_header_fields_enabled", + ) + + +class Test_elbv2_alb_drop_invalid_header_fields_enabled: + @mock_aws + def test_elb_no_balancers(self): + from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2 + + with ( + mock.patch( + GLOBAL_PROVIDER_PATCH, + return_value=set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ), + ), + mock.patch( + ELBV2_CLIENT_PATCH, + new=ELBv2( + set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1], + create_default_organization=False, + ) + ), + ), + ): + check = get_check_class()() + result = check.execute() + + assert len(result) == 0 + + @mock_aws + def test_elbv2_dropping_invalid_header_fields(self): + 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=AWS_REGION_EU_WEST_1_AZA, + ) + subnet2 = ec2.create_subnet( + VpcId=vpc.id, + CidrBlock="172.28.7.0/26", + AvailabilityZone=AWS_REGION_EU_WEST_1_AZB, + ) + + lb = conn.create_load_balancer( + Name="my-lb", + Subnets=[subnet1.id, subnet2.id], + SecurityGroups=[security_group.id], + Scheme="internal", + Type="application", + )["LoadBalancers"][0] + + conn.modify_load_balancer_attributes( + LoadBalancerArn=lb["LoadBalancerArn"], + Attributes=[ + { + "Key": "routing.http.drop_invalid_header_fields.enabled", + "Value": "true", + }, + ], + ) + + from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2 + + with ( + mock.patch( + GLOBAL_PROVIDER_PATCH, + return_value=set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ), + ), + mock.patch( + ELBV2_CLIENT_PATCH, + new=ELBv2( + set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1], + create_default_organization=False, + ) + ), + ), + ): + check = get_check_class()() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == PASS_STATUS_EXTENDED + assert result[0].resource_id == "my-lb" + assert result[0].resource_arn == lb["LoadBalancerArn"] + + @mock_aws + def test_elbv2_not_dropping_invalid_header_fields(self): + 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=AWS_REGION_EU_WEST_1_AZA, + ) + subnet2 = ec2.create_subnet( + VpcId=vpc.id, + CidrBlock="172.28.7.0/26", + AvailabilityZone=AWS_REGION_EU_WEST_1_AZB, + ) + + lb = conn.create_load_balancer( + Name="my-lb", + Subnets=[subnet1.id, subnet2.id], + SecurityGroups=[security_group.id], + Scheme="internal", + Type="application", + )["LoadBalancers"][0] + + conn.modify_load_balancer_attributes( + LoadBalancerArn=lb["LoadBalancerArn"], + Attributes=[ + { + "Key": "routing.http.drop_invalid_header_fields.enabled", + "Value": "false", + }, + ], + ) + + from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2 + + with ( + mock.patch( + GLOBAL_PROVIDER_PATCH, + return_value=set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ), + ), + mock.patch( + ELBV2_CLIENT_PATCH, + new=ELBv2( + set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1], + create_default_organization=False, + ) + ), + ), + ): + check = get_check_class()() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == FAIL_STATUS_EXTENDED + assert result[0].resource_id == "my-lb" + assert result[0].resource_arn == lb["LoadBalancerArn"] + + @mock_aws + def test_elbv2_network_load_balancer_ignored(self): + conn = client("elbv2", region_name=AWS_REGION_EU_WEST_1) + ec2 = resource("ec2", region_name=AWS_REGION_EU_WEST_1) + + 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=AWS_REGION_EU_WEST_1_AZA, + ) + + conn.create_load_balancer( + Name="my-nlb", + Subnets=[subnet1.id], + Scheme="internal", + Type="network", + ) + + from prowler.providers.aws.services.elbv2.elbv2_service import ELBv2 + + with ( + mock.patch( + GLOBAL_PROVIDER_PATCH, + return_value=set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ), + ), + mock.patch( + ELBV2_CLIENT_PATCH, + new=ELBv2( + set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1], + create_default_organization=False, + ) + ), + ), + ): + check = get_check_class()() + result = check.execute() + + assert len(result) == 0 diff --git a/tests/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists_test.py b/tests/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists_test.py new file mode 100644 index 0000000000..32f5487100 --- /dev/null +++ b/tests/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists_test.py @@ -0,0 +1,247 @@ +from unittest import mock + +from prowler.providers.aws.services.sagemaker.sagemaker_service import ProcessingJob +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +CLARIFY_IMAGE_URI = f"{AWS_ACCOUNT_NUMBER}.dkr.ecr.{AWS_REGION_US_EAST_1}.amazonaws.com/sagemaker-clarify-processing:1.0" +NON_CLARIFY_IMAGE_URI = f"{AWS_ACCOUNT_NUMBER}.dkr.ecr.{AWS_REGION_US_EAST_1}.amazonaws.com/sagemaker-xgboost:1.0" +CUSTOM_CLARIFY_IMAGE_URI = f"{AWS_ACCOUNT_NUMBER}.dkr.ecr.{AWS_REGION_US_EAST_1}.amazonaws.com/my-clarify-thing:1.0" +PROCESSING_JOB_ARN = f"arn:aws:sagemaker:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:processing-job/clarify-job" + + +class Test_sagemaker_clarify_exists: + def test_no_processing_jobs_no_scanned_regions(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_processing_jobs = [] + sagemaker_client.processing_jobs_scanned_regions = set() + + 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.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists import ( + sagemaker_clarify_exists, + ) + + check = sagemaker_clarify_exists() + result = check.execute() + assert len(result) == 0 + + def test_no_processing_jobs_region_scanned(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_processing_jobs = [] + sagemaker_client.processing_jobs_scanned_regions = {AWS_REGION_US_EAST_1} + sagemaker_client.audited_partition = "aws" + sagemaker_client.audited_account = AWS_ACCOUNT_NUMBER + + 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.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists import ( + sagemaker_clarify_exists, + ) + + check = sagemaker_clarify_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"No SageMaker Clarify processing jobs found in region {AWS_REGION_US_EAST_1}." + ) + assert result[0].resource_id == "sagemaker-clarify" + + def test_non_clarify_processing_job(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_processing_jobs = [ + ProcessingJob( + name="xgboost-job", + arn=f"arn:aws:sagemaker:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:processing-job/xgboost-job", + region=AWS_REGION_US_EAST_1, + image_uri=NON_CLARIFY_IMAGE_URI, + ) + ] + sagemaker_client.processing_jobs_scanned_regions = {AWS_REGION_US_EAST_1} + sagemaker_client.audited_partition = "aws" + sagemaker_client.audited_account = AWS_ACCOUNT_NUMBER + + 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.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists import ( + sagemaker_clarify_exists, + ) + + check = sagemaker_clarify_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"No SageMaker Clarify processing jobs found in region {AWS_REGION_US_EAST_1}." + ) + + def test_custom_image_with_clarify_in_name_does_not_match(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_processing_jobs = [ + ProcessingJob( + name="my-clarify-thing-job", + arn=f"arn:aws:sagemaker:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:processing-job/my-clarify-thing-job", + region=AWS_REGION_US_EAST_1, + image_uri=CUSTOM_CLARIFY_IMAGE_URI, + ) + ] + sagemaker_client.processing_jobs_scanned_regions = {AWS_REGION_US_EAST_1} + sagemaker_client.audited_partition = "aws" + sagemaker_client.audited_account = AWS_ACCOUNT_NUMBER + + 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.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists import ( + sagemaker_clarify_exists, + ) + + check = sagemaker_clarify_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"No SageMaker Clarify processing jobs found in region {AWS_REGION_US_EAST_1}." + ) + + def test_clarify_processing_job_exists(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_processing_jobs = [ + ProcessingJob( + name="clarify-job", + arn=PROCESSING_JOB_ARN, + region=AWS_REGION_US_EAST_1, + image_uri=CLARIFY_IMAGE_URI, + ) + ] + sagemaker_client.processing_jobs_scanned_regions = {AWS_REGION_US_EAST_1} + + 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.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists import ( + sagemaker_clarify_exists, + ) + + check = sagemaker_clarify_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"SageMaker Clarify processing job clarify-job exists in region {AWS_REGION_US_EAST_1}." + ) + assert result[0].resource_id == "clarify-job" + assert result[0].resource_arn == PROCESSING_JOB_ARN + + def test_mixed_regions(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_processing_jobs = [ + ProcessingJob( + name="clarify-job", + arn=PROCESSING_JOB_ARN, + region=AWS_REGION_US_EAST_1, + image_uri=CLARIFY_IMAGE_URI, + ) + ] + sagemaker_client.processing_jobs_scanned_regions = { + AWS_REGION_US_EAST_1, + AWS_REGION_EU_WEST_1, + } + sagemaker_client.audited_partition = "aws" + sagemaker_client.audited_account = AWS_ACCOUNT_NUMBER + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, 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_clarify_exists.sagemaker_clarify_exists.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists import ( + sagemaker_clarify_exists, + ) + + check = sagemaker_clarify_exists() + result = check.execute() + + assert len(result) == 2 + + results_by_region = {r.region: r for r in result} + + us_result = results_by_region[AWS_REGION_US_EAST_1] + assert us_result.status == "PASS" + assert ( + us_result.status_extended + == f"SageMaker Clarify processing job clarify-job exists in region {AWS_REGION_US_EAST_1}." + ) + + eu_result = results_by_region[AWS_REGION_EU_WEST_1] + assert eu_result.status == "FAIL" + assert ( + eu_result.status_extended + == f"No SageMaker Clarify processing jobs found in region {AWS_REGION_EU_WEST_1}." + ) diff --git a/tests/providers/aws/services/sagemaker/sagemaker_models_monitor_enabled/sagemaker_models_monitor_enabled_test.py b/tests/providers/aws/services/sagemaker/sagemaker_models_monitor_enabled/sagemaker_models_monitor_enabled_test.py new file mode 100644 index 0000000000..afdb5c6ff7 --- /dev/null +++ b/tests/providers/aws/services/sagemaker/sagemaker_models_monitor_enabled/sagemaker_models_monitor_enabled_test.py @@ -0,0 +1,229 @@ +from unittest import mock + +from prowler.providers.aws.services.sagemaker.sagemaker_service import ( + MonitoringSchedule, +) +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +test_monitoring_schedule = "test-monitoring-schedule" +monitoring_schedule_arn = f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:monitoring-schedule/{test_monitoring_schedule}" + +aggregate_name = "SageMaker Monitoring Schedules" +unknown_arn = f"arn:aws:sagemaker:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:monitoring-schedule/unknown" + + +class Test_sagemaker_models_monitor_enabled: + def test_no_models_monitoring_schedules_exist(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_monitoring_schedules = [ + MonitoringSchedule( + name=aggregate_name, + region=AWS_REGION_EU_WEST_1, + arn=unknown_arn, + has_schedules=False, + is_scheduled=False, + ) + ] + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled.sagemaker_client", + sagemaker_client, + ), + ): + + from prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled import ( + sagemaker_models_monitor_enabled, + ) + + check = sagemaker_models_monitor_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].region == AWS_REGION_EU_WEST_1 + assert ( + result[0].status_extended + == f"No SageMaker monitoring schedules found in region {AWS_REGION_EU_WEST_1}." + ) + assert result[0].resource_id == aggregate_name + assert result[0].resource_arn == unknown_arn + + def test_region_with_schedules_but_none_scheduled(self): + # A region that has monitoring schedules but none in Scheduled state + # must FAIL once. + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_monitoring_schedules = [ + MonitoringSchedule( + name=aggregate_name, + region=AWS_REGION_EU_WEST_1, + arn=unknown_arn, + has_schedules=True, + is_scheduled=False, + ) + ] + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled.sagemaker_client", + sagemaker_client, + ), + ): + + from prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled import ( + sagemaker_models_monitor_enabled, + ) + + check = sagemaker_models_monitor_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].region == AWS_REGION_EU_WEST_1 + assert ( + result[0].status_extended + == f"No active SageMaker monitoring schedule in region {AWS_REGION_EU_WEST_1}; existing schedules are not in Scheduled status." + ) + + def test_region_with_one_scheduled_passes(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_monitoring_schedules = [ + MonitoringSchedule( + name=test_monitoring_schedule, + region=AWS_REGION_EU_WEST_1, + arn=monitoring_schedule_arn, + has_schedules=True, + is_scheduled=True, + ) + ] + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled.sagemaker_client", + sagemaker_client, + ), + ): + + from prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled import ( + sagemaker_models_monitor_enabled, + ) + + check = sagemaker_models_monitor_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].region == AWS_REGION_EU_WEST_1 + assert ( + result[0].status_extended + == f"SageMaker monitoring schedule {test_monitoring_schedule} is enabled in region {AWS_REGION_EU_WEST_1}." + ) + assert result[0].resource_id == test_monitoring_schedule + assert result[0].resource_arn == monitoring_schedule_arn + + def test_scheduled_not_masked_across_regions(self): + # Regression: a region without an active monitor must not mask a + # Scheduled monitor in another region; one finding per region. + scheduled_name = "scheduled-monitor" + scheduled_arn = f"arn:aws:sagemaker:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:monitoring-schedule/{scheduled_name}" + + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_monitoring_schedules = [ + MonitoringSchedule( + name=aggregate_name, + region=AWS_REGION_EU_WEST_1, + arn=unknown_arn, + has_schedules=False, + is_scheduled=False, + ), + MonitoringSchedule( + name=scheduled_name, + region=AWS_REGION_US_EAST_1, + arn=scheduled_arn, + has_schedules=True, + is_scheduled=True, + ), + ] + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, 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.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled.sagemaker_client", + sagemaker_client, + ), + ): + + from prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled import ( + sagemaker_models_monitor_enabled, + ) + + check = sagemaker_models_monitor_enabled() + result = check.execute() + assert len(result) == 2 + + results_by_region = {r.region: r for r in result} + + assert results_by_region[AWS_REGION_EU_WEST_1].status == "FAIL" + assert ( + results_by_region[AWS_REGION_EU_WEST_1].status_extended + == f"No SageMaker monitoring schedules found in region {AWS_REGION_EU_WEST_1}." + ) + + assert results_by_region[AWS_REGION_US_EAST_1].status == "PASS" + assert ( + results_by_region[AWS_REGION_US_EAST_1].status_extended + == f"SageMaker monitoring schedule {scheduled_name} is enabled in region {AWS_REGION_US_EAST_1}." + ) + + def test_empty_schedules_list(self): + # Regression: an empty list must not raise and must yield no findings. + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_monitoring_schedules = [] + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled.sagemaker_client", + sagemaker_client, + ), + ): + + from prowler.providers.aws.services.sagemaker.sagemaker_models_monitor_enabled.sagemaker_models_monitor_enabled import ( + sagemaker_models_monitor_enabled, + ) + + check = sagemaker_models_monitor_enabled() + result = check.execute() + assert result == [] diff --git a/tests/providers/aws/services/sagemaker/sagemaker_service_test.py b/tests/providers/aws/services/sagemaker/sagemaker_service_test.py index d49a506fd9..50431c2e13 100644 --- a/tests/providers/aws/services/sagemaker/sagemaker_service_test.py +++ b/tests/providers/aws/services/sagemaker/sagemaker_service_test.py @@ -396,13 +396,13 @@ class Test_SageMaker_Service: sagemaker_service = SageMaker(audit_info) # Check that __threading_call__ was called for _list_tags_for_resource - # (one for each resource type: models, notebooks, training jobs, endpoint configs, domains) + # (one for each resource type: models, notebooks, training jobs, processing jobs, endpoint configs, domains) tag_calls = [ c for c in mock_threading_call.call_args_list if c[0][0] == sagemaker_service._list_tags_for_resource ] - assert len(tag_calls) == 5 + assert len(tag_calls) == 6 # Test SageMaker list model package groups def test_list_model_package_groups(self): diff --git a/tests/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions_test.py b/tests/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions_test.py new file mode 100644 index 0000000000..01af147e9a --- /dev/null +++ b/tests/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions_test.py @@ -0,0 +1,512 @@ +from unittest.mock import patch + +import botocore +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + set_mocked_aws_provider, +) + +orig = botocore.client.BaseClient._make_api_call + +HUB_ARN = f"arn:aws:securityhub:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:hub/default" + + +def _active_hub_responses(operation_name): + """Return a moto-friendly response for hub-describing API calls. + + Returns None if the operation is not one of the hub APIs (so the caller + can fall back to the default behavior). + """ + if operation_name == "DescribeHub": + return { + "HubArn": HUB_ARN, + "SubscribedAt": "2024-01-01T00:00:00.000Z", + "AutoEnableControls": True, + } + if operation_name == "GetEnabledStandards": + return {"StandardsSubscriptions": []} + if operation_name == "ListEnabledProductsForImport": + return {"ProductSubscriptions": []} + if operation_name == "ListTagsForResource": + return {"Tags": {}} + return None + + +def mock_make_api_call_org_admin_and_config(self, operation_name, api_params): + """Mock organization admin accounts and configuration APIs - PASS scenario.""" + hub_resp = _active_hub_responses(operation_name) + if hub_resp is not None: + return hub_resp + if operation_name == "ListOrganizationAdminAccounts": + return { + "AdminAccounts": [ + { + "AdminAccountId": "123456789012", + "AdminStatus": "ENABLED", + } + ] + } + if operation_name == "DescribeOrganizationConfiguration": + return { + "AutoEnable": True, + "AutoEnableStandards": "DEFAULT", + } + return orig(self, operation_name, api_params) + + +def mock_make_api_call_org_admin_no_auto_enable(self, operation_name, api_params): + """Mock organization admin configured but auto-enable disabled.""" + hub_resp = _active_hub_responses(operation_name) + if hub_resp is not None: + return hub_resp + if operation_name == "ListOrganizationAdminAccounts": + return { + "AdminAccounts": [ + { + "AdminAccountId": "123456789012", + "AdminStatus": "ENABLED", + } + ] + } + if operation_name == "DescribeOrganizationConfiguration": + return { + "AutoEnable": False, + "AutoEnableStandards": "NONE", + } + return orig(self, operation_name, api_params) + + +def mock_make_api_call_no_org_admin(self, operation_name, api_params): + """Mock no organization admin configured.""" + hub_resp = _active_hub_responses(operation_name) + if hub_resp is not None: + return hub_resp + if operation_name == "ListOrganizationAdminAccounts": + return {"AdminAccounts": []} + if operation_name == "DescribeOrganizationConfiguration": + return { + "AutoEnable": False, + "AutoEnableStandards": "NONE", + } + return orig(self, operation_name, api_params) + + +def mock_make_api_call_securityhub_not_subscribed(self, operation_name, api_params): + """Simulate Security Hub not subscribed in the account (InvalidAccessException).""" + if operation_name == "DescribeHub": + raise botocore.exceptions.ClientError( + { + "Error": { + "Code": "InvalidAccessException", + "Message": "Account is not subscribed to AWS Security Hub", + } + }, + operation_name, + ) + if operation_name == "ListOrganizationAdminAccounts": + return {"AdminAccounts": []} + return orig(self, operation_name, api_params) + + +def mock_make_api_call_admin_lookup_access_denied(self, operation_name, api_params): + """Hub is ACTIVE but ListOrganizationAdminAccounts is denied — lookup-failed path.""" + hub_resp = _active_hub_responses(operation_name) + if hub_resp is not None: + return hub_resp + if operation_name == "ListOrganizationAdminAccounts": + raise botocore.exceptions.ClientError( + { + "Error": { + "Code": "AccessDeniedException", + "Message": "User is not authorized to perform: securityhub:ListOrganizationAdminAccounts", + } + }, + operation_name, + ) + if operation_name == "DescribeOrganizationConfiguration": + return {"AutoEnable": True, "AutoEnableStandards": "DEFAULT"} + return orig(self, operation_name, api_params) + + +def mock_make_api_call_admin_lookup_unexpected(self, operation_name, api_params): + """ListOrganizationAdminAccounts raises a non-ClientError — bare Exception branch.""" + hub_resp = _active_hub_responses(operation_name) + if hub_resp is not None: + return hub_resp + if operation_name == "ListOrganizationAdminAccounts": + raise RuntimeError("simulated transient error") + if operation_name == "DescribeOrganizationConfiguration": + return {"AutoEnable": True, "AutoEnableStandards": "DEFAULT"} + return orig(self, operation_name, api_params) + + +def mock_make_api_call_describe_org_config_other_client_error( + self, operation_name, api_params +): + """DescribeOrganizationConfiguration raises a non-access ClientError — else branch.""" + hub_resp = _active_hub_responses(operation_name) + if hub_resp is not None: + return hub_resp + if operation_name == "ListOrganizationAdminAccounts": + return { + "AdminAccounts": [ + {"AdminAccountId": "123456789012", "AdminStatus": "ENABLED"} + ] + } + if operation_name == "DescribeOrganizationConfiguration": + raise botocore.exceptions.ClientError( + {"Error": {"Code": "InternalServerError", "Message": "boom"}}, + operation_name, + ) + return orig(self, operation_name, api_params) + + +def mock_make_api_call_describe_org_config_unexpected(self, operation_name, api_params): + """DescribeOrganizationConfiguration raises a non-ClientError — bare Exception branch.""" + hub_resp = _active_hub_responses(operation_name) + if hub_resp is not None: + return hub_resp + if operation_name == "ListOrganizationAdminAccounts": + return { + "AdminAccounts": [ + {"AdminAccountId": "123456789012", "AdminStatus": "ENABLED"} + ] + } + if operation_name == "DescribeOrganizationConfiguration": + raise RuntimeError("simulated transient error") + return orig(self, operation_name, api_params) + + +class Test_securityhub_delegated_admin_enabled_all_regions: + def teardown_method(self): + """Evict cached securityhub modules so legacy mock.patch-based tests + in the same session see a fresh import path.""" + import sys + + for mod in ( + "prowler.providers.aws.services.securityhub.securityhub_client", + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions", + ): + sys.modules.pop(mod, None) + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_securityhub_not_subscribed, + ) + @mock_aws + def test_no_securityhub(self): + """Test when Security Hub is not subscribed in any region.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=SecurityHub(aws_provider), + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + check = securityhub_delegated_admin_enabled_all_regions() + result = check.execute() + + # Should have findings for each region (with NOT_AVAILABLE hubs) + assert len(result) > 0 + # All should fail since hub is not enabled + for finding in result: + assert finding.status == "FAIL" + assert "Security Hub not enabled" in finding.status_extended + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_no_org_admin, + ) + @mock_aws + def test_securityhub_enabled_no_delegated_admin(self): + """Test when Security Hub is enabled but no delegated admin is configured.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=SecurityHub(aws_provider), + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + check = securityhub_delegated_admin_enabled_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + assert eu_west_1_result.status == "FAIL" + assert ( + "no delegated administrator configured" + in eu_west_1_result.status_extended + ) + assert eu_west_1_result.resource_arn == HUB_ARN + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_org_admin_no_auto_enable, + ) + @mock_aws + def test_securityhub_enabled_with_admin_no_auto_enable(self): + """Test when Security Hub is enabled with delegated admin but auto-enable is off.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=SecurityHub(aws_provider), + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + check = securityhub_delegated_admin_enabled_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + assert eu_west_1_result.status == "FAIL" + assert ( + "organization auto-enable not configured" + in eu_west_1_result.status_extended + ) + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_org_admin_and_config, + ) + @mock_aws + def test_securityhub_enabled_with_admin_and_auto_enable(self): + """Test when Security Hub is enabled with delegated admin and auto-enable on (PASS).""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=SecurityHub(aws_provider), + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + check = securityhub_delegated_admin_enabled_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + assert eu_west_1_result.status == "PASS" + assert "delegated admin configured" in eu_west_1_result.status_extended + assert "auto-enable" in eu_west_1_result.status_extended + assert eu_west_1_result.resource_arn == HUB_ARN + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_admin_lookup_access_denied, + ) + @mock_aws + def test_admin_lookup_access_denied(self): + """AccessDenied on ListOrganizationAdminAccounts must FAIL with unknown-admin message.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=SecurityHub(aws_provider), + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + check = securityhub_delegated_admin_enabled_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + assert eu_west_1_result.status == "FAIL" + assert ( + "delegated administrator status could not be determined" + in eu_west_1_result.status_extended + ) + assert ( + "no delegated administrator configured" + not in eu_west_1_result.status_extended + ) + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_admin_lookup_unexpected, + ) + @mock_aws + def test_admin_lookup_unexpected_exception(self): + """Non-ClientError raised from ListOrganizationAdminAccounts still sets lookup_failed.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + service = SecurityHub(aws_provider) + assert service.organization_admin_lookup_failed is True + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=service, + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + result = securityhub_delegated_admin_enabled_all_regions().execute() + assert result and result[0].status == "FAIL" + assert ( + "delegated administrator status could not be determined" + in result[0].status_extended + ) + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_describe_org_config_other_client_error, + ) + @mock_aws + def test_describe_org_config_other_client_error(self): + """Non-access ClientError on DescribeOrganizationConfiguration is logged at error level.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + service = SecurityHub(aws_provider) + # organization_config_available stays False, so the auto-enable issue is suppressed + assert service.securityhubs[0].organization_config_available is False + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=service, + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + result = securityhub_delegated_admin_enabled_all_regions().execute() + # Admin is configured and hub is active; with org config unavailable the + # check should PASS because there are no other detectable issues. + assert result and result[0].status == "PASS" + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_describe_org_config_unexpected, + ) + @mock_aws + def test_describe_org_config_unexpected_exception(self): + """Non-ClientError on DescribeOrganizationConfiguration is caught by bare except.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + service = SecurityHub(aws_provider) + assert service.securityhubs[0].organization_config_available is False + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=service, + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + result = securityhub_delegated_admin_enabled_all_regions().execute() + assert result and result[0].status == "PASS" diff --git a/tests/providers/azure/services/aks/aks_cluster_auto_upgrade_enabled/aks_cluster_auto_upgrade_enabled_test.py b/tests/providers/azure/services/aks/aks_cluster_auto_upgrade_enabled/aks_cluster_auto_upgrade_enabled_test.py new file mode 100644 index 0000000000..b7c2ac3bd9 --- /dev/null +++ b/tests/providers/azure/services/aks/aks_cluster_auto_upgrade_enabled/aks_cluster_auto_upgrade_enabled_test.py @@ -0,0 +1,99 @@ +from unittest import mock + +import pytest + +from prowler.providers.azure.services.aks.aks_service import Cluster +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + + +def build_cluster(auto_upgrade_channel): + return Cluster( + id="/sub/rg/cluster1", + name="test-cluster", + public_fqdn="test.azmk8s.io", + private_fqdn=None, + network_policy=None, + agent_pool_profiles=[], + rbac_enabled=True, + location="eastus", + auto_upgrade_channel=auto_upgrade_channel, + ) + + +class Test_aks_cluster_auto_upgrade_enabled: + def test_no_subscriptions(self): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.aks.aks_cluster_auto_upgrade_enabled.aks_cluster_auto_upgrade_enabled.aks_client", + new=aks_client, + ), + ): + from prowler.providers.azure.services.aks.aks_cluster_auto_upgrade_enabled.aks_cluster_auto_upgrade_enabled import ( + aks_cluster_auto_upgrade_enabled, + ) + + aks_client.clusters = {} + + check = aks_cluster_auto_upgrade_enabled() + result = check.execute() + assert len(result) == 0 + + def test_pass(self): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.aks.aks_cluster_auto_upgrade_enabled.aks_cluster_auto_upgrade_enabled.aks_client", + new=aks_client, + ), + ): + from prowler.providers.azure.services.aks.aks_cluster_auto_upgrade_enabled.aks_cluster_auto_upgrade_enabled import ( + aks_cluster_auto_upgrade_enabled, + ) + + cluster = build_cluster(auto_upgrade_channel="stable") + aks_client.clusters = {AZURE_SUBSCRIPTION_ID: {cluster.id: cluster}} + + check = aks_cluster_auto_upgrade_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + @pytest.mark.parametrize("auto_upgrade_channel", [None, "", "none", "None"]) + def test_fail(self, auto_upgrade_channel): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.aks.aks_cluster_auto_upgrade_enabled.aks_cluster_auto_upgrade_enabled.aks_client", + new=aks_client, + ), + ): + from prowler.providers.azure.services.aks.aks_cluster_auto_upgrade_enabled.aks_cluster_auto_upgrade_enabled import ( + aks_cluster_auto_upgrade_enabled, + ) + + cluster = build_cluster(auto_upgrade_channel=auto_upgrade_channel) + aks_client.clusters = {AZURE_SUBSCRIPTION_ID: {cluster.id: cluster}} + + check = aks_cluster_auto_upgrade_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled_test.py b/tests/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled_test.py new file mode 100644 index 0000000000..0575b67a4a --- /dev/null +++ b/tests/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled_test.py @@ -0,0 +1,122 @@ +from importlib import import_module +from unittest import mock +from uuid import uuid4 + +import pytest + +from prowler.providers.azure.services.aks.aks_service import Cluster +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + +CHECK_MODULE = ( + "prowler.providers.azure.services.aks.aks_cluster_defender_enabled." + "aks_cluster_defender_enabled" +) +CHECK_CLIENT_PATCH = f"{CHECK_MODULE}.aks_client" + + +def get_check_class(): + return import_module(CHECK_MODULE).aks_cluster_defender_enabled + + +def build_cluster(defender_enabled): + return Cluster( + id=str(uuid4()), + name="test-cluster", + public_fqdn="test.azmk8s.io", + private_fqdn=None, + network_policy=None, + agent_pool_profiles=[], + rbac_enabled=True, + location="eastus", + defender_enabled=defender_enabled, + ) + + +class Test_aks_cluster_defender_enabled: + def test_no_subscriptions(self): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + CHECK_CLIENT_PATCH, + new=aks_client, + ), + ): + aks_cluster_defender_enabled = get_check_class() + + aks_client.clusters = {} + + check = aks_cluster_defender_enabled() + result = check.execute() + assert len(result) == 0 + + def test_pass(self): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + CHECK_CLIENT_PATCH, + new=aks_client, + ), + ): + aks_cluster_defender_enabled = get_check_class() + + cluster = build_cluster(defender_enabled=True) + aks_client.clusters = {AZURE_SUBSCRIPTION_ID: {cluster.id: cluster}} + + check = aks_cluster_defender_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Cluster 'test-cluster' has Defender for Containers enabled." + ) + assert result[0].resource_name == "test-cluster" + assert result[0].resource_id == cluster.id + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].location == "eastus" + + @pytest.mark.parametrize("defender_enabled", [False, None, "true"]) + def test_fail(self, defender_enabled): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + CHECK_CLIENT_PATCH, + new=aks_client, + ), + ): + aks_cluster_defender_enabled = get_check_class() + + cluster = build_cluster(defender_enabled=defender_enabled) + aks_client.clusters = {AZURE_SUBSCRIPTION_ID: {cluster.id: cluster}} + + check = aks_cluster_defender_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Cluster 'test-cluster' does not have Defender for " + "Containers enabled." + ) + assert result[0].resource_name == "test-cluster" + assert result[0].resource_id == cluster.id + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].location == "eastus" diff --git a/tests/providers/azure/services/cosmosdb/cosmosdb_account_automatic_failover_enabled/cosmosdb_account_automatic_failover_enabled_test.py b/tests/providers/azure/services/cosmosdb/cosmosdb_account_automatic_failover_enabled/cosmosdb_account_automatic_failover_enabled_test.py new file mode 100644 index 0000000000..8b4330d64c --- /dev/null +++ b/tests/providers/azure/services/cosmosdb/cosmosdb_account_automatic_failover_enabled/cosmosdb_account_automatic_failover_enabled_test.py @@ -0,0 +1,115 @@ +from unittest import mock + +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + + +class Test_cosmosdb_account_automatic_failover_enabled: + def test_no_subscriptions(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_automatic_failover_enabled.cosmosdb_account_automatic_failover_enabled.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_automatic_failover_enabled.cosmosdb_account_automatic_failover_enabled import ( + cosmosdb_account_automatic_failover_enabled, + ) + + cosmosdb_client.accounts = {} + + check = cosmosdb_account_automatic_failover_enabled() + result = check.execute() + assert len(result) == 0 + + def test_pass(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_automatic_failover_enabled.cosmosdb_account_automatic_failover_enabled.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_automatic_failover_enabled.cosmosdb_account_automatic_failover_enabled import ( + cosmosdb_account_automatic_failover_enabled, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + enable_automatic_failover=True, + ) + ] + } + + check = cosmosdb_account_automatic_failover_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_fail(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_automatic_failover_enabled.cosmosdb_account_automatic_failover_enabled.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_automatic_failover_enabled.cosmosdb_account_automatic_failover_enabled import ( + cosmosdb_account_automatic_failover_enabled, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + enable_automatic_failover=False, + ) + ] + } + + check = cosmosdb_account_automatic_failover_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/azure/services/cosmosdb/cosmosdb_account_backup_policy_continuous/cosmosdb_account_backup_policy_continuous_test.py b/tests/providers/azure/services/cosmosdb/cosmosdb_account_backup_policy_continuous/cosmosdb_account_backup_policy_continuous_test.py new file mode 100644 index 0000000000..0cc282485b --- /dev/null +++ b/tests/providers/azure/services/cosmosdb/cosmosdb_account_backup_policy_continuous/cosmosdb_account_backup_policy_continuous_test.py @@ -0,0 +1,157 @@ +from unittest import mock + +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + + +class Test_cosmosdb_account_backup_policy_continuous: + def test_no_subscriptions(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_backup_policy_continuous.cosmosdb_account_backup_policy_continuous.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_backup_policy_continuous.cosmosdb_account_backup_policy_continuous import ( + cosmosdb_account_backup_policy_continuous, + ) + + cosmosdb_client.accounts = {} + + check = cosmosdb_account_backup_policy_continuous() + result = check.execute() + assert len(result) == 0 + + def test_pass(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_backup_policy_continuous.cosmosdb_account_backup_policy_continuous.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_backup_policy_continuous.cosmosdb_account_backup_policy_continuous import ( + cosmosdb_account_backup_policy_continuous, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + backup_policy_type="Continuous", + ) + ] + } + + check = cosmosdb_account_backup_policy_continuous() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_fail_periodic(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_backup_policy_continuous.cosmosdb_account_backup_policy_continuous.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_backup_policy_continuous.cosmosdb_account_backup_policy_continuous import ( + cosmosdb_account_backup_policy_continuous, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + backup_policy_type="Periodic", + ) + ] + } + + check = cosmosdb_account_backup_policy_continuous() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_fail_no_backup_policy(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_backup_policy_continuous.cosmosdb_account_backup_policy_continuous.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_backup_policy_continuous.cosmosdb_account_backup_policy_continuous import ( + cosmosdb_account_backup_policy_continuous, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + backup_policy_type=None, + ) + ] + } + + check = cosmosdb_account_backup_policy_continuous() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/azure/services/cosmosdb/cosmosdb_account_minimum_tls_version/cosmosdb_account_minimum_tls_version_test.py b/tests/providers/azure/services/cosmosdb/cosmosdb_account_minimum_tls_version/cosmosdb_account_minimum_tls_version_test.py new file mode 100644 index 0000000000..b14c08f697 --- /dev/null +++ b/tests/providers/azure/services/cosmosdb/cosmosdb_account_minimum_tls_version/cosmosdb_account_minimum_tls_version_test.py @@ -0,0 +1,209 @@ +from unittest import mock + +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + + +class Test_cosmosdb_account_minimum_tls_version: + def test_no_subscriptions(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_minimum_tls_version.cosmosdb_account_minimum_tls_version.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_minimum_tls_version.cosmosdb_account_minimum_tls_version import ( + cosmosdb_account_minimum_tls_version, + ) + + cosmosdb_client.accounts = {} + + check = cosmosdb_account_minimum_tls_version() + result = check.execute() + assert len(result) == 0 + + def test_pass_tls12(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_minimum_tls_version.cosmosdb_account_minimum_tls_version.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_minimum_tls_version.cosmosdb_account_minimum_tls_version import ( + cosmosdb_account_minimum_tls_version, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + minimal_tls_version="Tls12", + ) + ] + } + + check = cosmosdb_account_minimum_tls_version() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + f"CosmosDB account test-account from subscription " + f"{AZURE_SUBSCRIPTION_ID} enforces TLS 1.2 or higher." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + + def test_pass_tls13(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_minimum_tls_version.cosmosdb_account_minimum_tls_version.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_minimum_tls_version.cosmosdb_account_minimum_tls_version import ( + cosmosdb_account_minimum_tls_version, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + minimal_tls_version="Tls13", + ) + ] + } + + check = cosmosdb_account_minimum_tls_version() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_fail_tls11(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_minimum_tls_version.cosmosdb_account_minimum_tls_version.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_minimum_tls_version.cosmosdb_account_minimum_tls_version import ( + cosmosdb_account_minimum_tls_version, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + minimal_tls_version="Tls11", + ) + ] + } + + check = cosmosdb_account_minimum_tls_version() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + f"CosmosDB account test-account from subscription " + f"{AZURE_SUBSCRIPTION_ID} does not enforce TLS 1.2 or higher." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + + def test_fail_no_tls_version(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_minimum_tls_version.cosmosdb_account_minimum_tls_version.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_minimum_tls_version.cosmosdb_account_minimum_tls_version import ( + cosmosdb_account_minimum_tls_version, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + minimal_tls_version=None, + ) + ] + } + + check = cosmosdb_account_minimum_tls_version() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/azure/services/cosmosdb/cosmosdb_account_public_network_access_disabled/cosmosdb_account_public_network_access_disabled_test.py b/tests/providers/azure/services/cosmosdb/cosmosdb_account_public_network_access_disabled/cosmosdb_account_public_network_access_disabled_test.py new file mode 100644 index 0000000000..60297d6138 --- /dev/null +++ b/tests/providers/azure/services/cosmosdb/cosmosdb_account_public_network_access_disabled/cosmosdb_account_public_network_access_disabled_test.py @@ -0,0 +1,210 @@ +from unittest import mock + +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + + +class Test_cosmosdb_account_public_network_access_disabled: + def test_no_subscriptions(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_public_network_access_disabled.cosmosdb_account_public_network_access_disabled.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_public_network_access_disabled.cosmosdb_account_public_network_access_disabled import ( + cosmosdb_account_public_network_access_disabled, + ) + + cosmosdb_client.accounts = {} + + check = cosmosdb_account_public_network_access_disabled() + result = check.execute() + assert len(result) == 0 + + def test_pass_disabled(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_public_network_access_disabled.cosmosdb_account_public_network_access_disabled.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_public_network_access_disabled.cosmosdb_account_public_network_access_disabled import ( + cosmosdb_account_public_network_access_disabled, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + public_network_access="Disabled", + ) + ] + } + + check = cosmosdb_account_public_network_access_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + f"CosmosDB account test-account from subscription " + f"{AZURE_SUBSCRIPTION_ID} has public network access disabled." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + + def test_pass_secured_by_perimeter(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_public_network_access_disabled.cosmosdb_account_public_network_access_disabled.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_public_network_access_disabled.cosmosdb_account_public_network_access_disabled import ( + cosmosdb_account_public_network_access_disabled, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + public_network_access="SecuredByPerimeter", + ) + ] + } + + check = cosmosdb_account_public_network_access_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_fail_enabled(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_public_network_access_disabled.cosmosdb_account_public_network_access_disabled.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_public_network_access_disabled.cosmosdb_account_public_network_access_disabled import ( + cosmosdb_account_public_network_access_disabled, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + public_network_access="Enabled", + ) + ] + } + + check = cosmosdb_account_public_network_access_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + f"CosmosDB account test-account from subscription " + f"{AZURE_SUBSCRIPTION_ID} does not have public network access " + f"disabled (current value: 'Enabled')." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + + def test_fail_no_public_network_access(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_public_network_access_disabled.cosmosdb_account_public_network_access_disabled.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_public_network_access_disabled.cosmosdb_account_public_network_access_disabled import ( + cosmosdb_account_public_network_access_disabled, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + public_network_access=None, + ) + ] + } + + check = cosmosdb_account_public_network_access_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/azure/services/databricks/databricks_service_test.py b/tests/providers/azure/services/databricks/databricks_service_test.py index 69651f2235..f663d81fe2 100644 --- a/tests/providers/azure/services/databricks/databricks_service_test.py +++ b/tests/providers/azure/services/databricks/databricks_service_test.py @@ -18,6 +18,8 @@ def mock_databricks_get_workspaces(_): id="test-workspace-id", name="test-workspace", location="eastus", + public_network_access="Disabled", + no_public_ip_enabled=True, custom_managed_vnet_id="test-vnet-id", managed_disk_encryption=ManagedDiskEncryption( key_name="test-key", @@ -53,6 +55,8 @@ class Test_Databricks_Service: assert workspace.id == "test-workspace-id" assert workspace.name == "test-workspace" assert workspace.location == "eastus" + assert workspace.public_network_access == "Disabled" + assert workspace.no_public_ip_enabled is True assert workspace.custom_managed_vnet_id == "test-vnet-id" assert ( workspace.managed_disk_encryption.__class__.__name__ diff --git a/tests/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled_test.py b/tests/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled_test.py new file mode 100644 index 0000000000..a7903770c6 --- /dev/null +++ b/tests/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled_test.py @@ -0,0 +1,174 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.databricks.databricks_service import ( + DatabricksWorkspace, +) +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + set_mocked_azure_provider, +) + + +class Test_databricks_workspace_no_public_ip_enabled: + def test_databricks_no_workspaces(self): + databricks_client = mock.MagicMock + databricks_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + databricks_client.workspaces = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled.databricks_client", + new=databricks_client, + ), + ): + from prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled import ( + databricks_workspace_no_public_ip_enabled, + ) + + check = databricks_workspace_no_public_ip_enabled() + result = check.execute() + assert len(result) == 0 + + def test_databricks_workspace_no_public_ip_disabled(self): + workspace_id = str(uuid4()) + workspace_name = "test-workspace" + databricks_client = mock.MagicMock + databricks_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + databricks_client.workspaces = { + AZURE_SUBSCRIPTION_ID: { + workspace_id: DatabricksWorkspace( + id=workspace_id, + name=workspace_name, + location="eastus", + no_public_ip_enabled=False, + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled.databricks_client", + new=databricks_client, + ), + ): + from prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled import ( + databricks_workspace_no_public_ip_enabled, + ) + + check = databricks_workspace_no_public_ip_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Databricks workspace {workspace_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have secure cluster connectivity (no public IP) enabled." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == workspace_name + assert result[0].resource_id == workspace_id + assert result[0].location == "eastus" + + def test_databricks_workspace_no_public_ip_enabled(self): + workspace_id = str(uuid4()) + workspace_name = "test-workspace" + databricks_client = mock.MagicMock + databricks_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + databricks_client.workspaces = { + AZURE_SUBSCRIPTION_ID: { + workspace_id: DatabricksWorkspace( + id=workspace_id, + name=workspace_name, + location="eastus", + no_public_ip_enabled=True, + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled.databricks_client", + new=databricks_client, + ), + ): + from prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled import ( + databricks_workspace_no_public_ip_enabled, + ) + + check = databricks_workspace_no_public_ip_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Databricks workspace {workspace_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY} has secure cluster connectivity (no public IP) enabled." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == workspace_name + assert result[0].resource_id == workspace_id + assert result[0].location == "eastus" + + def test_databricks_workspace_no_public_ip_not_reported(self): + workspace_id = str(uuid4()) + workspace_name = "test-workspace" + databricks_client = mock.MagicMock + databricks_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + databricks_client.workspaces = { + AZURE_SUBSCRIPTION_ID: { + workspace_id: DatabricksWorkspace( + id=workspace_id, + name=workspace_name, + location="eastus", + no_public_ip_enabled=None, + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled.databricks_client", + new=databricks_client, + ), + ): + from prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled import ( + databricks_workspace_no_public_ip_enabled, + ) + + check = databricks_workspace_no_public_ip_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == f"Databricks workspace {workspace_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY} does not expose secure cluster connectivity (no public IP) settings (for example, serverless workspaces have no public-IP cluster nodes); verify the network configuration manually." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == workspace_name + assert result[0].resource_id == workspace_id + assert result[0].location == "eastus" diff --git a/tests/providers/azure/services/databricks/databricks_workspace_public_network_access_disabled/databricks_workspace_public_network_access_disabled_test.py b/tests/providers/azure/services/databricks/databricks_workspace_public_network_access_disabled/databricks_workspace_public_network_access_disabled_test.py new file mode 100644 index 0000000000..7d39d38760 --- /dev/null +++ b/tests/providers/azure/services/databricks/databricks_workspace_public_network_access_disabled/databricks_workspace_public_network_access_disabled_test.py @@ -0,0 +1,170 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.databricks.databricks_service import ( + DatabricksWorkspace, +) +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + set_mocked_azure_provider, +) + + +class Test_databricks_workspace_public_network_access_disabled: + def test_databricks_no_workspaces(self): + databricks_client = mock.MagicMock + databricks_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + databricks_client.workspaces = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.databricks.databricks_workspace_public_network_access_disabled.databricks_workspace_public_network_access_disabled.databricks_client", + new=databricks_client, + ), + ): + from prowler.providers.azure.services.databricks.databricks_workspace_public_network_access_disabled.databricks_workspace_public_network_access_disabled import ( + databricks_workspace_public_network_access_disabled, + ) + + check = databricks_workspace_public_network_access_disabled() + result = check.execute() + assert len(result) == 0 + + def test_databricks_workspace_public_network_access_enabled(self): + workspace_id = str(uuid4()) + workspace_name = "test-workspace" + databricks_client = mock.MagicMock + databricks_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + databricks_client.workspaces = { + AZURE_SUBSCRIPTION_ID: { + workspace_id: DatabricksWorkspace( + id=workspace_id, + name=workspace_name, + location="eastus", + public_network_access="Enabled", + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.databricks.databricks_workspace_public_network_access_disabled.databricks_workspace_public_network_access_disabled.databricks_client", + new=databricks_client, + ), + ): + from prowler.providers.azure.services.databricks.databricks_workspace_public_network_access_disabled.databricks_workspace_public_network_access_disabled import ( + databricks_workspace_public_network_access_disabled, + ) + + check = databricks_workspace_public_network_access_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Databricks workspace {workspace_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY} has public network access enabled." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == workspace_name + assert result[0].resource_id == workspace_id + assert result[0].location == "eastus" + + def test_databricks_workspace_public_network_access_not_set(self): + workspace_id = str(uuid4()) + workspace_name = "test-workspace" + databricks_client = mock.MagicMock + databricks_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + databricks_client.workspaces = { + AZURE_SUBSCRIPTION_ID: { + workspace_id: DatabricksWorkspace( + id=workspace_id, + name=workspace_name, + location="eastus", + public_network_access=None, + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.databricks.databricks_workspace_public_network_access_disabled.databricks_workspace_public_network_access_disabled.databricks_client", + new=databricks_client, + ), + ): + from prowler.providers.azure.services.databricks.databricks_workspace_public_network_access_disabled.databricks_workspace_public_network_access_disabled import ( + databricks_workspace_public_network_access_disabled, + ) + + check = databricks_workspace_public_network_access_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Databricks workspace {workspace_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY} has public network access enabled." + ) + + def test_databricks_workspace_public_network_access_disabled(self): + workspace_id = str(uuid4()) + workspace_name = "test-workspace" + databricks_client = mock.MagicMock + databricks_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + databricks_client.workspaces = { + AZURE_SUBSCRIPTION_ID: { + workspace_id: DatabricksWorkspace( + id=workspace_id, + name=workspace_name, + location="eastus", + public_network_access="Disabled", + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.databricks.databricks_workspace_public_network_access_disabled.databricks_workspace_public_network_access_disabled.databricks_client", + new=databricks_client, + ), + ): + from prowler.providers.azure.services.databricks.databricks_workspace_public_network_access_disabled.databricks_workspace_public_network_access_disabled import ( + databricks_workspace_public_network_access_disabled, + ) + + check = databricks_workspace_public_network_access_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Databricks workspace {workspace_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY} has public network access disabled." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == workspace_name + assert result[0].resource_id == workspace_id + assert result[0].location == "eastus" diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on_test.py new file mode 100644 index 0000000000..b81d10d31d --- /dev/null +++ b/tests/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on_test.py @@ -0,0 +1,117 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.defender.defender_service import Pricing +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + set_mocked_azure_provider, +) + + +class Test_defender_ensure_defender_cspm_is_on: + def test_defender_no_cspm(self): + defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + defender_client.pricings = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.defender.defender_ensure_defender_cspm_is_on.defender_ensure_defender_cspm_is_on.defender_client", + new=defender_client, + ), + ): + from prowler.providers.azure.services.defender.defender_ensure_defender_cspm_is_on.defender_ensure_defender_cspm_is_on import ( + defender_ensure_defender_cspm_is_on, + ) + + check = defender_ensure_defender_cspm_is_on() + result = check.execute() + assert len(result) == 0 + + def test_defender_cspm_pricing_tier_not_standard(self): + resource_id = str(uuid4()) + defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + defender_client.pricings = { + AZURE_SUBSCRIPTION_ID: { + "CloudPosture": Pricing( + resource_id=resource_id, + resource_name="Defender plan CSPM", + pricing_tier="Free", + free_trial_remaining_time=0, + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.defender.defender_ensure_defender_cspm_is_on.defender_ensure_defender_cspm_is_on.defender_client", + new=defender_client, + ), + ): + from prowler.providers.azure.services.defender.defender_ensure_defender_cspm_is_on.defender_ensure_defender_cspm_is_on import ( + defender_ensure_defender_cspm_is_on, + ) + + check = defender_ensure_defender_cspm_is_on() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Defender plan CSPM from subscription {AZURE_SUBSCRIPTION_DISPLAY} is set to OFF (pricing tier not standard)." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == "Defender plan CSPM" + assert result[0].resource_id == resource_id + + def test_defender_cspm_pricing_tier_standard(self): + resource_id = str(uuid4()) + defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + defender_client.pricings = { + AZURE_SUBSCRIPTION_ID: { + "CloudPosture": Pricing( + resource_id=resource_id, + resource_name="Defender plan CSPM", + pricing_tier="Standard", + free_trial_remaining_time=0, + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.defender.defender_ensure_defender_cspm_is_on.defender_ensure_defender_cspm_is_on.defender_client", + new=defender_client, + ), + ): + from prowler.providers.azure.services.defender.defender_ensure_defender_cspm_is_on.defender_ensure_defender_cspm_is_on import ( + defender_ensure_defender_cspm_is_on, + ) + + check = defender_ensure_defender_cspm_is_on() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Defender plan CSPM from subscription {AZURE_SUBSCRIPTION_DISPLAY} is set to ON (pricing tier standard)." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == "Defender plan CSPM" + assert result[0].resource_id == resource_id diff --git a/tests/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled_test.py b/tests/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled_test.py new file mode 100644 index 0000000000..c5e1f27de2 --- /dev/null +++ b/tests/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled_test.py @@ -0,0 +1,125 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.mysql.mysql_service import FlexibleServer +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + set_mocked_azure_provider, +) + + +class Test_mysql_flexible_server_geo_redundant_backup_enabled: + def test_mysql_no_subscriptions(self): + mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + mysql_client.flexible_servers = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.mysql.mysql_flexible_server_geo_redundant_backup_enabled.mysql_flexible_server_geo_redundant_backup_enabled.mysql_client", + new=mysql_client, + ), + ): + from prowler.providers.azure.services.mysql.mysql_flexible_server_geo_redundant_backup_enabled.mysql_flexible_server_geo_redundant_backup_enabled import ( + mysql_flexible_server_geo_redundant_backup_enabled, + ) + + check = mysql_flexible_server_geo_redundant_backup_enabled() + result = check.execute() + assert len(result) == 0 + + def test_mysql_geo_redundant_backup_disabled(self): + server_id = str(uuid4()) + server_name = "test-server" + mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + mysql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: { + server_id: FlexibleServer( + resource_id=server_id, + name=server_name, + location="eastus", + version="8.0", + configurations={}, + geo_redundant_backup="Disabled", + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.mysql.mysql_flexible_server_geo_redundant_backup_enabled.mysql_flexible_server_geo_redundant_backup_enabled.mysql_client", + new=mysql_client, + ), + ): + from prowler.providers.azure.services.mysql.mysql_flexible_server_geo_redundant_backup_enabled.mysql_flexible_server_geo_redundant_backup_enabled import ( + mysql_flexible_server_geo_redundant_backup_enabled, + ) + + check = mysql_flexible_server_geo_redundant_backup_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Geo-redundant backup is disabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == server_name + assert result[0].resource_id == server_id + assert result[0].location == "eastus" + + def test_mysql_geo_redundant_backup_enabled(self): + server_id = str(uuid4()) + server_name = "test-server" + mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + mysql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: { + server_id: FlexibleServer( + resource_id=server_id, + name=server_name, + location="eastus", + version="8.0", + configurations={}, + geo_redundant_backup="Enabled", + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.mysql.mysql_flexible_server_geo_redundant_backup_enabled.mysql_flexible_server_geo_redundant_backup_enabled.mysql_client", + new=mysql_client, + ), + ): + from prowler.providers.azure.services.mysql.mysql_flexible_server_geo_redundant_backup_enabled.mysql_flexible_server_geo_redundant_backup_enabled import ( + mysql_flexible_server_geo_redundant_backup_enabled, + ) + + check = mysql_flexible_server_geo_redundant_backup_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Geo-redundant backup is enabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == server_name + assert result[0].resource_id == server_id + assert result[0].location == "eastus" diff --git a/tests/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/mysql_flexible_server_high_availability_enabled_test.py b/tests/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/mysql_flexible_server_high_availability_enabled_test.py new file mode 100644 index 0000000000..c1084ec47b --- /dev/null +++ b/tests/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/mysql_flexible_server_high_availability_enabled_test.py @@ -0,0 +1,166 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.mysql.mysql_service import FlexibleServer +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + set_mocked_azure_provider, +) + + +class Test_mysql_flexible_server_high_availability_enabled: + def test_mysql_no_subscriptions(self): + mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + mysql_client.flexible_servers = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.mysql.mysql_flexible_server_high_availability_enabled.mysql_flexible_server_high_availability_enabled.mysql_client", + new=mysql_client, + ), + ): + from prowler.providers.azure.services.mysql.mysql_flexible_server_high_availability_enabled.mysql_flexible_server_high_availability_enabled import ( + mysql_flexible_server_high_availability_enabled, + ) + + check = mysql_flexible_server_high_availability_enabled() + result = check.execute() + assert len(result) == 0 + + def test_mysql_high_availability_disabled(self): + server_id = str(uuid4()) + server_name = "test-server" + mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + mysql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: { + server_id: FlexibleServer( + resource_id=server_id, + name=server_name, + location="eastus", + version="8.0", + configurations={}, + high_availability_mode="Disabled", + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.mysql.mysql_flexible_server_high_availability_enabled.mysql_flexible_server_high_availability_enabled.mysql_client", + new=mysql_client, + ), + ): + from prowler.providers.azure.services.mysql.mysql_flexible_server_high_availability_enabled.mysql_flexible_server_high_availability_enabled import ( + mysql_flexible_server_high_availability_enabled, + ) + + check = mysql_flexible_server_high_availability_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"High availability is disabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == server_name + assert result[0].resource_id == server_id + assert result[0].location == "eastus" + + def test_mysql_high_availability_not_set(self): + server_id = str(uuid4()) + server_name = "test-server" + mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + mysql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: { + server_id: FlexibleServer( + resource_id=server_id, + name=server_name, + location="eastus", + version="8.0", + configurations={}, + high_availability_mode=None, + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.mysql.mysql_flexible_server_high_availability_enabled.mysql_flexible_server_high_availability_enabled.mysql_client", + new=mysql_client, + ), + ): + from prowler.providers.azure.services.mysql.mysql_flexible_server_high_availability_enabled.mysql_flexible_server_high_availability_enabled import ( + mysql_flexible_server_high_availability_enabled, + ) + + check = mysql_flexible_server_high_availability_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"High availability is disabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}." + ) + + def test_mysql_high_availability_enabled(self): + server_id = str(uuid4()) + server_name = "test-server" + mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + mysql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: { + server_id: FlexibleServer( + resource_id=server_id, + name=server_name, + location="eastus", + version="8.0", + configurations={}, + high_availability_mode="ZoneRedundant", + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.mysql.mysql_flexible_server_high_availability_enabled.mysql_flexible_server_high_availability_enabled.mysql_client", + new=mysql_client, + ), + ): + from prowler.providers.azure.services.mysql.mysql_flexible_server_high_availability_enabled.mysql_flexible_server_high_availability_enabled import ( + mysql_flexible_server_high_availability_enabled, + ) + + check = mysql_flexible_server_high_availability_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"High availability is enabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == server_name + assert result[0].resource_id == server_id + assert result[0].location == "eastus" diff --git a/tests/providers/external/__init__.py b/tests/providers/external/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/external/test_dynamic_provider_loading.py b/tests/providers/external/test_dynamic_provider_loading.py new file mode 100644 index 0000000000..78e308597d --- /dev/null +++ b/tests/providers/external/test_dynamic_provider_loading.py @@ -0,0 +1,2766 @@ +""" +Tests for dynamic provider loading via entry points. + +Covers: provider discovery, check discovery, check execution, +CLI argument registration, compliance frameworks, parser integration, +and all dispatch fallbacks for external providers. +""" + +from argparse import Namespace +from unittest.mock import MagicMock, patch + +import pytest + +from prowler.providers.common.provider import Provider + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_entry_point(name, value, group): + """Create a mock entry point.""" + ep = MagicMock() + ep.name = name + ep.value = value + ep.group = group + return ep + + +class FakeExternalProvider(Provider): + """Minimal Provider subclass for testing the dynamic contract.""" + + _type = "fakeexternal" + _cli_help_text = "Fake External Provider" + + def __init__(self): + Provider.set_global_provider(self) + + @property + def type(self): + return self._type + + @property + def identity(self): + return MagicMock(host_id="fake-host-1") + + @property + def session(self): + return MagicMock() + + @property + def audit_config(self): + return {} + + def setup_session(self): + return MagicMock() + + def print_credentials(self): + pass + + @classmethod + def from_cli_args(cls, _arguments, _fixer_config): + cls() + + def get_output_options(self, _arguments, _bulk_checks_metadata): + return MagicMock(output_directory="/tmp", output_filename="fake") + + def get_stdout_detail(self, finding): + return "fake-detail" + + def get_finding_sort_key(self): + return "region" + + def get_summary_entity(self): + return ("Fake Host", "fake-host-1") + + def get_finding_output_data(self, check_output): + return { + "auth_method": "fake", + "account_uid": "fake-account", + "account_name": "fake", + "resource_name": "fake-resource", + "resource_uid": "fake-uid", + "region": "local", + } + + def get_mutelist_finding_args(self): + return {"host_id": self.identity.host_id} + + def display_compliance_table( + self, + findings, + _bulk_checks_metadata, + _compliance_framework, + _output_filename, + output_directory, # referenced via name elsewhere in tests + _compliance_overview, + ): + return True + + def get_html_assessment_summary(self): + return "
Fake Assessment
" + + def generate_compliance_output( + self, + findings, + _bulk_compliance_frameworks, + _input_compliance_frameworks, + output_options, + generated_outputs, + ): + generated_outputs["compliance"].append("fake-compliance-output") + + @classmethod + def init_parser(cls, parser_instance): + pass + + +class FakeToolWrapperProvider(Provider): + """External provider that declares itself a tool wrapper.""" + + _type = "faketoolwrapper" + is_external_tool_provider = True + + @property + def type(self): + return self._type + + @property + def identity(self): + return MagicMock() + + @property + def session(self): + return MagicMock() + + @property + def audit_config(self): + return {} + + def setup_session(self): + return MagicMock() + + def print_credentials(self): + pass + + +class FakePureContractProvider(Provider): + """External provider that honors the from_cli_args type hint literally: + returns an instance without calling Provider.set_global_provider() from + __init__. Used to verify the call site wires the returned instance.""" + + _type = "fakepure" + + @property + def type(self): + return self._type + + @property + def identity(self): + return MagicMock(host_id="fake-pure-1") + + @property + def session(self): + return MagicMock() + + @property + def audit_config(self): + return {} + + def setup_session(self): + return MagicMock() + + def print_credentials(self): + pass + + @classmethod + def from_cli_args(cls, _arguments, _fixer_config): + # Literal contract: return the instance, no side-effect in __init__. + return cls() + + +class FakeProviderNoHelpText(Provider): + """Provider without _cli_help_text.""" + + _type = "nohelptext" + + @property + def type(self): + return self._type + + @property + def identity(self): + return MagicMock() + + @property + def session(self): + return MagicMock() + + @property + def audit_config(self): + return {} + + def setup_session(self): + return MagicMock() + + def print_credentials(self): + pass + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _clear_ep_cache(): + """Clear the entry point provider cache before each test.""" + Provider._ep_providers = {} + yield + Provider._ep_providers = {} + + +@pytest.fixture +def fake_provider(): + """Create and register a FakeExternalProvider.""" + p = FakeExternalProvider() + yield p + Provider._global = None + + +# =========================================================================== +# 1. Provider Discovery & Loading +# =========================================================================== + + +class TestProviderDiscovery: + """Tests 1-7: get_available_providers, _load_ep_provider, get_providers_help_text.""" + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + def test_get_available_providers_merges_builtin_and_entrypoint(self, mock_ep): + """Test 1: get_available_providers returns both built-in and entry point providers.""" + mock_ep.return_value = [ + _make_entry_point("fakeexternal", "pkg.provider:Cls", "prowler.providers"), + ] + + providers = Provider.get_available_providers() + + # Built-in providers from actual prowler package + assert "aws" in providers + # External provider from entry point + assert "fakeexternal" in providers + assert "common" not in providers + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + def test_get_available_providers_deduplicates(self, mock_ep): + """Test 2: Same provider name in built-in and entry point appears once.""" + # "aws" exists as built-in AND as entry point + mock_ep.return_value = [ + _make_entry_point("aws", "pkg.provider:Cls", "prowler.providers"), + ] + + providers = Provider.get_available_providers() + + assert providers.count("aws") == 1 + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + def test_load_ep_provider_loads_class(self, mock_ep): + """Test 3: _load_ep_provider loads the class from entry point.""" + mock_ep.return_value = [ + _make_entry_point( + "fakeexternal", "pkg:FakeExternalProvider", "prowler.providers" + ), + ] + mock_ep.return_value[0].load.return_value = FakeExternalProvider + + cls = Provider._load_ep_provider("fakeexternal") + + assert cls is FakeExternalProvider + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + def test_load_ep_provider_returns_none_for_unknown(self, mock_ep): + """Test 4: _load_ep_provider returns None for unknown provider.""" + mock_ep.return_value = [] + + cls = Provider._load_ep_provider("nonexistent") + + assert cls is None + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + def test_load_ep_provider_caches_result(self, mock_ep): + """Test 5: _load_ep_provider caches the loaded class.""" + mock_ep.return_value = [ + _make_entry_point("fakeexternal", "pkg:Cls", "prowler.providers"), + ] + mock_ep.return_value[0].load.return_value = FakeExternalProvider + + cls1 = Provider._load_ep_provider("fakeexternal") + cls2 = Provider._load_ep_provider("fakeexternal") + + assert cls1 is cls2 + # load() should only be called once due to caching + mock_ep.return_value[0].load.assert_called_once() + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + def test_load_ep_provider_caches_misses(self, mock_ep): + """A miss (unknown provider) must also be cached so repeated lookups + do not re-iterate entry_points(). Aligns with tool_wrapper._ep_class_cache, + which already caches None on miss.""" + mock_ep.return_value = [] + + first = Provider._load_ep_provider("nonexistent") + second = Provider._load_ep_provider("nonexistent") + + assert first is None + assert second is None + # entry_points() should only be called once across the two lookups + mock_ep.assert_called_once() + + @patch("prowler.providers.common.provider.Provider._load_ep_provider") + @patch("prowler.providers.common.provider.Provider.get_available_providers") + def test_get_providers_help_text_reads_cli_help_text( + self, mock_providers, mock_load + ): + """Test 6: get_providers_help_text reads _cli_help_text from entry point provider.""" + mock_providers.return_value = ["fakeexternal"] + mock_load.return_value = FakeExternalProvider + + help_text = Provider.get_providers_help_text() + + assert help_text["fakeexternal"] == "Fake External Provider" + + @patch("prowler.providers.common.provider.Provider._load_ep_provider") + @patch("prowler.providers.common.provider.Provider.get_available_providers") + def test_get_providers_help_text_empty_without_cli_help_text( + self, mock_providers, mock_load + ): + """Test 7: get_providers_help_text returns empty string without _cli_help_text.""" + mock_providers.return_value = ["nohelptext"] + mock_load.return_value = FakeProviderNoHelpText + + help_text = Provider.get_providers_help_text() + + assert help_text["nohelptext"] == "" + + +class TestIsToolWrapperProvider: + """Tests for Provider.is_tool_wrapper_provider — the helper that combines the + built-in EXTERNAL_TOOL_PROVIDERS frozenset with the is_external_tool_provider + class attribute of entry-point providers.""" + + def test_returns_true_for_builtin_tool_wrappers(self): + # iac/llm/image are in the EXTERNAL_TOOL_PROVIDERS frozenset (fast path) + assert Provider.is_tool_wrapper_provider("iac") is True + assert Provider.is_tool_wrapper_provider("llm") is True + assert Provider.is_tool_wrapper_provider("image") is True + + def test_returns_false_for_regular_builtin_providers(self): + # Regular built-ins must not be classified as tool wrappers + assert Provider.is_tool_wrapper_provider("aws") is False + assert Provider.is_tool_wrapper_provider("gcp") is False + assert Provider.is_tool_wrapper_provider("github") is False + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + def test_returns_true_for_external_provider_declaring_flag(self, mock_ep): + # External plugin explicitly declares is_external_tool_provider = True + mock_ep.return_value = [ + _make_entry_point("faketoolwrapper", "pkg:Cls", "prowler.providers"), + ] + mock_ep.return_value[0].load.return_value = FakeToolWrapperProvider + + assert Provider.is_tool_wrapper_provider("faketoolwrapper") is True + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + def test_returns_false_for_external_provider_without_flag(self, mock_ep): + # External plugin without the flag (default False) is treated as regular + mock_ep.return_value = [ + _make_entry_point("fakeexternal", "pkg:Cls", "prowler.providers"), + ] + mock_ep.return_value[0].load.return_value = FakeExternalProvider + + assert Provider.is_tool_wrapper_provider("fakeexternal") is False + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + def test_returns_false_for_unknown_provider(self, mock_ep): + mock_ep.return_value = [] + + assert Provider.is_tool_wrapper_provider("does-not-exist") is False + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + def test_returns_false_for_none_provider(self, mock_ep): + # Pydantic validators may pass None when values.get("Provider") is missing + mock_ep.return_value = [] + + assert Provider.is_tool_wrapper_provider(None) is False + + +class TestIsBuiltinProvider: + """Tests for Provider.is_builtin — the helper that discriminates built-in + providers from external ones before attempting the import, so transitive + dependency failures in built-ins don't get silently re-routed to entry points.""" + + def test_returns_true_for_builtin_provider(self): + assert Provider.is_builtin("aws") is True + assert Provider.is_builtin("github") is True + + def test_returns_false_for_unknown_provider(self): + assert Provider.is_builtin("nonexistent_xyz") is False + + @patch("prowler.providers.common.provider.importlib.util.find_spec") + def test_returns_false_when_find_spec_raises(self, mock_find_spec): + # Certain namespace package edge cases raise ValueError/ImportError — + # helper should swallow and return False rather than propagate. + mock_find_spec.side_effect = ValueError("namespace package edge case") + + assert Provider.is_builtin("some_provider") is False + + +class TestInitProvidersParserBuiltinDependencyFailure: + """Selective fail-loud: init captures failures silently, enforce emits + warning for non-invoked and exits for the invoked broken provider.""" + + @patch("sys.argv", ["prowler", "aws"]) + @patch("prowler.providers.common.arguments.Provider.is_builtin") + @patch("prowler.providers.common.arguments.import_module") + def test_builtin_with_missing_transitive_dep_fails_loudly( + self, mock_import, mock_is_builtin + ): + from prowler.providers.common.arguments import ( + enforce_invoked_provider_loaded, + init_providers_parser, + ) + + mock_is_builtin.return_value = True + mock_import.side_effect = ImportError("No module named 'boto3'") + + parser = MagicMock() + parser._providers = ["aws"] + + with patch( + "prowler.providers.common.arguments.Provider.get_available_providers", + return_value=["aws"], + ): + init_providers_parser(parser) + assert "aws" in parser._builtin_load_failures + with pytest.raises(SystemExit): + enforce_invoked_provider_loaded(parser) + + @patch("prowler.providers.common.arguments.Provider.is_builtin") + @patch("prowler.providers.common.arguments.Provider._load_ep_provider") + def test_external_provider_does_not_touch_builtin_path( + self, mock_load_ep, mock_is_builtin + ): + from prowler.providers.common.arguments import init_providers_parser + + mock_is_builtin.return_value = False + ext_cls = MagicMock() + ext_cls.init_parser = MagicMock() + mock_load_ep.return_value = ext_cls + + parser = MagicMock() + + with patch( + "prowler.providers.common.arguments.Provider.get_available_providers", + return_value=["fakeexternal"], + ): + init_providers_parser(parser) + + ext_cls.init_parser.assert_called_once_with(parser) + + @patch("sys.argv", ["prowler", "aws"]) + @patch("prowler.providers.common.arguments.Provider.is_builtin") + @patch("prowler.providers.common.arguments.import_module") + def test_unrelated_builtin_failure_does_not_abort_when_other_provider_invoked( + self, mock_import, mock_is_builtin + ): + """Broken stackit + invoked aws → warning, no abort.""" + from prowler.providers.common.arguments import ( + enforce_invoked_provider_loaded, + init_providers_parser, + ) + + mock_is_builtin.return_value = True + aws_module = MagicMock() + + def import_side_effect(module_path): + if "stackit" in module_path: + raise ImportError("No module named 'stackit.objectstorage'") + return aws_module + + mock_import.side_effect = import_side_effect + + parser = MagicMock() + + with patch( + "prowler.providers.common.arguments.Provider.get_available_providers", + return_value=["aws", "stackit"], + ): + init_providers_parser(parser) + assert "stackit" in parser._builtin_load_failures + enforce_invoked_provider_loaded(parser) + + aws_module.init_parser.assert_called_once_with(parser) + + @patch("sys.argv", ["prowler", "-h"]) + @patch("prowler.providers.common.arguments.Provider.is_builtin") + @patch("prowler.providers.common.arguments.import_module") + def test_no_provider_invoked_failure_does_not_abort( + self, mock_import, mock_is_builtin + ): + """`prowler -h` + broken built-in → warning, help still renders.""" + from prowler.providers.common.arguments import ( + enforce_invoked_provider_loaded, + init_providers_parser, + ) + + mock_is_builtin.return_value = True + mock_import.side_effect = ImportError("No module named 'stackit.objectstorage'") + + parser = MagicMock() + + with patch( + "prowler.providers.common.arguments.Provider.get_available_providers", + return_value=["stackit"], + ): + init_providers_parser(parser) + enforce_invoked_provider_loaded(parser) + + @patch("sys.argv", ["prowler", "microsoft365"]) + @patch("prowler.providers.common.arguments.Provider.is_builtin") + @patch("prowler.providers.common.arguments.import_module") + def test_invoked_microsoft365_alias_still_triggers_fail_loud( + self, mock_import, mock_is_builtin + ): + """Alias `microsoft365 → m365` must be normalised before matching.""" + from prowler.providers.common.arguments import ( + enforce_invoked_provider_loaded, + init_providers_parser, + ) + + mock_is_builtin.return_value = True + mock_import.side_effect = ImportError("No module named 'msgraph'") + + parser = MagicMock() + + with patch( + "prowler.providers.common.arguments.Provider.get_available_providers", + return_value=["m365"], + ): + init_providers_parser(parser) + with pytest.raises(SystemExit): + enforce_invoked_provider_loaded(parser) + + @patch("sys.argv", ["prowler", "oci"]) + @patch("prowler.providers.common.arguments.Provider.is_builtin") + @patch("prowler.providers.common.arguments.import_module") + def test_invoked_oci_alias_still_triggers_fail_loud( + self, mock_import, mock_is_builtin + ): + """Alias `oci → oraclecloud` must be normalised before matching.""" + from prowler.providers.common.arguments import ( + enforce_invoked_provider_loaded, + init_providers_parser, + ) + + mock_is_builtin.return_value = True + mock_import.side_effect = ImportError("No module named 'oci'") + + parser = MagicMock() + + with patch( + "prowler.providers.common.arguments.Provider.get_available_providers", + return_value=["oraclecloud"], + ): + init_providers_parser(parser) + with pytest.raises(SystemExit): + enforce_invoked_provider_loaded(parser) + + @patch("sys.argv", ["prowler", "--output-directory", "stackit"]) + @patch("prowler.providers.common.arguments.Provider.is_builtin") + @patch("prowler.providers.common.arguments.import_module") + def test_flag_value_matching_provider_name_not_treated_as_invoked( + self, mock_import, mock_is_builtin + ): + """Flag-first invocation → invoked is 'aws' (default), not the flag's value.""" + from prowler.providers.common.arguments import ( + enforce_invoked_provider_loaded, + init_providers_parser, + ) + + mock_is_builtin.return_value = True + aws_module = MagicMock() + + def import_side_effect(module_path): + if "stackit" in module_path: + raise ImportError("No module named 'stackit.objectstorage'") + return aws_module + + mock_import.side_effect = import_side_effect + + parser = MagicMock() + + with patch( + "prowler.providers.common.arguments.Provider.get_available_providers", + return_value=["aws", "stackit"], + ): + init_providers_parser(parser) + enforce_invoked_provider_loaded(parser) + + aws_module.init_parser.assert_called_once_with(parser) + + @patch("sys.argv", ["prowler", "aws"]) + @patch("prowler.providers.common.arguments.Provider.is_builtin") + @patch("prowler.providers.common.arguments.import_module") + def test_invoked_builtin_non_import_error_fails_loudly( + self, mock_import, mock_is_builtin + ): + """Non-ImportError in invoked provider → still fail-loud.""" + from prowler.providers.common.arguments import ( + enforce_invoked_provider_loaded, + init_providers_parser, + ) + + mock_is_builtin.return_value = True + mock_import.side_effect = RuntimeError("Unexpected error in aws init_parser") + + parser = MagicMock() + + with patch( + "prowler.providers.common.arguments.Provider.get_available_providers", + return_value=["aws"], + ): + init_providers_parser(parser) + with pytest.raises(SystemExit): + enforce_invoked_provider_loaded(parser) + + @patch("sys.argv", ["prowler", "aws"]) + @patch("prowler.providers.common.arguments.Provider.is_builtin") + @patch("prowler.providers.common.arguments.import_module") + def test_unrelated_builtin_non_import_error_does_not_abort( + self, mock_import, mock_is_builtin + ): + """Non-ImportError in unrelated provider → warning, no abort.""" + from prowler.providers.common.arguments import ( + enforce_invoked_provider_loaded, + init_providers_parser, + ) + + mock_is_builtin.return_value = True + aws_module = MagicMock() + + def import_side_effect(module_path): + if "stackit" in module_path: + raise RuntimeError("Unexpected error in stackit init_parser") + return aws_module + + mock_import.side_effect = import_side_effect + + parser = MagicMock() + + with patch( + "prowler.providers.common.arguments.Provider.get_available_providers", + return_value=["aws", "stackit"], + ): + init_providers_parser(parser) + enforce_invoked_provider_loaded(parser) + + aws_module.init_parser.assert_called_once_with(parser) + + +class TestParseArgsOverrideAlignment: + """Regression: `parse(args=...)` overrides sys.argv AFTER __init__ ran; + the selective fail-loud must read argv at enforce time, not init time.""" + + def test_enforce_reads_current_sys_argv_not_init_time_sys_argv(self): + """Init with argv=['prowler','-h'] (no provider) captures stackit + failure silently. Enforce with argv=['prowler','stackit'] must + fail-loud — proving alignment under parse(args=...).""" + from prowler.providers.common.arguments import ( + enforce_invoked_provider_loaded, + init_providers_parser, + ) + + def import_side_effect(path): + if "stackit" in path: + raise ImportError("No module named 'stackit.objectstorage'") + return MagicMock() + + parser = MagicMock() + + with ( + patch( + "prowler.providers.common.arguments.Provider.is_builtin", + return_value=True, + ), + patch( + "prowler.providers.common.arguments.Provider.get_available_providers", + return_value=["aws", "stackit"], + ), + patch( + "prowler.providers.common.arguments.import_module", + side_effect=import_side_effect, + ), + ): + # Phase 1: __init__ with ambient argv = ['prowler', '-h'] + with patch("sys.argv", ["prowler", "-h"]): + init_providers_parser(parser) + # Failure captured silently — no SystemExit during init + assert "stackit" in parser._builtin_load_failures + + # Phase 2: parse(args=...) overrode sys.argv → stackit invoked + with patch("sys.argv", ["prowler", "stackit"]): + with pytest.raises(SystemExit): + enforce_invoked_provider_loaded(parser) + + def test_enforce_reads_current_sys_argv_for_no_invocation(self): + """Inverse: init's argv invokes stackit, but parse(args=['prowler', + '-h']) overrides. Enforce must NOT fail-loud.""" + from prowler.providers.common.arguments import ( + enforce_invoked_provider_loaded, + init_providers_parser, + ) + + def import_side_effect(path): + if "stackit" in path: + raise ImportError("No module named 'stackit.objectstorage'") + return MagicMock() + + parser = MagicMock() + + with ( + patch( + "prowler.providers.common.arguments.Provider.is_builtin", + return_value=True, + ), + patch( + "prowler.providers.common.arguments.Provider.get_available_providers", + return_value=["aws", "stackit"], + ), + patch( + "prowler.providers.common.arguments.import_module", + side_effect=import_side_effect, + ), + ): + # Phase 1: __init__ with ambient argv pretending stackit invoked + with patch("sys.argv", ["prowler", "stackit"]): + init_providers_parser(parser) + assert "stackit" in parser._builtin_load_failures + + # Phase 2: parse(args=['prowler', '-h']) overrode sys.argv → + # no provider invoked anymore → enforce must NOT exit + with patch("sys.argv", ["prowler", "-h"]): + enforce_invoked_provider_loaded(parser) + + +class TestInitGlobalProviderBuiltinDependencyFailure: + """Same contract as TestInitProvidersParserBuiltinDependencyFailure but + for the provider class import path in init_global_provider.""" + + @patch("prowler.providers.common.provider.Provider.is_builtin") + @patch("prowler.providers.common.provider.import_module") + def test_builtin_with_missing_transitive_dep_fails_loudly( + self, mock_import, mock_is_builtin + ): + mock_is_builtin.return_value = True + mock_import.side_effect = ImportError("No module named 'boto3'") + + args = Namespace( + provider="aws", + fixer_config="config.yaml", + config_file="config.yaml", + ) + + Provider._global = None + with pytest.raises(SystemExit): + Provider.init_global_provider(args) + Provider._global = None + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + def test_load_ep_provider_handles_load_exception(self, mock_ep): + """_load_ep_provider returns None when ep.load() raises.""" + ep = _make_entry_point("broken", "pkg:Cls", "prowler.providers") + ep.load.side_effect = Exception("Import failed") + mock_ep.return_value = [ep] + + cls = Provider._load_ep_provider("broken") + + assert cls is None + + @patch("prowler.providers.common.provider.import_module") + @patch("prowler.providers.common.provider.Provider.is_builtin") + @patch("prowler.providers.common.provider.Provider.get_available_providers") + def test_get_providers_help_text_builtin_path( + self, mock_providers, mock_is_builtin, mock_import + ): + """get_providers_help_text reads _cli_help_text from a built-in provider module.""" + import types + + mock_providers.return_value = ["fakebuiltin"] + mock_is_builtin.return_value = True + + mock_cls = type( + "FakebuiltinProvider", (Provider,), {"_cli_help_text": "Built-in Help"} + ) + mock_module = types.ModuleType("fake_module") + mock_module.FakebuiltinProvider = mock_cls + mock_import.return_value = mock_module + + help_text = Provider.get_providers_help_text() + + assert help_text["fakebuiltin"] == "Built-in Help" + + @patch("prowler.providers.common.provider.import_module") + @patch("prowler.providers.common.provider.Provider.get_available_providers") + def test_get_providers_help_text_generic_exception( + self, mock_providers, mock_import + ): + """get_providers_help_text handles generic exceptions with empty string.""" + mock_providers.return_value = ["broken"] + mock_import.side_effect = RuntimeError("Unexpected error") + + help_text = Provider.get_providers_help_text() + + assert help_text["broken"] == "" + + +# =========================================================================== +# 2. Provider Initialization +# =========================================================================== + + +class TestProviderInitialization: + """Tests 8-9: init_global_provider fallback to entry point.""" + + @patch("prowler.providers.common.provider.load_and_validate_config_file") + @patch("prowler.providers.common.provider.Provider._load_ep_provider") + @patch("prowler.providers.common.provider.import_module") + def test_init_global_provider_fallback_to_entry_point( + self, mock_import, mock_load_ep, mock_config + ): + """Test 8: init_global_provider falls back to entry point when built-in fails.""" + mock_import.side_effect = ImportError("No built-in") + mock_load_ep.return_value = FakeExternalProvider + mock_config.return_value = {} + + args = Namespace( + provider="fakeexternal", + fixer_config="config.yaml", + config_file="config.yaml", + ) + + Provider._global = None + Provider.init_global_provider(args) + + assert isinstance(Provider._global, FakeExternalProvider) + Provider._global = None + + @patch("prowler.providers.common.provider.load_and_validate_config_file") + @patch("prowler.providers.common.provider.Provider._load_ep_provider") + @patch("prowler.providers.common.provider.import_module") + def test_init_global_provider_exits_for_unknown_provider( + self, mock_import, mock_load_ep, mock_config + ): + """Test 9: init_global_provider exits when provider not found anywhere.""" + mock_import.side_effect = ImportError("No built-in") + mock_load_ep.return_value = None + mock_config.return_value = {} + + args = Namespace( + provider="nonexistent", + fixer_config="config.yaml", + config_file="config.yaml", + ) + + with pytest.raises(SystemExit): + Provider.init_global_provider(args) + + @patch("prowler.providers.common.provider.load_and_validate_config_file") + @patch("prowler.providers.common.provider.Provider._load_ep_provider") + @patch("prowler.providers.common.provider.import_module") + def test_init_global_provider_wires_instance_returned_by_from_cli_args( + self, mock_import, mock_load_ep, mock_config + ): + """A provider that implements from_cli_args as a pure function (returns + the instance without calling set_global_provider from __init__) is + correctly wired as the global provider by init_global_provider.""" + mock_import.side_effect = ImportError("No built-in") + mock_load_ep.return_value = FakePureContractProvider + mock_config.return_value = {} + + args = Namespace( + provider="fakepure", + fixer_config="config.yaml", + config_file="config.yaml", + ) + + Provider._global = None + Provider.init_global_provider(args) + + assert isinstance(Provider._global, FakePureContractProvider) + Provider._global = None + + @pytest.mark.parametrize( + "plugin_name", + [ + "awsx", + "aws_lite", + "azure_gov", + "gcp_org", + "github_enterprise", + "iac_v2", + ], + ) + @patch("prowler.providers.common.provider.load_and_validate_config_file") + @patch("prowler.providers.common.provider.Provider._load_ep_provider") + @patch("prowler.providers.common.provider.import_module") + def test_init_global_provider_external_with_builtin_substring_uses_from_cli_args( + self, mock_import, mock_load_ep, mock_config, plugin_name + ): + """Regression guard for the substring footgun in the dispatch chain. + + An external plug-in whose name contains a built-in substring + (e.g. `awsx`, `aws_lite`, `azure_gov`, `gcp_org`, `github_enterprise`, + `iac_v2`) MUST be routed to the dynamic else and instantiated via + `from_cli_args` — not silently captured by the built-in branch whose + name happens to be a substring of the plug-in name. See PR #10700 + review. + """ + mock_import.side_effect = ImportError("No built-in") + mock_load_ep.return_value = FakeExternalProvider + mock_config.return_value = {} + + # Namespace deliberately omits the kwargs of any built-in branch + # (no `aws_retries_max_attempts`, `az_cli_auth`, `personal_access_token`, + # etc.). If equality dispatch is broken and the plug-in is misrouted to + # a built-in branch, attribute access will raise and the global never + # gets wired. + args = Namespace( + provider=plugin_name, + fixer_config="config.yaml", + config_file="config.yaml", + ) + + Provider._global = None + Provider.init_global_provider(args) + + assert isinstance(Provider._global, FakeExternalProvider) + Provider._global = None + + @patch("prowler.providers.common.provider.logger") + @patch("prowler.providers.common.provider.load_and_validate_config_file") + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + @patch("prowler.providers.common.provider.import_module") + @patch("prowler.providers.common.provider.Provider.is_builtin") + def test_init_global_provider_warns_when_plugin_shadowed_by_builtin( + self, mock_is_builtin, mock_import, mock_entry_points, mock_config, mock_logger + ): + """Regression guard: when a plug-in registers a provider name that + collides with a built-in, the BUILT-IN wins and a warning is emitted + naming the shadowed plug-in. Shadow detection matches by entry-point + name only — the plug-in is never `ep.load()`-ed just to warn, so its + module code cannot run during a built-in run. See PR #10700 review + (HugoPBrito, Alan-TheGentleman). + """ + # Simulate a built-in `aws` that exists, AND a plug-in registered + # under the same `aws` name via entry points. + mock_is_builtin.return_value = True + shadow_ep = MagicMock() + shadow_ep.name = "aws" # plug-in shadowing the built-in name + mock_entry_points.return_value = [shadow_ep] + mock_import.return_value = MagicMock( + AwsProvider=MagicMock(side_effect=lambda **_kw: None) + ) + mock_config.return_value = {} + + args = Namespace( + provider="aws", + fixer_config="config.yaml", + config_file="config.yaml", + aws_retries_max_attempts=3, + role=None, + session_duration=None, + external_id=None, + role_session_name=None, + mfa=None, + profile=None, + region=None, + excluded_region=None, + organizations_role=None, + scan_unused_services=False, + resource_tag=None, + resource_arn=None, + mutelist_file=None, + ) + + Provider._global = None + try: + Provider.init_global_provider(args) + except BaseException: + # The AwsProvider mock is fake and the dispatch may sys.exit on + # the simulated failure; we only care about the warning emitted + # before the dispatch happens. + pass + finally: + Provider._global = None + + # Warning was emitted naming the shadowed plug-in + warning_msgs = [ + call.args[0] + for call in mock_logger.warning.call_args_list + if call.args and "Plug-in provider 'aws'" in call.args[0] + ] + assert warning_msgs, "expected a warning about the shadowed plug-in 'aws'" + assert "IGNORED" in warning_msgs[0] + # Shadow detected by name only — plug-in code never executed to warn + shadow_ep.load.assert_not_called() + + +# =========================================================================== +# 3. Check Discovery +# =========================================================================== + + +class TestCheckDiscovery: + """Tests 10-14: _recover_ep_checks, recover_checks_from_provider.""" + + @patch("prowler.lib.check.utils.importlib.metadata.entry_points") + @patch("prowler.lib.check.utils.importlib.util.find_spec") + def test_recover_ep_checks_discovers_checks(self, mock_spec, mock_ep): + """Test 10: _recover_ep_checks discovers checks from entry points.""" + from prowler.lib.check.utils import _recover_ep_checks + + mock_ep.return_value = [ + _make_entry_point("my_check", "pkg.checks.my_check", "prowler.checks.fake"), + ] + mock_spec_obj = MagicMock() + mock_spec_obj.origin = "/path/to/pkg/checks/my_check.py" + mock_spec.return_value = mock_spec_obj + + checks = _recover_ep_checks("fake") + + assert len(checks) == 1 + assert checks[0][0] == "my_check" + assert checks[0][1] == "/path/to/pkg/checks" + + @patch("prowler.lib.check.utils.importlib.metadata.entry_points") + def test_recover_ep_checks_empty_without_entry_points(self, mock_ep): + """Test 11: _recover_ep_checks returns empty list with no entry points.""" + from prowler.lib.check.utils import _recover_ep_checks + + mock_ep.return_value = [] + + checks = _recover_ep_checks("fake") + + assert checks == [] + + @patch("prowler.lib.check.utils.importlib.metadata.entry_points") + @patch("prowler.lib.check.utils.importlib.util.find_spec") + def test_recover_ep_checks_handles_broken_entry_point(self, mock_spec, mock_ep): + """Test 12: _recover_ep_checks handles failed entry points gracefully.""" + from prowler.lib.check.utils import _recover_ep_checks + + mock_ep.return_value = [ + _make_entry_point("broken_check", "pkg.broken", "prowler.checks.fake"), + ] + mock_spec.side_effect = Exception("Module not found") + + checks = _recover_ep_checks("fake") + + assert checks == [] + + @patch("prowler.lib.check.utils._recover_ep_checks") + @patch("prowler.lib.check.utils.importlib.util.find_spec") + def test_recover_checks_handles_external_provider_without_services( + self, mock_find_spec, mock_ep_checks + ): + """Test 13: recover_checks_from_provider doesn't crash for external providers. + + With find_spec returning None (built-in package doesn't exist), discovery + falls through to entry points cleanly — no ModuleNotFoundError catch + needed. + """ + from prowler.lib.check.utils import recover_checks_from_provider + + mock_find_spec.return_value = None # not a built-in + mock_ep_checks.return_value = [("ext_check", "/path/to/check")] + + checks = recover_checks_from_provider("fakeexternal") + + assert len(checks) == 1 + assert checks[0][0] == "ext_check" + + @patch("prowler.lib.check.utils._recover_ep_checks") + @patch("prowler.lib.check.utils.list_modules") + @patch("prowler.lib.check.utils.importlib.util.find_spec") + def test_recover_checks_combines_builtin_and_entry_points( + self, mock_find_spec, mock_list_modules, mock_ep_checks + ): + """Test 14: recover_checks_from_provider combines built-in and entry point checks.""" + from prowler.lib.check.utils import recover_checks_from_provider + + mock_find_spec.return_value = MagicMock() # built-in package exists + + # Simulate a built-in module + builtin_module = MagicMock() + builtin_module.name = "prowler.providers.aws.services.ec2.check_a.check_a" + builtin_module.module_finder.path = "/builtin/path" + mock_list_modules.return_value = [builtin_module] + + mock_ep_checks.return_value = [("check_b", "/external/path")] + + checks = recover_checks_from_provider("aws") + + check_names = [c[0] for c in checks] + assert "check_a" in check_names + assert "check_b" in check_names + + @patch("prowler.lib.check.utils.importlib.metadata.entry_points") + @patch("prowler.lib.check.utils.importlib.util.find_spec") + def test_recover_ep_checks_filters_by_service(self, mock_spec, mock_ep): + """Service filter keeps only entry points whose dotted path includes + `.services.{service}.` — mirroring the built-in package filter.""" + from prowler.lib.check.utils import _recover_ep_checks + + mock_ep.return_value = [ + _make_entry_point( + "container_has_no_root_user", + "prowler_artifacts_dockerdesktop.services.container.container_has_no_root_user.container_has_no_root_user", + "prowler.checks.dockerdesktop", + ), + _make_entry_point( + "image_is_signed", + "prowler_artifacts_dockerdesktop.services.image.image_is_signed.image_is_signed", + "prowler.checks.dockerdesktop", + ), + ] + mock_spec_obj = MagicMock() + mock_spec_obj.origin = "/some/path/check.py" + mock_spec.return_value = mock_spec_obj + + checks = _recover_ep_checks("dockerdesktop", service="container") + + assert len(checks) == 1 + assert checks[0][0] == "container_has_no_root_user" + + @patch("prowler.lib.check.utils.importlib.metadata.entry_points") + @patch("prowler.lib.check.utils.importlib.util.find_spec") + def test_recover_ep_checks_without_service_returns_all(self, mock_spec, mock_ep): + """Without a service filter, all entry points for the provider are returned.""" + from prowler.lib.check.utils import _recover_ep_checks + + mock_ep.return_value = [ + _make_entry_point( + "container_has_no_root_user", + "prowler_artifacts_dockerdesktop.services.container.container_has_no_root_user.container_has_no_root_user", + "prowler.checks.dockerdesktop", + ), + _make_entry_point( + "image_is_signed", + "prowler_artifacts_dockerdesktop.services.image.image_is_signed.image_is_signed", + "prowler.checks.dockerdesktop", + ), + ] + mock_spec_obj = MagicMock() + mock_spec_obj.origin = "/some/path/check.py" + mock_spec.return_value = mock_spec_obj + + checks = _recover_ep_checks("dockerdesktop") + + assert len(checks) == 2 + + @patch("prowler.lib.check.utils._recover_ep_checks") + @patch("prowler.lib.check.utils.importlib.util.find_spec") + def test_recover_checks_external_provider_with_service( + self, mock_find_spec, mock_ep_checks + ): + """External provider with --service: built-in package doesn't exist, + but entry points are still consulted and return the requested service's + checks. No premature sys.exit.""" + from prowler.lib.check.utils import recover_checks_from_provider + + mock_find_spec.return_value = None # not a built-in + mock_ep_checks.return_value = [("container_check", "/ext/path")] + + checks = recover_checks_from_provider("dockerdesktop", service="container") + + assert len(checks) == 1 + assert checks[0][0] == "container_check" + mock_ep_checks.assert_called_once_with("dockerdesktop", "container") + + @patch("prowler.lib.check.utils._recover_ep_checks") + @patch("prowler.lib.check.utils.importlib.util.find_spec") + def test_recover_checks_unknown_service_fails_cleanly( + self, mock_find_spec, mock_ep_checks + ): + """A typo or unknown service (not in built-ins nor in entry points) + fails with a clear error message, not a silent empty result.""" + from prowler.lib.check.utils import recover_checks_from_provider + + mock_find_spec.return_value = None + mock_ep_checks.return_value = [] + + with pytest.raises(SystemExit): + recover_checks_from_provider("aws", service="typo_service") + + @patch("prowler.lib.check.utils._recover_ep_checks") + @patch("prowler.lib.check.utils.importlib.util.find_spec") + def test_recover_checks_builtin_with_new_external_service( + self, mock_find_spec, mock_ep_checks + ): + """Built-in provider with a new service added via entry points: + the built-in package for that specific service doesn't exist (find_spec + returns None), but entry points pick it up. The gate `if not service:` + that previously skipped entry points when --service was passed is removed.""" + from prowler.lib.check.utils import recover_checks_from_provider + + mock_find_spec.return_value = None # built-in for new_aws_service doesn't exist + mock_ep_checks.return_value = [("new_check", "/ext/path")] + + checks = recover_checks_from_provider("aws", service="new_aws_service") + + assert len(checks) == 1 + assert checks[0][0] == "new_check" + mock_ep_checks.assert_called_once_with("aws", "new_aws_service") + + @patch("prowler.lib.check.utils._recover_ep_checks") + @patch("prowler.lib.check.utils.list_modules") + @patch("prowler.lib.check.utils.importlib.util.find_spec") + def test_recover_checks_surfaces_error_when_builtin_service_import_fails( + self, mock_find_spec, mock_list_modules, mock_ep_checks + ): + """Regression guard: when a built-in service's package exists but one + of its modules fails to import (e.g. a broken transitive dependency), + the error must surface via the global exception handler — not be + silently swallowed and replaced by an entry-point plug-in that happens + to share a name. See PR #10700 review (HugoPBrito).""" + from prowler.lib.check.utils import recover_checks_from_provider + + mock_find_spec.return_value = MagicMock() # built-in service exists + mock_list_modules.side_effect = ImportError("missing transitive dep: foo") + + # Even if a plug-in registers checks for the same service, it must NOT + # silently take over — the import error wins. + mock_ep_checks.return_value = [("evil_check", "/evil/path")] + + with pytest.raises(SystemExit): + recover_checks_from_provider("aws", service="ec2") + + +# =========================================================================== +# 4. Check Execution +# =========================================================================== + + +class TestCheckExecution: + """Tests 15-17: _resolve_check_module.""" + + @patch("prowler.lib.check.check.importlib.util.find_spec") + @patch("prowler.lib.check.check.import_check") + def test_resolve_check_module_builtin_first(self, mock_import, mock_find_spec): + """Test 15: _resolve_check_module resolves built-in checks first.""" + from prowler.lib.check.check import _resolve_check_module + + mock_module = MagicMock() + mock_import.return_value = mock_module + mock_find_spec.return_value = MagicMock() # built-in package exists + + result = _resolve_check_module("aws", "ec2", "my_check") + + assert result is mock_module + mock_import.assert_called_once_with( + "prowler.providers.aws.services.ec2.my_check.my_check" + ) + + @patch("prowler.lib.check.check.importlib.util.find_spec") + @patch("prowler.lib.check.check.import_check") + def test_resolve_check_module_fallback_to_entry_point( + self, mock_import_check, mock_find_spec + ): + """Test 16: _resolve_check_module falls back to entry point when built-in is absent.""" + from prowler.lib.check.check import _resolve_check_module + + mock_find_spec.return_value = None # built-in does not exist + + mock_ext_module = MagicMock() + ep = _make_entry_point( + "my_check", "ext_pkg.checks.my_check", "prowler.checks.fake" + ) + + with ( + patch("importlib.metadata.entry_points", return_value=[ep]), + patch("importlib.import_module", return_value=mock_ext_module) as mock_imp, + ): + result = _resolve_check_module("fake", "svc", "my_check") + + assert result is mock_ext_module + mock_imp.assert_called_with("ext_pkg.checks.my_check") + mock_import_check.assert_not_called() + + @patch("prowler.lib.check.check.importlib.util.find_spec") + @patch("prowler.lib.check.check.import_check") + def test_resolve_check_module_builtin_wins_over_entry_point( + self, mock_import_check, mock_find_spec + ): + """Regression guard: when both a built-in and an entry-point check + exist with the same CheckID, the BUILT-IN wins. Plug-ins extend + Prowler with new checks but cannot silently override existing + built-ins — a security tool prefers fail-loud predictability over + permissive overrides. CheckMetadata.get_bulk applies the same + precedence (first-write-wins) and emits a warning. See PR #10700 + review (HugoPBrito).""" + from prowler.lib.check.check import _resolve_check_module + + mock_find_spec.return_value = MagicMock() # built-in exists + builtin_module = MagicMock() + mock_import_check.return_value = builtin_module + + # Plug-in registers same CheckID — must NOT take precedence + ep = _make_entry_point( + "ec2_instance_public_ip", + "plug_pkg.checks.ec2_instance_public_ip", + "prowler.checks.aws", + ) + + with ( + patch("importlib.metadata.entry_points", return_value=[ep]), + patch("importlib.import_module") as mock_imp, + ): + result = _resolve_check_module("aws", "ec2", "ec2_instance_public_ip") + + assert result is builtin_module + mock_import_check.assert_called_once_with( + "prowler.providers.aws.services.ec2.ec2_instance_public_ip.ec2_instance_public_ip" + ) + # Plug-in must NOT be loaded when a built-in with the same CheckID exists + mock_imp.assert_not_called() + + @patch("prowler.lib.check.check.importlib.metadata.entry_points") + @patch("prowler.lib.check.check.importlib.util.find_spec") + def test_resolve_check_module_raises_when_not_found(self, mock_find_spec, mock_ep): + """Test 17: _resolve_check_module raises ModuleNotFoundError when both fail.""" + from prowler.lib.check.check import _resolve_check_module + + mock_find_spec.return_value = None + mock_ep.return_value = [] + + with pytest.raises(ModuleNotFoundError, match="not found"): + _resolve_check_module("fake", "svc", "nonexistent_check") + + @patch("prowler.lib.check.check.importlib.util.find_spec") + @patch("prowler.lib.check.check.import_check") + def test_resolve_check_module_surfaces_error_when_builtin_import_fails( + self, mock_import_check, mock_find_spec + ): + """Regression guard: when no plug-in entry-point overrides the + check, a built-in whose module exists but fails to import (e.g. + broken transitive dependency) MUST surface the real error instead + of being silently treated as 'not found'. See PR #10700 review + (HugoPBrito).""" + from prowler.lib.check.check import _resolve_check_module + + mock_find_spec.return_value = MagicMock() # built-in module exists + mock_import_check.side_effect = ImportError("missing transitive dep: foo") + + # No plug-in override — the built-in's import failure must propagate + with patch("importlib.metadata.entry_points", return_value=[]): + with pytest.raises(ImportError, match="missing transitive dep"): + _resolve_check_module("aws", "ec2", "ec2_instance_public_ip") + + +# =========================================================================== +# 5. CLI Arguments +# =========================================================================== + + +class TestCLIArguments: + """Tests 18-19: init_providers_parser fallback.""" + + @patch("prowler.providers.common.arguments.Provider._load_ep_provider") + @patch("prowler.providers.common.arguments.Provider.get_available_providers") + @patch("prowler.providers.common.arguments.import_module") + def test_init_providers_parser_fallback_to_init_parser( + self, mock_import, mock_providers, mock_load_ep + ): + """Test 18: init_providers_parser falls back to cls.init_parser for external providers.""" + from prowler.providers.common.arguments import init_providers_parser + + mock_providers.return_value = ["fakeexternal"] + mock_import.side_effect = ImportError("No built-in arguments module") + mock_load_ep.return_value = FakeExternalProvider + + parser_instance = MagicMock() + + # Should not raise + init_providers_parser(parser_instance) + + @patch("prowler.providers.common.arguments.Provider._load_ep_provider") + @patch("prowler.providers.common.arguments.Provider.get_available_providers") + @patch("prowler.providers.common.arguments.import_module") + def test_init_providers_parser_no_crash_without_init_parser( + self, mock_import, mock_providers, mock_load_ep + ): + """Test 19: init_providers_parser doesn't crash if provider has no init_parser.""" + from prowler.providers.common.arguments import init_providers_parser + + mock_providers.return_value = ["nohelptext"] + mock_import.side_effect = ImportError("No built-in") + # FakeProviderNoHelpText has no init_parser + mock_load_ep.return_value = FakeProviderNoHelpText + + parser_instance = MagicMock() + + # Should not raise + init_providers_parser(parser_instance) + + @patch("prowler.providers.common.arguments.Provider._load_ep_provider") + @patch("prowler.providers.common.arguments.Provider.get_available_providers") + @patch("prowler.providers.common.arguments.import_module") + def test_init_providers_parser_handles_init_parser_exception( + self, mock_import, mock_providers, mock_load_ep + ): + """init_providers_parser handles exception when init_parser raises.""" + from prowler.providers.common.arguments import init_providers_parser + + mock_providers.return_value = ["fakeexternal"] + mock_import.side_effect = ImportError("No built-in") + + broken_cls = MagicMock() + broken_cls.init_parser.side_effect = RuntimeError("Parser init failed") + mock_load_ep.return_value = broken_cls + + parser_instance = MagicMock() + + # Should not raise + init_providers_parser(parser_instance) + + +# =========================================================================== +# 6. Compliance +# =========================================================================== + + +class TestCompliance: + """Tests 20-23: compliance discovery and loading.""" + + @patch("prowler.config.config.importlib.metadata.entry_points") + def test_get_ep_compliance_dirs_discovers_dirs(self, mock_ep): + """Test 20: _get_ep_compliance_dirs discovers compliance directories.""" + from prowler.config.config import _get_ep_compliance_dirs + + mock_module = MagicMock() + mock_module.__path__ = ["/path/to/compliance"] + ep = _make_entry_point("fakeexternal", "pkg.compliance", "prowler.compliance") + ep.load.return_value = mock_module + mock_ep.return_value = [ep] + + dirs = _get_ep_compliance_dirs() + + assert dirs["fakeexternal"] == "/path/to/compliance" + + @patch("prowler.config.config.importlib.metadata.entry_points") + def test_get_ep_compliance_dirs_file_fallback(self, mock_ep): + """_get_ep_compliance_dirs uses __file__ when module has no __path__.""" + from prowler.config.config import _get_ep_compliance_dirs + + mock_module = MagicMock(spec=[]) + mock_module.__file__ = "/path/to/compliance/__init__.py" + del mock_module.__path__ + ep = _make_entry_point("ext", "pkg.compliance", "prowler.compliance") + ep.load.return_value = mock_module + mock_ep.return_value = [ep] + + dirs = _get_ep_compliance_dirs() + + assert dirs["ext"] == "/path/to/compliance" + + @patch("prowler.config.config.importlib.metadata.entry_points") + def test_get_ep_compliance_dirs_handles_load_exception(self, mock_ep): + """_get_ep_compliance_dirs handles ep.load() exception gracefully.""" + from prowler.config.config import _get_ep_compliance_dirs + + ep = _make_entry_point("broken", "pkg.compliance", "prowler.compliance") + ep.load.side_effect = Exception("Load failed") + mock_ep.return_value = [ep] + + dirs = _get_ep_compliance_dirs() + + assert dirs == {} + + @patch("prowler.config.config._get_ep_compliance_dirs") + def test_get_available_compliance_includes_external(self, mock_dirs): + """Test 21: get_available_compliance_frameworks includes external compliance.""" + import json + import os + import tempfile + + from prowler.config.config import get_available_compliance_frameworks + + # Create a temp dir with a compliance JSON + with tempfile.TemporaryDirectory() as tmpdir: + json_path = os.path.join(tmpdir, "custom_1.0_ext.json") + with open(json_path, "w") as f: + json.dump({"Framework": "Custom", "Provider": "ext"}, f) + + mock_dirs.return_value = {"ext": tmpdir} + + frameworks = get_available_compliance_frameworks("ext") + + 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): + """External universal frameworks under prowler.compliance.universal are + listed, for a provider and for the provider=None case that feeds + --compliance choices.""" + import json + import os + import tempfile + + from prowler.config.config import get_available_compliance_frameworks + + with tempfile.TemporaryDirectory() as tmpdir: + framework = { + "framework": "CustomUniversal", + "name": "Custom Universal", + "version": "1.0", + "description": "Multi-provider", + "requirements": [ + { + "id": "1", + "name": "r", + "description": "d", + "checks": {"aws": ["c"]}, + } + ], + } + with open(os.path.join(tmpdir, "customuniversal_1.0.json"), "w") as f: + json.dump(framework, f) + + module = MagicMock() + module.__path__ = [tmpdir] + ep = _make_entry_point( + "anyname", "pkg.compliance", "prowler.compliance.universal" + ) + ep.load.return_value = module + mock_ep.side_effect = lambda group: ( + [ep] if group == "prowler.compliance.universal" else [] + ) + + assert "customuniversal_1.0" in get_available_compliance_frameworks("aws") + assert "customuniversal_1.0" in get_available_compliance_frameworks(None) + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + @patch("prowler.lib.check.compliance_models.list_compliance_modules") + def test_compliance_get_bulk_loads_external(self, mock_list_modules, mock_ep): + """Test 22: Compliance.get_bulk loads external compliance JSON.""" + import json + import os + import tempfile + + from prowler.lib.check.compliance_models import Compliance + + mock_list_modules.return_value = [] + + # Create a valid compliance JSON + with tempfile.TemporaryDirectory() as tmpdir: + json_data = { + "Framework": "Custom", + "Name": "Custom Framework", + "Version": "1.0", + "Provider": "fakeexternal", + "Description": "Test framework", + "Requirements": [], + } + json_path = os.path.join(tmpdir, "custom_1.0_fakeexternal.json") + with open(json_path, "w") as f: + json.dump(json_data, f) + + mock_module = MagicMock() + mock_module.__path__ = [tmpdir] + ep = _make_entry_point( + "fakeexternal", "pkg.compliance", "prowler.compliance" + ) + ep.load.return_value = mock_module + mock_ep.return_value = [ep] + + bulk = Compliance.get_bulk("fakeexternal") + + assert "custom_1.0_fakeexternal" in bulk + assert bulk["custom_1.0_fakeexternal"].Framework == "Custom" + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + @patch("prowler.lib.check.compliance_models.list_compliance_modules") + def test_compliance_get_bulk_skips_non_legacy_external_json( + self, mock_list_modules, mock_ep + ): + """A universal-schema JSON registered under prowler.compliance is skipped, + not aborting the run via sys.exit.""" + import json + import os + import tempfile + + from prowler.lib.check.compliance_models import Compliance + + mock_list_modules.return_value = [] + + with tempfile.TemporaryDirectory() as tmpdir: + json_data = { + "framework": "Universal", + "name": "Universal Framework", + "version": "1.0", + "description": "Multi-provider", + "requirements": [ + { + "id": "1", + "name": "r", + "description": "d", + "checks": {"aws": ["c"]}, + } + ], + } + with open(os.path.join(tmpdir, "universal_1.0.json"), "w") as f: + json.dump(json_data, f) + + mock_module = MagicMock() + mock_module.__path__ = [tmpdir] + ep = _make_entry_point("aws", "pkg.compliance", "prowler.compliance") + ep.load.return_value = mock_module + mock_ep.return_value = [ep] + + bulk = Compliance.get_bulk("aws") + + assert "universal_1.0" not in bulk + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + @patch("prowler.lib.check.compliance_models.list_compliance_modules") + def test_compliance_get_bulk_file_fallback(self, mock_list_modules, mock_ep): + """Compliance.get_bulk uses __file__ when external module has no __path__.""" + import json + import os + import tempfile + + from prowler.lib.check.compliance_models import Compliance + + mock_list_modules.return_value = [] + + with tempfile.TemporaryDirectory() as tmpdir: + json_data = { + "Framework": "Custom", + "Name": "Custom File Fallback", + "Version": "1.0", + "Provider": "fakeexternal", + "Description": "Test", + "Requirements": [], + } + json_path = os.path.join(tmpdir, "custom_file_fakeexternal.json") + with open(json_path, "w") as f: + json.dump(json_data, f) + + mock_module = MagicMock(spec=[]) + mock_module.__file__ = os.path.join(tmpdir, "__init__.py") + del mock_module.__path__ + ep = _make_entry_point( + "fakeexternal", "pkg.compliance", "prowler.compliance" + ) + ep.load.return_value = mock_module + mock_ep.return_value = [ep] + + bulk = Compliance.get_bulk("fakeexternal") + + assert "custom_file_fakeexternal" in bulk + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + @patch("prowler.lib.check.compliance_models.list_compliance_modules") + def test_compliance_get_bulk_handles_external_exception( + self, mock_list_modules, mock_ep + ): + """Compliance.get_bulk handles exception when loading external compliance.""" + from prowler.lib.check.compliance_models import Compliance + + mock_list_modules.return_value = [] + + ep = _make_entry_point("fakeexternal", "pkg.compliance", "prowler.compliance") + ep.load.side_effect = Exception("Load failed") + mock_ep.return_value = [ep] + + bulk = Compliance.get_bulk("fakeexternal") + + assert bulk == {} + + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + @patch("prowler.lib.check.compliance_models.list_compliance_modules") + def test_compliance_get_bulk_builtin_wins_on_duplicate( + self, mock_list_modules, mock_ep + ): + """Test 23: Compliance.get_bulk built-in wins on duplicate framework names.""" + import json + import os + import tempfile + + from prowler.lib.check.compliance_models import Compliance + + mock_list_modules.return_value = [] + mock_ep.return_value = [] + + # If both exist with same key, built-in (loaded first) should win + # Since we have no built-in modules mocked, just verify external loads + # The actual dedup logic: `if name not in bulk_compliance_frameworks` + with tempfile.TemporaryDirectory() as tmpdir: + json_data = { + "Framework": "CIS", + "Name": "CIS Test", + "Version": "1.0", + "Provider": "fakeexternal", + "Description": "Test", + "Requirements": [], + } + with open(os.path.join(tmpdir, "dup_framework.json"), "w") as f: + json.dump(json_data, f) + + mock_module = MagicMock() + mock_module.__path__ = [tmpdir] + ep = _make_entry_point( + "fakeexternal", "pkg.compliance", "prowler.compliance" + ) + ep.load.return_value = mock_module + mock_ep.return_value = [ep] + + bulk = Compliance.get_bulk("fakeexternal") + + assert "dup_framework" in bulk + + @pytest.mark.parametrize( + "provider, framework_segments", + [ + # `cloud` is a substring of THREE built-in modules at once. + ("cloud", ["alibabacloud", "cloudflare", "oraclecloud"]), + ("git", ["github"]), + ("work", ["googleworkspace"]), + ("open", ["openstack"]), + ], + ) + @patch("prowler.lib.check.compliance_models.importlib.metadata.entry_points") + @patch("prowler.lib.check.compliance_models.list_compliance_modules") + def test_compliance_get_bulk_matches_provider_segment_exactly( + self, mock_list_modules, mock_ep, provider, framework_segments + ): + """Regression: a provider whose name is a substring of one or more + framework modules must NOT load them. The old `provider in name` + check captured overlapping built-ins (e.g. `cloud` matched + alibabacloud, cloudflare and oraclecloud). See PR #10700 review + (Alan-TheGentleman). + """ + import json + import os + import tempfile + + from prowler.lib.check.compliance_models import Compliance + + mock_ep.return_value = [] + + with tempfile.TemporaryDirectory() as tmpdir: + # The substring path the old code would have read from. + os.mkdir(os.path.join(tmpdir, provider)) + json_data = { + "Framework": "Custom", + "Name": f"Should not load for '{provider}'", + "Version": "1.0", + "Provider": provider, + "Description": "Test", + "Requirements": [], + } + with open(os.path.join(tmpdir, provider, "wrong.json"), "w") as f: + json.dump(json_data, f) + + modules = [] + for segment in framework_segments: + module = MagicMock() + module.name = f"prowler.compliance.{segment}" + module.module_finder.path = tmpdir + modules.append(module) + mock_list_modules.return_value = modules + + bulk = Compliance.get_bulk(provider) + + # Exact-segment match: the provider is not any of these modules. + assert "wrong" not in bulk + assert bulk == {} + + +# =========================================================================== +# 7. Parser +# =========================================================================== + + +class TestParser: + """Tests 24-27: parser dynamic discovery.""" + + @patch("prowler.lib.cli.parser.Provider.get_providers_help_text") + @patch("prowler.lib.cli.parser.Provider.get_available_providers") + def test_parser_discovers_new_providers(self, mock_providers, mock_help): + """Test 24: Parser discovers providers not in known_providers.""" + from prowler.lib.cli.parser import ProwlerArgumentParser + + mock_providers.return_value = [ + "aws", + "azure", + "gcp", + "kubernetes", + "m365", + "github", + "googleworkspace", + "cloudflare", + "oraclecloud", + "openstack", + "alibabacloud", + "iac", + "llm", + "image", + "nhn", + "mongodbatlas", + "fakeexternal", + ] + mock_help.return_value = {"fakeexternal": "Fake External Provider"} + + parser = ProwlerArgumentParser() + + assert "fakeexternal" in parser.parser.format_usage() + + @patch("prowler.lib.cli.parser.Provider.get_providers_help_text") + @patch("prowler.lib.cli.parser.Provider.get_available_providers") + def test_parser_appends_to_epilog_with_help_text(self, mock_providers, mock_help): + """Test 25: Parser appends new providers to epilog with _cli_help_text.""" + from prowler.lib.cli.parser import ProwlerArgumentParser + + mock_providers.return_value = [ + "aws", + "azure", + "gcp", + "kubernetes", + "m365", + "github", + "googleworkspace", + "cloudflare", + "oraclecloud", + "openstack", + "alibabacloud", + "iac", + "llm", + "image", + "nhn", + "mongodbatlas", + "fakeexternal", + ] + mock_help.return_value = {"fakeexternal": "Fake External Provider"} + + parser = ProwlerArgumentParser() + epilog = parser.parser.epilog + + assert "fakeexternal" in epilog + assert "Fake External Provider" in epilog + + @patch("prowler.lib.cli.parser.Provider.get_providers_help_text") + @patch("prowler.lib.cli.parser.Provider.get_available_providers") + def test_parser_skips_epilog_entry_without_help_text( + self, mock_providers, mock_help + ): + """Test 26: Parser doesn't add epilog entry if _cli_help_text is empty.""" + from prowler.lib.cli.parser import ProwlerArgumentParser + + mock_providers.return_value = [ + "aws", + "azure", + "gcp", + "kubernetes", + "m365", + "github", + "googleworkspace", + "cloudflare", + "oraclecloud", + "openstack", + "alibabacloud", + "iac", + "llm", + "image", + "nhn", + "mongodbatlas", + "nohelptext", + ] + mock_help.return_value = {"nohelptext": ""} + + parser = ProwlerArgumentParser() + epilog = parser.parser.epilog + + # Should appear in usage/csv but NOT in the descriptive epilog listing + assert "nohelptext" in parser.parser.format_usage() + # No line with "nohelptext Something" in epilog + epilog_lines = [ + line.strip() for line in epilog.splitlines() if "nohelptext" in line + ] + assert len(epilog_lines) == 0 or all( + "nohelptext" in line and line.strip() == "nohelptext" or "{" in line + for line in epilog_lines + ) + + @patch("prowler.lib.cli.parser.Provider.get_providers_help_text") + @patch("prowler.lib.cli.parser.Provider.get_available_providers") + def test_parser_does_not_duplicate_known_providers(self, mock_providers, mock_help): + """Test 27: Parser doesn't duplicate providers already in the known list.""" + from prowler.lib.cli.parser import ProwlerArgumentParser + + # No new providers + mock_providers.return_value = [ + "aws", + "azure", + "gcp", + "kubernetes", + "m365", + "github", + "googleworkspace", + "cloudflare", + "oraclecloud", + "openstack", + "alibabacloud", + "iac", + "llm", + "image", + "nhn", + "mongodbatlas", + ] + mock_help.return_value = {} + + parser = ProwlerArgumentParser() + usage = parser.parser.format_usage() + + # aws should appear exactly once in usage + assert usage.count("aws") == 1 + + +# =========================================================================== +# 8. Dispatch Fallbacks +# =========================================================================== + + +class TestDispatchFallbacks: + """Tests 28-34: all else clause fallbacks for external providers.""" + + def test_stdout_report_calls_get_stdout_detail(self, fake_provider): + """Test 28: stdout_report else clause calls provider.get_stdout_detail.""" + from prowler.lib.outputs.outputs import stdout_report + + finding = MagicMock() + finding.check_metadata.Provider = "fakeexternal" + finding.status = "FAIL" + finding.muted = False + finding.status_extended = "test" + + with patch("builtins.print") as mock_print: + stdout_report( + finding, "\033[31m", True, ["FAIL"], False, provider=fake_provider + ) + + mock_print.assert_called_once() + printed = mock_print.call_args[0][0] + assert "fake-detail" in printed + + def test_stdout_report_resolves_provider_when_none(self, fake_provider): + """stdout_report resolves provider via get_global_provider when not passed.""" + from prowler.lib.outputs.outputs import stdout_report + + finding = MagicMock() + finding.check_metadata.Provider = "fakeexternal" + finding.status = "FAIL" + finding.muted = False + finding.status_extended = "test" + + with patch("builtins.print") as mock_print: + stdout_report(finding, "\033[31m", True, ["FAIL"], False) + + mock_print.assert_called_once() + printed = mock_print.call_args[0][0] + assert "fake-detail" in printed + + def test_report_propagates_provider_to_stdout_report(self): + """Regression guard: report() must pass its local `provider` through to + stdout_report so the dynamic else does not fall back to the global + singleton. With Provider._global cleared, the call chain still has to + work for an external provider — proving the argument is being used + instead of `Provider.get_global_provider()`. See PR #10700 review + (HugoPBrito).""" + from prowler.lib.outputs.outputs import report + from prowler.providers.common.provider import Provider + + local_provider = FakeExternalProvider.__new__(FakeExternalProvider) + + finding = MagicMock() + finding.status = "PASS" + finding.muted = False + finding.region = "x" + finding.check_metadata.Provider = "fakeexternal" + finding.status_extended = "test" + + output_options = MagicMock() + output_options.verbose = True + output_options.status = [] + output_options.fixer = False + + # Clear the global singleton so any unintended fallback would crash. + previous_global = Provider._global + Provider._global = None + try: + with patch("builtins.print") as mock_print: + report([finding], local_provider, output_options) + + # report() prints the finding line plus an empty separator when + # verbose is set; we only care that the finding was rendered using + # the local provider's `get_stdout_detail` ("fake-detail"). + printed = "".join( + call.args[0] for call in mock_print.call_args_list if call.args + ) + assert "fake-detail" in printed + finally: + Provider._global = previous_global + + def test_report_sort_calls_get_finding_sort_key(self, fake_provider): + """Test 29: report else clause calls provider.get_finding_sort_key.""" + from prowler.lib.outputs.outputs import report + + finding1 = MagicMock() + finding1.status = "PASS" + finding1.muted = False + finding1.region = "b-region" + finding1.check_metadata.Provider = "fakeexternal" + finding1.status_extended = "test1" + + finding2 = MagicMock() + finding2.status = "PASS" + finding2.muted = False + finding2.region = "a-region" + finding2.check_metadata.Provider = "fakeexternal" + finding2.status_extended = "test2" + + output_options = MagicMock() + output_options.verbose = False + output_options.status = [] + + findings = [finding1, finding2] + report(findings, fake_provider, output_options) + + # Should be sorted by region (get_finding_sort_key returns "region") + assert findings[0].region == "a-region" + assert findings[1].region == "b-region" + + def test_display_summary_table_calls_get_summary_entity(self, fake_provider): + """Test 30: display_summary_table else clause calls provider.get_summary_entity.""" + from prowler.lib.outputs.summary_table import display_summary_table + + finding = MagicMock() + finding.status = "FAIL" + finding.muted = False + finding.check_metadata.ServiceName = "test_service" + finding.check_metadata.Provider = "fakeexternal" + finding.check_metadata.Severity = "high" + + output_options = MagicMock() + output_options.output_directory = "/tmp" + output_options.output_filename = "test" + output_options.output_modes = [] + + with patch("builtins.print") as mock_print: + display_summary_table([finding], fake_provider, output_options) + + printed_text = " ".join(str(c) for c in mock_print.call_args_list) + assert "Fake Host" in printed_text or "fake-host-1" in printed_text + + def test_generate_output_calls_get_finding_output_data(self, fake_provider): + """Test 31: finding.generate_output else clause calls provider.get_finding_output_data.""" + from prowler.lib.check.models import ( + CheckMetadata, + Code, + Recommendation, + Remediation, + ) + from prowler.lib.outputs.finding import Finding + + metadata = CheckMetadata( + Provider="fakeexternal", + CheckID="test_check", + CheckTitle="Test check title", + CheckType=[], + ServiceName="test", + SubServiceName="", + ResourceIdTemplate="", + Severity="high", + ResourceType="Test", + ResourceGroup="", + Description="Test description", + Risk="Test risk", + RelatedUrl="", + Remediation=Remediation( + Code=Code(CLI="", NativeIaC="", Other="", Terraform=""), + Recommendation=Recommendation( + Text="Fix it", Url="https://hub.prowler.com/check/test_check" + ), + ), + Categories=[], + DependsOn=[], + RelatedTo=[], + Notes="", + ) + + check_output = MagicMock() + check_output.check_metadata = metadata + check_output.status = "FAIL" + check_output.status_extended = "test failed" + check_output.muted = False + check_output.resource = {} + check_output.resource_details = "" + check_output.resource_tags = {} + check_output.compliance = {} + + output_options = MagicMock() + output_options.unix_timestamp = False + output_options.bulk_checks_metadata = {} + + finding = Finding.generate_output(fake_provider, check_output, output_options) + + assert finding.auth_method == "fake" + assert finding.account_uid == "fake-account" + assert finding.resource_name == "fake-resource" + assert finding.region == "local" + + def test_output_options_calls_get_output_options(self, fake_provider): + """Test 32: __main__.py else clause calls provider.get_output_options.""" + result = fake_provider.get_output_options(MagicMock(), {}) + + assert result is not None + assert hasattr(result, "output_directory") + + def test_html_assessment_calls_get_html_assessment_summary(self, fake_provider): + """Test 33: html.py fallback calls provider.get_html_assessment_summary.""" + from prowler.lib.outputs.html.html import HTML + + result = HTML.get_assessment_summary(fake_provider) + + assert "
Fake Assessment
" in result + + def test_compliance_output_calls_generate_compliance_output(self, fake_provider): + """Test 34: __main__.py else clause calls provider.generate_compliance_output.""" + generated_outputs = {"compliance": []} + + fake_provider.generate_compliance_output( + [], + {}, + set(), + MagicMock(), + generated_outputs, + ) + + assert "fake-compliance-output" in generated_outputs["compliance"] + + +# =========================================================================== +# 9. Base Contract Defaults +# =========================================================================== + + +class TestBaseContractDefaults: + """Tests for Provider base class default implementations.""" + + def test_from_cli_args_raises_not_implemented(self): + """Base Provider.from_cli_args raises NotImplementedError.""" + with pytest.raises(NotImplementedError): + FakeProviderNoHelpText.from_cli_args(MagicMock(), {}) + + def test_get_output_options_raises_not_implemented(self): + """Base Provider.get_output_options raises NotImplementedError; the + generic default is applied at the call site via default_output_options.""" + provider = FakeProviderNoHelpText() + with pytest.raises(NotImplementedError): + provider.get_output_options(MagicMock(), {}) + + def test_default_output_options_builds_generic_default(self): + """default_output_options returns a generic ProviderOutputOptions so an + external provider without get_output_options still produces output + instead of aborting the run.""" + from prowler.config.config import output_file_timestamp + from prowler.providers.common.models import ( + ProviderOutputOptions, + default_output_options, + ) + + provider = FakeProviderNoHelpText() + arguments = Namespace( + status=None, + output_formats=None, + output_directory=None, + output_filename=None, + verbose=None, + only_logs=None, + unix_timestamp=None, + shodan=None, + fixer=None, + ) + + output_options = default_output_options(provider, arguments, {}) + + assert isinstance(output_options, ProviderOutputOptions) + assert ( + output_options.output_filename + == f"prowler-output-{provider.type}-{output_file_timestamp}" + ) + + def test_default_output_options_honors_explicit_filename(self): + """A user-supplied output_filename is preserved by default_output_options.""" + from prowler.providers.common.models import default_output_options + + provider = FakeProviderNoHelpText() + arguments = Namespace( + status=None, + output_formats=None, + output_directory=None, + output_filename="custom-name", + verbose=None, + only_logs=None, + unix_timestamp=None, + shodan=None, + fixer=None, + ) + + output_options = default_output_options(provider, arguments, {}) + + assert output_options.output_filename == "custom-name" + + def test_get_stdout_detail_raises_not_implemented(self): + """Base Provider.get_stdout_detail raises NotImplementedError.""" + provider = FakeProviderNoHelpText() + with pytest.raises(NotImplementedError): + provider.get_stdout_detail(MagicMock()) + + def test_get_finding_sort_key_returns_none(self): + """Base Provider.get_finding_sort_key returns None.""" + provider = FakeProviderNoHelpText() + assert provider.get_finding_sort_key() is None + + def test_get_summary_entity_returns_type_and_account_default(self): + """Base Provider.get_summary_entity returns (type, account_id) so the + summary table is not silently dropped for providers that don't override + it.""" + from types import SimpleNamespace + from unittest.mock import PropertyMock + + provider = FakeProviderNoHelpText() + with patch.object( + type(provider), + "identity", + new_callable=PropertyMock, + return_value=SimpleNamespace(account_id="acc-123"), + ): + entity_type, audited_entities = provider.get_summary_entity() + + assert entity_type == provider.type + assert audited_entities == "acc-123" + + def test_get_summary_entity_defaults_account_to_empty_string(self): + """When the identity has no account_id, audited_entities falls back to ''.""" + from types import SimpleNamespace + from unittest.mock import PropertyMock + + provider = FakeProviderNoHelpText() + with patch.object( + type(provider), + "identity", + new_callable=PropertyMock, + return_value=SimpleNamespace(), + ): + entity_type, audited_entities = provider.get_summary_entity() + + assert entity_type == provider.type + assert audited_entities == "" + + def test_get_finding_output_data_raises_not_implemented(self): + """Base Provider.get_finding_output_data raises NotImplementedError.""" + provider = FakeProviderNoHelpText() + with pytest.raises(NotImplementedError): + provider.get_finding_output_data(MagicMock()) + + def test_get_html_assessment_summary_raises_not_implemented(self): + """Base Provider.get_html_assessment_summary raises NotImplementedError.""" + provider = FakeProviderNoHelpText() + with pytest.raises(NotImplementedError): + provider.get_html_assessment_summary() + + def test_generate_compliance_output_raises_not_implemented(self): + """Base Provider.generate_compliance_output raises NotImplementedError.""" + provider = FakeProviderNoHelpText() + with pytest.raises(NotImplementedError): + provider.generate_compliance_output([], {}, set(), MagicMock(), {}) + + def test_get_mutelist_finding_args_raises_not_implemented(self): + """Base Provider.get_mutelist_finding_args raises NotImplementedError.""" + provider = FakeProviderNoHelpText() + with pytest.raises(NotImplementedError): + provider.get_mutelist_finding_args() + + def test_display_compliance_table_raises_not_implemented(self): + """Base Provider.display_compliance_table raises NotImplementedError.""" + provider = FakeProviderNoHelpText() + with pytest.raises(NotImplementedError): + provider.display_compliance_table([], {}, "fw", "out", "/tmp", False) + + def test_is_external_tool_provider_defaults_to_false(self): + """Base Provider.is_external_tool_provider returns False.""" + provider = FakeProviderNoHelpText() + assert provider.is_external_tool_provider is False + + +# =========================================================================== +# 10. Mutelist Dispatch for External Providers +# =========================================================================== + + +class TestMutelistDispatch: + """Tests for mutelist integration with external providers.""" + + def test_get_mutelist_finding_args_returns_identity(self, fake_provider): + """External provider returns identity kwargs for mutelist.""" + args = fake_provider.get_mutelist_finding_args() + + assert args == {"host_id": "fake-host-1"} + + def test_mutelist_dispatch_calls_external_provider(self, fake_provider): + """execute() uses get_mutelist_finding_args for unknown provider types.""" + from prowler.lib.check.check import execute + + # Create a mock check that returns one finding + finding = MagicMock() + finding.status = "FAIL" + finding.muted = False + finding.check_metadata.Provider = "fakeexternal" + + check = MagicMock() + check.execute.return_value = [finding] + check.CheckID = "fake_check" + check.ServiceName = "fake_service" + check.Severity.value = "high" + + # Setup mutelist on the provider + fake_provider.mutelist = MagicMock() + fake_provider.mutelist.mutelist = {"Accounts": {}} + fake_provider.mutelist.is_finding_muted.return_value = True + + output_options = MagicMock() + output_options.status = [] + output_options.unix_timestamp = False + + execute(check, fake_provider, None, output_options) + + # is_finding_muted should have been called with host_id + finding + fake_provider.mutelist.is_finding_muted.assert_called_once_with( + host_id="fake-host-1", finding=finding + ) + + +# =========================================================================== +# 11. Compliance Table Dispatch for External Providers +# =========================================================================== + + +class TestComplianceTableDispatch: + """Tests for compliance table display with external providers.""" + + def test_display_compliance_table_delegates_to_provider(self, fake_provider): + """display_compliance_table uses provider method for unknown frameworks.""" + from prowler.lib.outputs.compliance.compliance import ( + display_compliance_table, + ) + + fake_provider.display_compliance_table = MagicMock(return_value=True) + + display_compliance_table( + [], {}, "custom_1.0_fakeexternal", "out", "/tmp", False + ) + + fake_provider.display_compliance_table.assert_called_once_with( + [], + {}, + "custom_1.0_fakeexternal", + "out", + "/tmp", + False, + ) + + def test_display_compliance_table_falls_back_to_generic(self, fake_provider): + """display_compliance_table falls back to generic when provider returns False.""" + from prowler.lib.outputs.compliance.compliance import ( + display_compliance_table, + ) + + fake_provider.display_compliance_table = MagicMock(return_value=False) + + with patch( + "prowler.lib.outputs.compliance.compliance.get_generic_compliance_table" + ) as mock_generic: + display_compliance_table( + [], {}, "custom_1.0_fakeexternal", "out", "/tmp", False + ) + + mock_generic.assert_called_once() + + def test_display_compliance_table_falls_back_on_not_implemented(self): + """display_compliance_table falls back to generic when NotImplementedError.""" + # Use a provider that doesn't implement display_compliance_table + provider = FakeProviderNoHelpText() + Provider.set_global_provider(provider) + + with patch( + "prowler.lib.outputs.compliance.compliance.get_generic_compliance_table" + ) as mock_generic: + from prowler.lib.outputs.compliance.compliance import ( + display_compliance_table, + ) + + display_compliance_table( + [], {}, "unknown_1.0_nohelptext", "out", "/tmp", False + ) + + mock_generic.assert_called_once() + Provider._global = None + + +# =========================================================================== +# 12. Provider.get_class — Public side-effect-free class resolver +# =========================================================================== + + +class TestGetClass: + """Tests for Provider.get_class(provider) — the public, side-effect-free + class resolver that unblocks the Django API and other callers that need + a provider class without triggering CLI side-effects (sys.exit, global + provider mutation).""" + + # ----------------------------------------------------------------------- + # T1: Built-in provider resolves to correct class + # ----------------------------------------------------------------------- + + def test_get_class_builtin_returns_correct_class(self): + """get_class('aws') returns AwsProvider — identity check.""" + from prowler.providers.aws.aws_provider import AwsProvider + + cls = Provider.get_class("aws") + + assert cls is AwsProvider + + # ----------------------------------------------------------------------- + # T2: External entry-point provider resolves + # ----------------------------------------------------------------------- + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + @patch("prowler.providers.common.provider.Provider.is_builtin") + def test_get_class_external_ep_returns_class(self, mock_is_builtin, mock_ep): + """get_class resolves an external entry-point provider and returns that class.""" + mock_is_builtin.return_value = False + mock_ep.return_value = [ + _make_entry_point( + "fakeexternal", "pkg:FakeExternalProvider", "prowler.providers" + ), + ] + mock_ep.return_value[0].load.return_value = FakeExternalProvider + + cls = Provider.get_class("fakeexternal") + + assert cls is FakeExternalProvider + + # ----------------------------------------------------------------------- + # T3: Unknown provider raises, does NOT call sys.exit + # ----------------------------------------------------------------------- + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + @patch("prowler.providers.common.provider.Provider.is_builtin") + def test_get_class_unknown_raises_and_does_not_sys_exit( + self, mock_is_builtin, mock_ep + ): + """get_class raises for an unknown provider and never calls sys.exit.""" + mock_is_builtin.return_value = False + mock_ep.return_value = [] + + # Assert ImportError specifically to enforce the public API contract + # (not a broad Exception). SystemExit belongs in init_global_provider's + # wrapper, not in the pure resolver. + with pytest.raises(ImportError): + Provider.get_class("totally_unknown_xyz_provider") + + # ----------------------------------------------------------------------- + # T4: get_class is PURE for built-ins — no collision warning, no EP call + # ----------------------------------------------------------------------- + + @patch("prowler.providers.common.provider.logger") + @patch("prowler.providers.common.provider.Provider._load_ep_provider") + @patch("prowler.providers.common.provider.import_module") + @patch("prowler.providers.common.provider.Provider.is_builtin") + def test_get_class_builtin_with_ep_shadow_is_pure( + self, mock_is_builtin, mock_import, mock_load_ep, mock_logger + ): + """get_class for a built-in with a same-named EP is PURE: + - returns the built-in class + - does NOT emit a collision warning + - does NOT call _load_ep_provider (so _ep_providers cache stays empty for + this key, proving no side-effect) + """ + import types + + mock_is_builtin.return_value = True + mock_load_ep.return_value = FakeExternalProvider # plug-in shadow present + + fake_module = types.ModuleType("fake_builtin_module") + fake_builtin_cls = type("AwsProvider", (Provider,), {"_type": "aws"}) + fake_module.AwsProvider = fake_builtin_cls + mock_import.return_value = fake_module + + cls = Provider.get_class("aws") + + # Built-in class returned + assert cls is fake_builtin_cls + # No collision warning emitted — that is now init_global_provider's job + warning_msgs = [ + call.args[0] + for call in mock_logger.warning.call_args_list + if call.args and "IGNORED" in call.args[0] + ] + assert not warning_msgs, ( + "get_class must NOT emit a collision warning; " + "init_global_provider owns that responsibility" + ) + # _load_ep_provider must NOT have been called for the built-in path + mock_load_ep.assert_not_called() + # _ep_providers cache must not contain 'aws' (no side-effect) + assert "aws" not in Provider._ep_providers + + # ----------------------------------------------------------------------- + # T4b: Built-in module missing its expected class raises ImportError + # and does NOT fall back to a same-named entry point + # ----------------------------------------------------------------------- + + @patch("prowler.providers.common.provider.Provider._load_ep_provider") + @patch("prowler.providers.common.provider.import_module") + @patch("prowler.providers.common.provider.Provider.is_builtin") + def test_get_class_builtin_missing_class_raises_importerror( + self, mock_is_builtin, mock_import, mock_load_ep + ): + """When is_builtin is True but the module does not define the expected + provider class, get_class raises ImportError and does NOT fall back to a + same-named entry point — falling back would contradict is_builtin and + silently return a foreign class.""" + import types + + mock_is_builtin.return_value = True + # Module imports fine but lacks the expected `Provider` attribute. + empty_module = types.ModuleType("empty_builtin_module") + mock_import.return_value = empty_module + + with pytest.raises(ImportError): + Provider.get_class("aws") + + # Must NOT fall back to entry points for a (broken) built-in. + mock_load_ep.assert_not_called() + + # ----------------------------------------------------------------------- + # T4c: Entry point resolving to a non-Provider class raises ImportError + # ----------------------------------------------------------------------- + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + @patch("prowler.providers.common.provider.Provider.is_builtin") + def test_get_class_external_ep_not_provider_subclass_raises_importerror( + self, mock_is_builtin, mock_ep + ): + """When an entry point resolves to an object that is not a Provider + subclass, get_class raises ImportError instead of returning it, so the + public contract (a Provider subclass) is enforced rather than trusted.""" + + class NotAProvider: + pass + + mock_is_builtin.return_value = False + mock_ep.return_value = [ + _make_entry_point("rogue", "pkg:NotAProvider", "prowler.providers"), + ] + mock_ep.return_value[0].load.return_value = NotAProvider + + with pytest.raises(ImportError): + Provider.get_class("rogue") + + # ----------------------------------------------------------------------- + # T4d: Contract — every built-in provider stays resolvable via get_class + # ----------------------------------------------------------------------- + + @pytest.mark.parametrize( + "provider", + [ + name + for name in Provider.get_available_providers() + if Provider.is_builtin(name) + ], + ) + def test_get_class_resolves_every_builtin_provider(self, provider): + """Contract test over all built-in providers: each one must remain + resolvable through get_class and return a Provider subclass whose name + follows the `{Capitalized}Provider` convention. This pins the naming + convention as the built-in resolution contract, so a future provider + that breaks it fails here instead of silently at runtime in a caller + (e.g. the API).""" + cls = Provider.get_class(provider) + + assert isinstance(cls, type) and issubclass(cls, Provider) + assert cls.__name__ == f"{provider.capitalize()}Provider" + + # ----------------------------------------------------------------------- + # T5: Regression — init_global_provider still resolves external correctly + # ----------------------------------------------------------------------- + + @patch("prowler.providers.common.provider.load_and_validate_config_file") + @patch("prowler.providers.common.provider.Provider._load_ep_provider") + def test_init_global_provider_still_resolves_external_via_get_class( + self, mock_load_ep, mock_config + ): + """Regression: init_global_provider continues to work for external providers + after the class-resolution block is delegated to get_class. + + 'fakepure' is not a built-in, so is_builtin() returns False and get_class + takes the entry-point path. This verifies the FakePureContractProvider path + (pure from_cli_args returning an instance) still works — i.e., + init_global_provider correctly wires the returned instance as global provider. + """ + mock_load_ep.return_value = FakePureContractProvider + mock_config.return_value = {} + + args = Namespace( + provider="fakepure", + fixer_config="config.yaml", + config_file="config.yaml", + ) + + Provider._global = None + Provider.init_global_provider(args) + + assert isinstance(Provider._global, FakePureContractProvider) + Provider._global = None + + # ----------------------------------------------------------------------- + # T6: Regression — get_providers_help_text returns same text after refactor + # ----------------------------------------------------------------------- + + @patch("prowler.providers.common.provider.Provider._load_ep_provider") + @patch("prowler.providers.common.provider.Provider.get_available_providers") + def test_get_providers_help_text_identical_after_refactor_external( + self, mock_providers, mock_load_ep + ): + """get_providers_help_text returns identical _cli_help_text for an external + provider both before and after the refactor to use get_class internally.""" + mock_providers.return_value = ["fakeexternal"] + mock_load_ep.return_value = FakeExternalProvider + + help_text = Provider.get_providers_help_text() + + # Must match the known _cli_help_text on FakeExternalProvider + assert help_text["fakeexternal"] == "Fake External Provider" + + @patch("prowler.providers.common.provider.import_module") + @patch("prowler.providers.common.provider.Provider.is_builtin") + @patch("prowler.providers.common.provider.Provider.get_available_providers") + def test_get_providers_help_text_identical_after_refactor_builtin( + self, mock_providers, mock_is_builtin, mock_import + ): + """get_providers_help_text returns identical _cli_help_text for a built-in + provider both before and after the refactor to use get_class internally. + is_builtin is mocked to True so get_class takes the built-in import path.""" + import types + + mock_providers.return_value = ["fakebuiltin"] + mock_is_builtin.return_value = True + mock_cls = type( + "FakebuiltinProvider", (Provider,), {"_cli_help_text": "Built-in Help"} + ) + mock_module = types.ModuleType("fake_module") + mock_module.FakebuiltinProvider = mock_cls + mock_import.return_value = mock_module + + help_text = Provider.get_providers_help_text() + + assert help_text["fakebuiltin"] == "Built-in Help" + + # ----------------------------------------------------------------------- + # T7: init_global_provider emits collision warning (not get_class) + # ----------------------------------------------------------------------- + + @patch("prowler.providers.common.provider.load_and_validate_config_file") + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + @patch("prowler.providers.common.provider.import_module") + @patch("prowler.providers.common.provider.Provider.is_builtin") + def test_init_global_provider_emits_collision_warning_for_builtin_ep_shadow( + self, mock_is_builtin, mock_import, mock_entry_points, mock_config, caplog + ): + """init_global_provider (not get_class) emits the collision warning + when a built-in provider has a same-named entry-point plug-in registered. + + This is the counterpart to test_get_class_builtin_with_ep_shadow_is_pure: + the warning responsibility moved OUT of get_class and INTO + init_global_provider, so users still see the message on CLI invocation + but prowler --help and API calls (which never hit init_global_provider) + do not spuriously emit it. The shadow is detected by entry-point name + only — the plug-in is never loaded to warn. + """ + import logging + import types + + mock_is_builtin.return_value = True + shadow_ep = MagicMock() + shadow_ep.name = "aws" # plug-in shadowing the built-in name + mock_entry_points.return_value = [shadow_ep] + + fake_module = types.ModuleType("fake_builtin_module") + fake_module.AwsProvider = MagicMock(side_effect=lambda **_kw: None) + mock_import.return_value = fake_module + mock_config.return_value = {} + + args = Namespace( + provider="aws", + fixer_config="config.yaml", + config_file="config.yaml", + aws_retries_max_attempts=3, + role=None, + session_duration=None, + external_id=None, + role_session_name=None, + mfa=None, + profile=None, + region=None, + excluded_region=None, + organizations_role=None, + scan_unused_services=False, + resource_tag=None, + resource_arn=None, + mutelist_file=None, + ) + + Provider._global = None + with caplog.at_level(logging.WARNING, logger="prowler"): + try: + Provider.init_global_provider(args) + except BaseException: + # AwsProvider mock is fake; dispatch may fail — only the + # warning emitted BEFORE dispatch matters here. + pass + Provider._global = None + + collision_warnings = [ + r.message + for r in caplog.records + if "Plug-in provider 'aws'" in r.message and "IGNORED" in r.message + ] + assert collision_warnings, ( + "init_global_provider must emit the collision warning when a " + "same-named EP plug-in exists for a built-in provider" + ) + # Shadow detected by name only — the plug-in is never loaded to warn. + shadow_ep.load.assert_not_called() diff --git a/tests/providers/gcp/gcp_fixtures.py b/tests/providers/gcp/gcp_fixtures.py index ba6480ee22..1abcaf5bf0 100644 --- a/tests/providers/gcp/gcp_fixtures.py +++ b/tests/providers/gcp/gcp_fixtures.py @@ -722,6 +722,7 @@ def mock_api_instances_calls(client: MagicMock, service: str): }, "backupConfiguration": {"enabled": True}, "databaseFlags": [], + "availabilityType": "REGIONAL", }, }, { @@ -737,6 +738,7 @@ def mock_api_instances_calls(client: MagicMock, service: str): }, "backupConfiguration": {"enabled": False}, "databaseFlags": [], + "availabilityType": "ZONAL", }, }, ] diff --git a/tests/providers/gcp/gcp_provider_test.py b/tests/providers/gcp/gcp_provider_test.py index 7d2bea9f88..7b30b828da 100644 --- a/tests/providers/gcp/gcp_provider_test.py +++ b/tests/providers/gcp/gcp_provider_test.py @@ -13,6 +13,7 @@ from prowler.config.config import ( ) from prowler.providers.common.models import Connection from prowler.providers.gcp.exceptions.exceptions import ( + GCPGetOrganizationProjectsError, GCPInvalidProviderIdError, GCPNoAccesibleProjectsError, GCPTestConnectionError, @@ -1077,3 +1078,66 @@ class TestGCPProvider: assert gcp_provider.skip_api_check is True mocked_is_api_active.assert_not_called() + + def test_get_projects_organization_id_permission_denied_raises(self): + """When --organization-id is set and the Cloud Asset API returns a 403, + get_projects must raise GCPGetOrganizationProjectsError instead of + silently falling back to the service account's home project. + + Regression test for https://github.com/prowler-cloud/prowler/issues/11250. + """ + from googleapiclient.errors import HttpError + + forbidden_response = MagicMock(status=403, reason="Forbidden") + http_error = HttpError( + resp=forbidden_response, + content=b'{"error": {"code": 403, "message": "Permission denied on resource organization"}}', + uri="https://cloudasset.googleapis.com/v1/organizations/123:listAssets", + ) + + asset_service = MagicMock() + asset_service.assets.return_value.list.return_value.execute.side_effect = ( + http_error + ) + + with patch( + "prowler.providers.gcp.gcp_provider.discovery.build", + return_value=asset_service, + ): + with pytest.raises(GCPGetOrganizationProjectsError): + GcpProvider.get_projects( + credentials=MagicMock(), + organization_id="test-organization-id", + credentials_file="test_credentials_file", + ) + + def test_get_projects_organization_id_cloud_asset_api_disabled_raises(self): + """When --organization-id is set and the Cloud Asset API is disabled, + get_projects must raise GCPGetOrganizationProjectsError with the + enable-API remediation rather than swallowing the error.""" + from googleapiclient.errors import HttpError + + disabled_response = MagicMock(status=403, reason="Forbidden") + http_error = HttpError( + resp=disabled_response, + content=b'{"error": {"message": "Cloud Asset API has not been used in project 123 before or it is disabled."}}', + uri="https://cloudasset.googleapis.com/v1/organizations/123:listAssets", + ) + + asset_service = MagicMock() + asset_service.assets.return_value.list.return_value.execute.side_effect = ( + http_error + ) + + with patch( + "prowler.providers.gcp.gcp_provider.discovery.build", + return_value=asset_service, + ): + with pytest.raises(GCPGetOrganizationProjectsError) as exc_info: + GcpProvider.get_projects( + credentials=MagicMock(), + organization_id="test-organization-id", + credentials_file="test_credentials_file", + ) + + assert "Cloud Asset API" in str(exc_info.value) diff --git a/tests/providers/gcp/services/cloudfunction/__init__.py b/tests/providers/gcp/services/cloudfunction/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/__init__.py b/tests/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc_test.py b/tests/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc_test.py new file mode 100644 index 0000000000..b764760538 --- /dev/null +++ b/tests/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc_test.py @@ -0,0 +1,197 @@ +from unittest import mock + +from tests.providers.gcp.gcp_fixtures import ( + GCP_PROJECT_ID, + GCP_US_CENTER1_LOCATION, + set_mocked_gcp_provider, +) + +_CHECK_PATH = ( + "prowler.providers.gcp.services.cloudfunction." + "cloudfunction_function_inside_vpc.cloudfunction_function_inside_vpc" +) +_CLIENT_PATH = f"{_CHECK_PATH}.cloudfunction_client" + + +class Test_cloudfunction_function_inside_vpc: + def test_no_functions(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_inside_vpc.cloudfunction_function_inside_vpc import ( + cloudfunction_function_inside_vpc, + ) + + cloudfunction_client.functions = [] + + check = cloudfunction_function_inside_vpc() + result = check.execute() + assert len(result) == 0 + + def test_function_with_vpc_connector(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_inside_vpc.cloudfunction_function_inside_vpc import ( + cloudfunction_function_inside_vpc, + ) + from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + Function, + ) + + connector = ( + f"projects/{GCP_PROJECT_ID}/locations/{GCP_US_CENTER1_LOCATION}" + f"/connectors/my-connector" + ) + cloudfunction_client.functions = [ + Function( + name="fn-vpc", + project_id=GCP_PROJECT_ID, + location=GCP_US_CENTER1_LOCATION, + state="ACTIVE", + vpc_connector=connector, + ) + ] + + check = cloudfunction_function_inside_vpc() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Cloud Function fn-vpc is connected to a VPC via connector: {connector}." + ) + assert result[0].resource_id == "fn-vpc" + assert result[0].resource_name == "fn-vpc" + assert result[0].location == GCP_US_CENTER1_LOCATION + assert result[0].project_id == GCP_PROJECT_ID + + def test_function_without_vpc_connector(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_inside_vpc.cloudfunction_function_inside_vpc import ( + cloudfunction_function_inside_vpc, + ) + from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + Function, + ) + + cloudfunction_client.functions = [ + Function( + name="fn-public", + project_id=GCP_PROJECT_ID, + location=GCP_US_CENTER1_LOCATION, + state="ACTIVE", + vpc_connector=None, + ) + ] + + check = cloudfunction_function_inside_vpc() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Cloud Function fn-public is not connected to any VPC network." + ) + assert result[0].resource_id == "fn-public" + assert result[0].resource_name == "fn-public" + assert result[0].location == GCP_US_CENTER1_LOCATION + assert result[0].project_id == GCP_PROJECT_ID + + def test_function_with_empty_vpc_connector(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_inside_vpc.cloudfunction_function_inside_vpc import ( + cloudfunction_function_inside_vpc, + ) + from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + Function, + ) + + cloudfunction_client.functions = [ + Function( + name="fn-empty", + project_id=GCP_PROJECT_ID, + location=GCP_US_CENTER1_LOCATION, + state="ACTIVE", + vpc_connector="", + ) + ] + + check = cloudfunction_function_inside_vpc() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_inactive_function_skipped(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_inside_vpc.cloudfunction_function_inside_vpc import ( + cloudfunction_function_inside_vpc, + ) + from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + Function, + ) + + cloudfunction_client.functions = [ + Function( + name="fn-deploy", + project_id=GCP_PROJECT_ID, + location=GCP_US_CENTER1_LOCATION, + state="DEPLOYING", + vpc_connector=None, + ) + ] + + check = cloudfunction_function_inside_vpc() + result = check.execute() + assert len(result) == 0 diff --git a/tests/providers/gcp/services/cloudfunction/cloudfunction_service_test.py b/tests/providers/gcp/services/cloudfunction/cloudfunction_service_test.py new file mode 100644 index 0000000000..82f77a939f --- /dev/null +++ b/tests/providers/gcp/services/cloudfunction/cloudfunction_service_test.py @@ -0,0 +1,102 @@ +from unittest.mock import MagicMock, patch + +from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + CloudFunction, +) +from tests.providers.gcp.gcp_fixtures import ( + GCP_PROJECT_ID, + mock_is_api_active, + set_mocked_gcp_provider, +) + +_LOCATION_ID = "us-central1" +_FUNCTION_NAME = "my-function" +_CONNECTOR = ( + f"projects/{GCP_PROJECT_ID}/locations/{_LOCATION_ID}/connectors/my-connector" +) + + +def _make_cloudfunction_client(functions_list): + """Return a mock GCP API client for the Cloud Functions v2 service.""" + client = MagicMock() + + client.projects().locations().list().execute.return_value = { + "locations": [{"locationId": _LOCATION_ID}] + } + client.projects().locations().list_next.return_value = None + + client.projects().locations().functions().list().execute.return_value = { + "functions": functions_list + } + client.projects().locations().functions().list_next.return_value = None + + return client + + +class TestCloudFunctionService: + def test_get_functions_with_vpc_connector(self): + def mock_api_client(*args, **kwargs): + return _make_cloudfunction_client( + functions_list=[ + { + "name": f"projects/{GCP_PROJECT_ID}/locations/{_LOCATION_ID}/functions/{_FUNCTION_NAME}", + "state": "ACTIVE", + "serviceConfig": { + "vpcConnector": _CONNECTOR, + }, + } + ] + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + ): + cf_client = CloudFunction( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(cf_client.functions) == 1 + fn = cf_client.functions[0] + assert fn.name == _FUNCTION_NAME + assert fn.project_id == GCP_PROJECT_ID + assert fn.location == _LOCATION_ID + assert fn.state == "ACTIVE" + assert fn.vpc_connector == _CONNECTOR + + def test_get_functions_without_vpc_connector(self): + def mock_api_client(*args, **kwargs): + return _make_cloudfunction_client( + functions_list=[ + { + "name": f"projects/{GCP_PROJECT_ID}/locations/{_LOCATION_ID}/functions/no-vpc-func", + "state": "ACTIVE", + "serviceConfig": {}, + } + ] + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + ): + cf_client = CloudFunction( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(cf_client.functions) == 1 + fn = cf_client.functions[0] + assert fn.name == "no-vpc-func" + assert fn.vpc_connector is None diff --git a/tests/providers/gcp/services/cloudsql/cloudsql_instance_cmek_encryption_enabled/__init__.py b/tests/providers/gcp/services/cloudsql/cloudsql_instance_cmek_encryption_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/__init__.py b/tests/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled_test.py b/tests/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled_test.py new file mode 100644 index 0000000000..82c49fb89e --- /dev/null +++ b/tests/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled_test.py @@ -0,0 +1,205 @@ +from unittest import mock + +from tests.providers.gcp.gcp_fixtures import ( + GCP_EU1_LOCATION, + GCP_PROJECT_ID, + set_mocked_gcp_provider, +) + + +class Test_cloudsql_instance_high_availability_enabled: + """Tests for the cloudsql_instance_high_availability_enabled check.""" + + def test_no_instances(self): + """No Cloud SQL instances → no findings.""" + cloudsql_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled.cloudsql_client", + new=cloudsql_client, + ), + ): + from prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled import ( + cloudsql_instance_high_availability_enabled, + ) + + cloudsql_client.instances = [] + check = cloudsql_instance_high_availability_enabled() + result = check.execute() + assert len(result) == 0 + + def test_instance_ha_enabled(self): + """A REGIONAL primary instance → PASS.""" + cloudsql_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled.cloudsql_client", + new=cloudsql_client, + ), + ): + from prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled import ( + cloudsql_instance_high_availability_enabled, + ) + from prowler.providers.gcp.services.cloudsql.cloudsql_service import ( + Instance, + ) + + cloudsql_client.instances = [ + Instance( + name="db-ha", + version="POSTGRES_15", + ip_addresses=[], + region=GCP_EU1_LOCATION, + public_ip=False, + require_ssl=False, + ssl_mode="ENCRYPTED_ONLY", + automated_backups=True, + authorized_networks=[], + flags=[], + project_id=GCP_PROJECT_ID, + availability_type="REGIONAL", + ) + ] + check = cloudsql_instance_high_availability_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "db-ha" + assert result[0].location == GCP_EU1_LOCATION + assert result[0].project_id == GCP_PROJECT_ID + + def test_instance_ha_disabled(self): + """A ZONAL primary instance → FAIL with current availability in status_extended.""" + cloudsql_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled.cloudsql_client", + new=cloudsql_client, + ), + ): + from prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled import ( + cloudsql_instance_high_availability_enabled, + ) + from prowler.providers.gcp.services.cloudsql.cloudsql_service import ( + Instance, + ) + + cloudsql_client.instances = [ + Instance( + name="db-zonal", + version="POSTGRES_15", + ip_addresses=[], + region=GCP_EU1_LOCATION, + public_ip=False, + require_ssl=False, + ssl_mode="ENCRYPTED_ONLY", + automated_backups=True, + authorized_networks=[], + flags=[], + project_id=GCP_PROJECT_ID, + availability_type="ZONAL", + ) + ] + check = cloudsql_instance_high_availability_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "ZONAL" in result[0].status_extended + assert result[0].resource_id == "db-zonal" + assert result[0].location == GCP_EU1_LOCATION + assert result[0].project_id == GCP_PROJECT_ID + + def test_read_replica_skipped(self): + """Read replicas (instance_type != CLOUD_SQL_INSTANCE) are skipped.""" + cloudsql_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled.cloudsql_client", + new=cloudsql_client, + ), + ): + from prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled import ( + cloudsql_instance_high_availability_enabled, + ) + from prowler.providers.gcp.services.cloudsql.cloudsql_service import ( + Instance, + ) + + cloudsql_client.instances = [ + Instance( + name="db-replica", + version="POSTGRES_15", + ip_addresses=[], + region=GCP_EU1_LOCATION, + public_ip=False, + require_ssl=False, + ssl_mode="ENCRYPTED_ONLY", + automated_backups=True, + authorized_networks=[], + flags=[], + project_id=GCP_PROJECT_ID, + availability_type="ZONAL", + instance_type="READ_REPLICA_INSTANCE", + ) + ] + check = cloudsql_instance_high_availability_enabled() + result = check.execute() + assert len(result) == 0 + + def test_instance_default_availability_type_fails(self): + """An instance missing availabilityType defaults to ZONAL (service layer) and must FAIL.""" + cloudsql_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled.cloudsql_client", + new=cloudsql_client, + ), + ): + from prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled import ( + cloudsql_instance_high_availability_enabled, + ) + from prowler.providers.gcp.services.cloudsql.cloudsql_service import ( + Instance, + ) + + cloudsql_client.instances = [ + Instance( + name="db-default", + version="POSTGRES_15", + ip_addresses=[], + region=GCP_EU1_LOCATION, + public_ip=False, + require_ssl=False, + ssl_mode="ENCRYPTED_ONLY", + automated_backups=True, + authorized_networks=[], + flags=[], + project_id=GCP_PROJECT_ID, + # availability_type omitted → model default "ZONAL" + ) + ] + check = cloudsql_instance_high_availability_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "ZONAL" in result[0].status_extended diff --git a/tests/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused_test.py b/tests/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused_test.py index d76200734d..79c0eb23c3 100644 --- a/tests/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused_test.py +++ b/tests/providers/gcp/services/iam/iam_service_account_unused/iam_service_account_unused_test.py @@ -179,3 +179,60 @@ class Test_iam_service_account_unused: assert result[1].project_id == GCP_PROJECT_ID assert result[1].location == GCP_US_CENTER1_LOCATION assert result[1].resource == iam_client.service_accounts[1] + + def test_iam_service_account_disabled(self): + iam_client = mock.MagicMock() + monitoring_client = mock.MagicMock() + + 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_service_account_unused.iam_service_account_unused.iam_client", + new=iam_client, + ), + mock.patch( + "prowler.providers.gcp.services.iam.iam_service_account_unused.iam_service_account_unused.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.services.iam.iam_service import ServiceAccount + from prowler.providers.gcp.services.iam.iam_service_account_unused.iam_service_account_unused import ( + iam_service_account_unused, + ) + + iam_client.project_ids = [GCP_PROJECT_ID] + iam_client.region = GCP_US_CENTER1_LOCATION + + iam_client.service_accounts = [ + ServiceAccount( + name="projects/my-project/serviceAccounts/disabled-sa@my-project.iam.gserviceaccount.com", + email="disabled-sa@my-project.iam.gserviceaccount.com", + display_name="Disabled service account", + keys=[], + project_id=GCP_PROJECT_ID, + uniqueId="999888877776666", + disabled=True, + ) + ] + + # The account is absent from the usage metrics, so a non-disabled + # account here would FAIL. Being disabled must take precedence and + # PASS, since a disabled account cannot authenticate or be used. + monitoring_client.sa_api_metrics = set() + monitoring_client.audit_config = {"max_unused_account_days": 30} + + check = iam_service_account_unused() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Service Account {iam_client.service_accounts[0].email} is disabled and cannot be used." + ) + assert result[0].resource_id == iam_client.service_accounts[0].email + assert result[0].project_id == GCP_PROJECT_ID + assert result[0].location == GCP_US_CENTER1_LOCATION + assert result[0].resource == iam_client.service_accounts[0] diff --git a/tests/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled_test.py b/tests/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled_test.py index 5776c858f5..6600921c9f 100644 --- a/tests/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled_test.py +++ b/tests/providers/gcp/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled_test.py @@ -1,4 +1,3 @@ -import datetime from unittest import mock from tests.providers.gcp.gcp_fixtures import ( @@ -34,7 +33,7 @@ class Test_kms_key_rotation_enabled: result = check.execute() assert len(result) == 0 - def test_kms_key_no_next_rotation_time_and_no_rotation_period(self): + def test_kms_key_without_rotation_period(self): kms_client = mock.MagicMock() with ( @@ -86,14 +85,14 @@ class Test_kms_key_rotation_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Key {kms_client.crypto_keys[0].name} is not rotated every 90 days or less and the next rotation time is in more than 90 days." + == f"Key {kms_client.crypto_keys[0].name} does not have automatic rotation enabled." ) assert result[0].resource_id == kms_client.crypto_keys[0].id assert result[0].resource_name == kms_client.crypto_keys[0].name assert result[0].location == kms_client.crypto_keys[0].location assert result[0].project_id == kms_client.crypto_keys[0].project_id - def test_kms_key_no_next_rotation_time_and_big_rotation_period(self): + def test_kms_key_with_long_rotation_period(self): kms_client = mock.MagicMock() with ( @@ -135,471 +134,26 @@ class Test_kms_key_rotation_enabled: project_id=GCP_PROJECT_ID, key_ring=keyring.name, location=keylocation.name, + # Rotation period greater than 90 days still counts as enabled rotation_period="8776000s", members=["user:jane@example.com"], ) ] - check = kms_key_rotation_enabled() - result = check.execute() - assert len(result) == 1 - assert result[0].status == "FAIL" - assert ( - result[0].status_extended - == f"Key {kms_client.crypto_keys[0].name} is not rotated every 90 days or less and the next rotation time is in more than 90 days." - ) - assert result[0].resource_id == kms_client.crypto_keys[0].id - assert result[0].resource_name == kms_client.crypto_keys[0].name - assert result[0].location == kms_client.crypto_keys[0].location - assert result[0].project_id == kms_client.crypto_keys[0].project_id - - def test_kms_key_no_next_rotation_time_and_appropriate_rotation_period(self): - kms_client = mock.MagicMock() - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=set_mocked_gcp_provider(), - ), - mock.patch( - "prowler.providers.gcp.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled.kms_client", - new=kms_client, - ), - ): - from prowler.providers.gcp.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled import ( - kms_key_rotation_enabled, - ) - from prowler.providers.gcp.services.kms.kms_service import ( - CriptoKey, - KeyLocation, - KeyRing, - ) - - kms_client.project_ids = [GCP_PROJECT_ID] - kms_client.region = GCP_US_CENTER1_LOCATION - - keyring = KeyRing( - name="projects/123/locations/us-central1/keyRings/keyring1", - project_id=GCP_PROJECT_ID, - ) - - keylocation = KeyLocation( - name=GCP_US_CENTER1_LOCATION, - project_id=GCP_PROJECT_ID, - ) - - kms_client.crypto_keys = [ - CriptoKey( - name="key1", - id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1", - project_id=GCP_PROJECT_ID, - key_ring=keyring.name, - location=keylocation.name, - rotation_period="7776000s", - members=["user:jane@example.com"], - ) - ] - - check = kms_key_rotation_enabled() - result = check.execute() - assert len(result) == 1 - assert result[0].status == "FAIL" - assert ( - result[0].status_extended - == f"Key {kms_client.crypto_keys[0].name} is rotated every 90 days or less but the next rotation time is in more than 90 days." - ) - assert result[0].resource_id == kms_client.crypto_keys[0].id - assert result[0].resource_name == kms_client.crypto_keys[0].name - assert result[0].location == kms_client.crypto_keys[0].location - assert result[0].project_id == kms_client.crypto_keys[0].project_id - - def test_kms_key_no_rotation_period_and_big_next_rotation_time(self): - kms_client = mock.MagicMock() - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=set_mocked_gcp_provider(), - ), - mock.patch( - "prowler.providers.gcp.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled.kms_client", - new=kms_client, - ), - ): - from prowler.providers.gcp.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled import ( - kms_key_rotation_enabled, - ) - from prowler.providers.gcp.services.kms.kms_service import ( - CriptoKey, - KeyLocation, - KeyRing, - ) - - kms_client.project_ids = [GCP_PROJECT_ID] - kms_client.region = GCP_US_CENTER1_LOCATION - - keyring = KeyRing( - name="projects/123/locations/us-central1/keyRings/keyring1", - project_id=GCP_PROJECT_ID, - ) - - keylocation = KeyLocation( - name=GCP_US_CENTER1_LOCATION, - project_id=GCP_PROJECT_ID, - ) - - kms_client.crypto_keys = [ - CriptoKey( - name="key1", - id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1", - project_id=GCP_PROJECT_ID, - key_ring=keyring.name, - location=keylocation.name, - # Next rotation time of now + 100 days - next_rotation_time=( - datetime.datetime.now() - datetime.timedelta(days=+100) - ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), - members=["user:jane@example.com"], - ) - ] - - check = kms_key_rotation_enabled() - result = check.execute() - assert len(result) == 1 - assert result[0].status == "FAIL" - assert ( - result[0].status_extended - == f"Key {kms_client.crypto_keys[0].name} is not rotated every 90 days or less and the next rotation time is in more than 90 days." - ) - assert result[0].resource_id == kms_client.crypto_keys[0].id - assert result[0].resource_name == kms_client.crypto_keys[0].name - assert result[0].location == kms_client.crypto_keys[0].location - assert result[0].project_id == kms_client.crypto_keys[0].project_id - - def test_kms_key_no_rotation_period_and_appropriate_next_rotation_time(self): - kms_client = mock.MagicMock() - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=set_mocked_gcp_provider(), - ), - mock.patch( - "prowler.providers.gcp.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled.kms_client", - new=kms_client, - ), - ): - from prowler.providers.gcp.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled import ( - kms_key_rotation_enabled, - ) - from prowler.providers.gcp.services.kms.kms_service import ( - CriptoKey, - KeyLocation, - KeyRing, - ) - - kms_client.project_ids = [GCP_PROJECT_ID] - kms_client.region = GCP_US_CENTER1_LOCATION - - keyring = KeyRing( - name="projects/123/locations/us-central1/keyRings/keyring1", - project_id=GCP_PROJECT_ID, - ) - - keylocation = KeyLocation( - name=GCP_US_CENTER1_LOCATION, - project_id=GCP_PROJECT_ID, - ) - - kms_client.crypto_keys = [ - CriptoKey( - name="key1", - id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1", - project_id=GCP_PROJECT_ID, - key_ring=keyring.name, - location=keylocation.name, - # Next rotation time of now + 30 days - next_rotation_time=( - datetime.datetime.now() - datetime.timedelta(days=+30) - ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), - members=["user:jane@example.com"], - ) - ] - - check = kms_key_rotation_enabled() - result = check.execute() - assert len(result) == 1 - assert result[0].status == "FAIL" - assert ( - result[0].status_extended - == f"Key {kms_client.crypto_keys[0].name} is not rotated every 90 days or less but the next rotation time is in less than 90 days." - ) - assert result[0].resource_id == kms_client.crypto_keys[0].id - assert result[0].resource_name == kms_client.crypto_keys[0].name - assert result[0].location == kms_client.crypto_keys[0].location - assert result[0].project_id == kms_client.crypto_keys[0].project_id - - def test_kms_key_rotation_period_greater_90_days_and_big_next_rotation_time(self): - kms_client = mock.MagicMock() - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=set_mocked_gcp_provider(), - ), - mock.patch( - "prowler.providers.gcp.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled.kms_client", - new=kms_client, - ), - ): - from prowler.providers.gcp.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled import ( - kms_key_rotation_enabled, - ) - from prowler.providers.gcp.services.kms.kms_service import ( - CriptoKey, - KeyLocation, - KeyRing, - ) - - kms_client.project_ids = [GCP_PROJECT_ID] - kms_client.region = GCP_US_CENTER1_LOCATION - - keyring = KeyRing( - name="projects/123/locations/us-central1/keyRings/keyring1", - project_id=GCP_PROJECT_ID, - ) - - keylocation = KeyLocation( - name=GCP_US_CENTER1_LOCATION, - project_id=GCP_PROJECT_ID, - ) - - kms_client.crypto_keys = [ - CriptoKey( - name="key1", - id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1", - project_id=GCP_PROJECT_ID, - rotation_period="8776000s", - # Next rotation time of now + 100 days - next_rotation_time=( - datetime.datetime.now() - datetime.timedelta(days=+100) - ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), - key_ring=keyring.name, - location=keylocation.name, - members=["user:jane@example.com"], - ) - ] - - check = kms_key_rotation_enabled() - result = check.execute() - assert len(result) == 1 - assert result[0].status == "FAIL" - assert ( - result[0].status_extended - == f"Key {kms_client.crypto_keys[0].name} is not rotated every 90 days or less and the next rotation time is in more than 90 days." - ) - assert result[0].resource_id == kms_client.crypto_keys[0].id - assert result[0].resource_name == kms_client.crypto_keys[0].name - assert result[0].location == kms_client.crypto_keys[0].location - assert result[0].project_id == kms_client.crypto_keys[0].project_id - - def test_kms_key_rotation_period_greater_90_days_and_appropriate_next_rotation_time( - self, - ): - kms_client = mock.MagicMock() - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=set_mocked_gcp_provider(), - ), - mock.patch( - "prowler.providers.gcp.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled.kms_client", - new=kms_client, - ), - ): - from prowler.providers.gcp.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled import ( - kms_key_rotation_enabled, - ) - from prowler.providers.gcp.services.kms.kms_service import ( - CriptoKey, - KeyLocation, - KeyRing, - ) - - kms_client.project_ids = [GCP_PROJECT_ID] - kms_client.region = GCP_US_CENTER1_LOCATION - - keyring = KeyRing( - name="projects/123/locations/us-central1/keyRings/keyring1", - project_id=GCP_PROJECT_ID, - ) - - keylocation = KeyLocation( - name=GCP_US_CENTER1_LOCATION, - project_id=GCP_PROJECT_ID, - ) - - kms_client.crypto_keys = [ - CriptoKey( - name="key1", - id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1", - project_id=GCP_PROJECT_ID, - rotation_period="8776000s", - # Next rotation time of now + 30 days - next_rotation_time=( - datetime.datetime.now() - datetime.timedelta(days=+30) - ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), - key_ring=keyring.name, - location=keylocation.name, - members=["user:jane@example.com"], - ) - ] - - check = kms_key_rotation_enabled() - result = check.execute() - assert len(result) == 1 - assert result[0].status == "FAIL" - assert ( - result[0].status_extended - == f"Key {kms_client.crypto_keys[0].name} is not rotated every 90 days or less but the next rotation time is in less than 90 days." - ) - assert result[0].resource_id == kms_client.crypto_keys[0].id - assert result[0].resource_name == kms_client.crypto_keys[0].name - assert result[0].location == kms_client.crypto_keys[0].location - assert result[0].project_id == kms_client.crypto_keys[0].project_id - - def test_kms_key_rotation_period_less_90_days_and_big_next_rotation_time(self): - kms_client = mock.MagicMock() - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=set_mocked_gcp_provider(), - ), - mock.patch( - "prowler.providers.gcp.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled.kms_client", - new=kms_client, - ), - ): - from prowler.providers.gcp.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled import ( - kms_key_rotation_enabled, - ) - from prowler.providers.gcp.services.kms.kms_service import ( - CriptoKey, - KeyLocation, - KeyRing, - ) - - kms_client.project_ids = [GCP_PROJECT_ID] - kms_client.region = GCP_US_CENTER1_LOCATION - - keyring = KeyRing( - name="projects/123/locations/us-central1/keyRings/keyring1", - project_id=GCP_PROJECT_ID, - ) - - keylocation = KeyLocation( - name=GCP_US_CENTER1_LOCATION, - project_id=GCP_PROJECT_ID, - ) - - kms_client.crypto_keys = [ - CriptoKey( - name="key1", - id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1", - project_id=GCP_PROJECT_ID, - rotation_period="7776000s", - # Next rotation time of now + 100 days - next_rotation_time=( - datetime.datetime.now() - datetime.timedelta(days=+100) - ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), - key_ring=keyring.name, - location=keylocation.name, - members=["user:jane@example.com"], - ) - ] - - check = kms_key_rotation_enabled() - result = check.execute() - assert len(result) == 1 - assert result[0].status == "FAIL" - assert ( - result[0].status_extended - == f"Key {kms_client.crypto_keys[0].name} is rotated every 90 days or less but the next rotation time is in more than 90 days." - ) - assert result[0].resource_id == kms_client.crypto_keys[0].id - assert result[0].resource_name == kms_client.crypto_keys[0].name - assert result[0].location == kms_client.crypto_keys[0].location - assert result[0].project_id == kms_client.crypto_keys[0].project_id - - def test_kms_key_rotation_period_less_90_days_and_appropriate_next_rotation_time( - self, - ): - kms_client = mock.MagicMock() - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=set_mocked_gcp_provider(), - ), - mock.patch( - "prowler.providers.gcp.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled.kms_client", - new=kms_client, - ), - ): - from prowler.providers.gcp.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled import ( - kms_key_rotation_enabled, - ) - from prowler.providers.gcp.services.kms.kms_service import ( - CriptoKey, - KeyLocation, - KeyRing, - ) - - kms_client.project_ids = [GCP_PROJECT_ID] - kms_client.region = GCP_US_CENTER1_LOCATION - - keyring = KeyRing( - name="projects/123/locations/us-central1/keyRings/keyring1", - project_id=GCP_PROJECT_ID, - ) - - keylocation = KeyLocation( - name=GCP_US_CENTER1_LOCATION, - project_id=GCP_PROJECT_ID, - ) - - kms_client.crypto_keys = [ - CriptoKey( - name="key1", - id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1", - project_id=GCP_PROJECT_ID, - rotation_period="7776000s", - # Next rotation time of now + 30 days - next_rotation_time=( - datetime.datetime.now() - datetime.timedelta(days=+30) - ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), - key_ring=keyring.name, - location=keylocation.name, - members=["user:jane@example.com"], - ) - ] - check = kms_key_rotation_enabled() result = check.execute() assert len(result) == 1 assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Key {kms_client.crypto_keys[0].name} is rotated every 90 days or less and the next rotation time is in less than 90 days." + == f"Key {kms_client.crypto_keys[0].name} has automatic rotation enabled." ) assert result[0].resource_id == kms_client.crypto_keys[0].id assert result[0].resource_name == kms_client.crypto_keys[0].name assert result[0].location == kms_client.crypto_keys[0].location assert result[0].project_id == kms_client.crypto_keys[0].project_id - def test_kms_key_rotation_with_fractional_seconds(self): + def test_kms_key_with_short_rotation_period(self): kms_client = mock.MagicMock() with ( @@ -639,13 +193,9 @@ class Test_kms_key_rotation_enabled: name="key1", id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1", project_id=GCP_PROJECT_ID, - rotation_period="7776000s", - # Next rotation time of now + 100 days - next_rotation_time=( - datetime.datetime.now() - datetime.timedelta(days=+100) - ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), key_ring=keyring.name, location=keylocation.name, + rotation_period="7776000s", members=["user:jane@example.com"], ) ] @@ -653,10 +203,10 @@ class Test_kms_key_rotation_enabled: check = kms_key_rotation_enabled() result = check.execute() assert len(result) == 1 - assert result[0].status == "FAIL" + assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Key {kms_client.crypto_keys[0].name} is rotated every 90 days or less but the next rotation time is in more than 90 days." + == f"Key {kms_client.crypto_keys[0].name} has automatic rotation enabled." ) assert result[0].resource_id == kms_client.crypto_keys[0].id assert result[0].resource_name == kms_client.crypto_keys[0].name diff --git a/tests/providers/gcp/services/kms/kms_key_rotation_max_90_days/kms_key_rotation_max_90_days_test.py b/tests/providers/gcp/services/kms/kms_key_rotation_max_90_days/kms_key_rotation_max_90_days_test.py new file mode 100644 index 0000000000..e200b9674d --- /dev/null +++ b/tests/providers/gcp/services/kms/kms_key_rotation_max_90_days/kms_key_rotation_max_90_days_test.py @@ -0,0 +1,728 @@ +import datetime +from unittest import mock + +from tests.providers.gcp.gcp_fixtures import ( + GCP_PROJECT_ID, + GCP_US_CENTER1_LOCATION, + set_mocked_gcp_provider, +) + + +class Test_kms_key_rotation_max_90_days: + def test_kms_no_key(self): + kms_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days.kms_client", + new=kms_client, + ), + ): + from prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days import ( + kms_key_rotation_max_90_days, + ) + + kms_client.project_ids = [GCP_PROJECT_ID] + kms_client.region = GCP_US_CENTER1_LOCATION + kms_client.crypto_keys = [] + + check = kms_key_rotation_max_90_days() + result = check.execute() + assert len(result) == 0 + + def test_kms_key_no_next_rotation_time_and_no_rotation_period(self): + kms_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days.kms_client", + new=kms_client, + ), + ): + from prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days import ( + kms_key_rotation_max_90_days, + ) + from prowler.providers.gcp.services.kms.kms_service import ( + CriptoKey, + KeyLocation, + KeyRing, + ) + + kms_client.project_ids = [GCP_PROJECT_ID] + kms_client.region = GCP_US_CENTER1_LOCATION + + keyring = KeyRing( + name="projects/123/locations/us-central1/keyRings/keyring1", + project_id=GCP_PROJECT_ID, + ) + + keylocation = KeyLocation( + name=GCP_US_CENTER1_LOCATION, + project_id=GCP_PROJECT_ID, + ) + + kms_client.crypto_keys = [ + CriptoKey( + name="key1", + id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1", + project_id=GCP_PROJECT_ID, + key_ring=keyring.name, + location=keylocation.name, + members=["user:jane@example.com"], + ) + ] + + check = kms_key_rotation_max_90_days() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Key {kms_client.crypto_keys[0].name} is not rotated every 90 days or less and the next rotation time is in more than 90 days." + ) + assert result[0].resource_id == kms_client.crypto_keys[0].id + assert result[0].resource_name == kms_client.crypto_keys[0].name + assert result[0].location == kms_client.crypto_keys[0].location + assert result[0].project_id == kms_client.crypto_keys[0].project_id + + def test_kms_key_no_next_rotation_time_and_big_rotation_period(self): + kms_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days.kms_client", + new=kms_client, + ), + ): + from prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days import ( + kms_key_rotation_max_90_days, + ) + from prowler.providers.gcp.services.kms.kms_service import ( + CriptoKey, + KeyLocation, + KeyRing, + ) + + kms_client.project_ids = [GCP_PROJECT_ID] + kms_client.region = GCP_US_CENTER1_LOCATION + + keyring = KeyRing( + name="projects/123/locations/us-central1/keyRings/keyring1", + project_id=GCP_PROJECT_ID, + ) + + keylocation = KeyLocation( + name=GCP_US_CENTER1_LOCATION, + project_id=GCP_PROJECT_ID, + ) + + kms_client.crypto_keys = [ + CriptoKey( + name="key1", + id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1", + project_id=GCP_PROJECT_ID, + key_ring=keyring.name, + location=keylocation.name, + rotation_period="8776000s", + members=["user:jane@example.com"], + ) + ] + + check = kms_key_rotation_max_90_days() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Key {kms_client.crypto_keys[0].name} is not rotated every 90 days or less and the next rotation time is in more than 90 days." + ) + assert result[0].resource_id == kms_client.crypto_keys[0].id + assert result[0].resource_name == kms_client.crypto_keys[0].name + assert result[0].location == kms_client.crypto_keys[0].location + assert result[0].project_id == kms_client.crypto_keys[0].project_id + + def test_kms_key_no_next_rotation_time_and_appropriate_rotation_period(self): + kms_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days.kms_client", + new=kms_client, + ), + ): + from prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days import ( + kms_key_rotation_max_90_days, + ) + from prowler.providers.gcp.services.kms.kms_service import ( + CriptoKey, + KeyLocation, + KeyRing, + ) + + kms_client.project_ids = [GCP_PROJECT_ID] + kms_client.region = GCP_US_CENTER1_LOCATION + + keyring = KeyRing( + name="projects/123/locations/us-central1/keyRings/keyring1", + project_id=GCP_PROJECT_ID, + ) + + keylocation = KeyLocation( + name=GCP_US_CENTER1_LOCATION, + project_id=GCP_PROJECT_ID, + ) + + kms_client.crypto_keys = [ + CriptoKey( + name="key1", + id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1", + project_id=GCP_PROJECT_ID, + key_ring=keyring.name, + location=keylocation.name, + rotation_period="7776000s", + members=["user:jane@example.com"], + ) + ] + + check = kms_key_rotation_max_90_days() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Key {kms_client.crypto_keys[0].name} is rotated every 90 days or less but the next rotation time is in more than 90 days." + ) + assert result[0].resource_id == kms_client.crypto_keys[0].id + assert result[0].resource_name == kms_client.crypto_keys[0].name + assert result[0].location == kms_client.crypto_keys[0].location + assert result[0].project_id == kms_client.crypto_keys[0].project_id + + def test_kms_key_no_rotation_period_and_big_next_rotation_time(self): + kms_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days.kms_client", + new=kms_client, + ), + ): + from prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days import ( + kms_key_rotation_max_90_days, + ) + from prowler.providers.gcp.services.kms.kms_service import ( + CriptoKey, + KeyLocation, + KeyRing, + ) + + kms_client.project_ids = [GCP_PROJECT_ID] + kms_client.region = GCP_US_CENTER1_LOCATION + + keyring = KeyRing( + name="projects/123/locations/us-central1/keyRings/keyring1", + project_id=GCP_PROJECT_ID, + ) + + keylocation = KeyLocation( + name=GCP_US_CENTER1_LOCATION, + project_id=GCP_PROJECT_ID, + ) + + kms_client.crypto_keys = [ + CriptoKey( + name="key1", + id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1", + project_id=GCP_PROJECT_ID, + key_ring=keyring.name, + location=keylocation.name, + # Next rotation time of now + 100 days + next_rotation_time=( + datetime.datetime.now() - datetime.timedelta(days=+100) + ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + members=["user:jane@example.com"], + ) + ] + + check = kms_key_rotation_max_90_days() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Key {kms_client.crypto_keys[0].name} is not rotated every 90 days or less and the next rotation time is in more than 90 days." + ) + assert result[0].resource_id == kms_client.crypto_keys[0].id + assert result[0].resource_name == kms_client.crypto_keys[0].name + assert result[0].location == kms_client.crypto_keys[0].location + assert result[0].project_id == kms_client.crypto_keys[0].project_id + + def test_kms_key_no_rotation_period_and_appropriate_next_rotation_time(self): + kms_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days.kms_client", + new=kms_client, + ), + ): + from prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days import ( + kms_key_rotation_max_90_days, + ) + from prowler.providers.gcp.services.kms.kms_service import ( + CriptoKey, + KeyLocation, + KeyRing, + ) + + kms_client.project_ids = [GCP_PROJECT_ID] + kms_client.region = GCP_US_CENTER1_LOCATION + + keyring = KeyRing( + name="projects/123/locations/us-central1/keyRings/keyring1", + project_id=GCP_PROJECT_ID, + ) + + keylocation = KeyLocation( + name=GCP_US_CENTER1_LOCATION, + project_id=GCP_PROJECT_ID, + ) + + kms_client.crypto_keys = [ + CriptoKey( + name="key1", + id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1", + project_id=GCP_PROJECT_ID, + key_ring=keyring.name, + location=keylocation.name, + # Next rotation time of now + 30 days + next_rotation_time=( + datetime.datetime.now() - datetime.timedelta(days=+30) + ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + members=["user:jane@example.com"], + ) + ] + + check = kms_key_rotation_max_90_days() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Key {kms_client.crypto_keys[0].name} is not rotated every 90 days or less but the next rotation time is in less than 90 days." + ) + assert result[0].resource_id == kms_client.crypto_keys[0].id + assert result[0].resource_name == kms_client.crypto_keys[0].name + assert result[0].location == kms_client.crypto_keys[0].location + assert result[0].project_id == kms_client.crypto_keys[0].project_id + + def test_kms_key_rotation_period_greater_90_days_and_big_next_rotation_time(self): + kms_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days.kms_client", + new=kms_client, + ), + ): + from prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days import ( + kms_key_rotation_max_90_days, + ) + from prowler.providers.gcp.services.kms.kms_service import ( + CriptoKey, + KeyLocation, + KeyRing, + ) + + kms_client.project_ids = [GCP_PROJECT_ID] + kms_client.region = GCP_US_CENTER1_LOCATION + + keyring = KeyRing( + name="projects/123/locations/us-central1/keyRings/keyring1", + project_id=GCP_PROJECT_ID, + ) + + keylocation = KeyLocation( + name=GCP_US_CENTER1_LOCATION, + project_id=GCP_PROJECT_ID, + ) + + kms_client.crypto_keys = [ + CriptoKey( + name="key1", + id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1", + project_id=GCP_PROJECT_ID, + rotation_period="8776000s", + # Next rotation time of now + 100 days + next_rotation_time=( + datetime.datetime.now() - datetime.timedelta(days=+100) + ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + key_ring=keyring.name, + location=keylocation.name, + members=["user:jane@example.com"], + ) + ] + + check = kms_key_rotation_max_90_days() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Key {kms_client.crypto_keys[0].name} is not rotated every 90 days or less and the next rotation time is in more than 90 days." + ) + assert result[0].resource_id == kms_client.crypto_keys[0].id + assert result[0].resource_name == kms_client.crypto_keys[0].name + assert result[0].location == kms_client.crypto_keys[0].location + assert result[0].project_id == kms_client.crypto_keys[0].project_id + + def test_kms_key_rotation_period_greater_90_days_and_appropriate_next_rotation_time( + self, + ): + kms_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days.kms_client", + new=kms_client, + ), + ): + from prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days import ( + kms_key_rotation_max_90_days, + ) + from prowler.providers.gcp.services.kms.kms_service import ( + CriptoKey, + KeyLocation, + KeyRing, + ) + + kms_client.project_ids = [GCP_PROJECT_ID] + kms_client.region = GCP_US_CENTER1_LOCATION + + keyring = KeyRing( + name="projects/123/locations/us-central1/keyRings/keyring1", + project_id=GCP_PROJECT_ID, + ) + + keylocation = KeyLocation( + name=GCP_US_CENTER1_LOCATION, + project_id=GCP_PROJECT_ID, + ) + + kms_client.crypto_keys = [ + CriptoKey( + name="key1", + id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1", + project_id=GCP_PROJECT_ID, + rotation_period="8776000s", + # Next rotation time of now + 30 days + next_rotation_time=( + datetime.datetime.now() - datetime.timedelta(days=+30) + ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + key_ring=keyring.name, + location=keylocation.name, + members=["user:jane@example.com"], + ) + ] + + check = kms_key_rotation_max_90_days() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Key {kms_client.crypto_keys[0].name} is not rotated every 90 days or less but the next rotation time is in less than 90 days." + ) + assert result[0].resource_id == kms_client.crypto_keys[0].id + assert result[0].resource_name == kms_client.crypto_keys[0].name + assert result[0].location == kms_client.crypto_keys[0].location + assert result[0].project_id == kms_client.crypto_keys[0].project_id + + def test_kms_key_rotation_period_less_90_days_and_big_next_rotation_time(self): + kms_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days.kms_client", + new=kms_client, + ), + ): + from prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days import ( + kms_key_rotation_max_90_days, + ) + from prowler.providers.gcp.services.kms.kms_service import ( + CriptoKey, + KeyLocation, + KeyRing, + ) + + kms_client.project_ids = [GCP_PROJECT_ID] + kms_client.region = GCP_US_CENTER1_LOCATION + + keyring = KeyRing( + name="projects/123/locations/us-central1/keyRings/keyring1", + project_id=GCP_PROJECT_ID, + ) + + keylocation = KeyLocation( + name=GCP_US_CENTER1_LOCATION, + project_id=GCP_PROJECT_ID, + ) + + kms_client.crypto_keys = [ + CriptoKey( + name="key1", + id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1", + project_id=GCP_PROJECT_ID, + rotation_period="7776000s", + # Next rotation time of now + 100 days + next_rotation_time=( + datetime.datetime.now() - datetime.timedelta(days=+100) + ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + key_ring=keyring.name, + location=keylocation.name, + members=["user:jane@example.com"], + ) + ] + + check = kms_key_rotation_max_90_days() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Key {kms_client.crypto_keys[0].name} is rotated every 90 days or less but the next rotation time is in more than 90 days." + ) + assert result[0].resource_id == kms_client.crypto_keys[0].id + assert result[0].resource_name == kms_client.crypto_keys[0].name + assert result[0].location == kms_client.crypto_keys[0].location + assert result[0].project_id == kms_client.crypto_keys[0].project_id + + def test_kms_key_rotation_period_less_90_days_and_appropriate_next_rotation_time( + self, + ): + kms_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days.kms_client", + new=kms_client, + ), + ): + from prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days import ( + kms_key_rotation_max_90_days, + ) + from prowler.providers.gcp.services.kms.kms_service import ( + CriptoKey, + KeyLocation, + KeyRing, + ) + + kms_client.project_ids = [GCP_PROJECT_ID] + kms_client.region = GCP_US_CENTER1_LOCATION + + keyring = KeyRing( + name="projects/123/locations/us-central1/keyRings/keyring1", + project_id=GCP_PROJECT_ID, + ) + + keylocation = KeyLocation( + name=GCP_US_CENTER1_LOCATION, + project_id=GCP_PROJECT_ID, + ) + + kms_client.crypto_keys = [ + CriptoKey( + name="key1", + id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1", + project_id=GCP_PROJECT_ID, + rotation_period="7776000s", + # Next rotation time of now + 30 days + next_rotation_time=( + datetime.datetime.now() - datetime.timedelta(days=+30) + ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + key_ring=keyring.name, + location=keylocation.name, + members=["user:jane@example.com"], + ) + ] + + check = kms_key_rotation_max_90_days() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Key {kms_client.crypto_keys[0].name} is rotated every 90 days or less and the next rotation time is in less than 90 days." + ) + assert result[0].resource_id == kms_client.crypto_keys[0].id + assert result[0].resource_name == kms_client.crypto_keys[0].name + assert result[0].location == kms_client.crypto_keys[0].location + assert result[0].project_id == kms_client.crypto_keys[0].project_id + + def test_kms_key_rotation_with_fractional_seconds(self): + kms_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days.kms_client", + new=kms_client, + ), + ): + from prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days import ( + kms_key_rotation_max_90_days, + ) + from prowler.providers.gcp.services.kms.kms_service import ( + CriptoKey, + KeyLocation, + KeyRing, + ) + + kms_client.project_ids = [GCP_PROJECT_ID] + kms_client.region = GCP_US_CENTER1_LOCATION + + keyring = KeyRing( + name="projects/123/locations/us-central1/keyRings/keyring1", + project_id=GCP_PROJECT_ID, + ) + + keylocation = KeyLocation( + name=GCP_US_CENTER1_LOCATION, + project_id=GCP_PROJECT_ID, + ) + + kms_client.crypto_keys = [ + CriptoKey( + name="key1", + id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1", + project_id=GCP_PROJECT_ID, + rotation_period="7776000s", + # Next rotation time of now + 100 days + next_rotation_time=( + datetime.datetime.now() - datetime.timedelta(days=+100) + ).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + key_ring=keyring.name, + location=keylocation.name, + members=["user:jane@example.com"], + ) + ] + + check = kms_key_rotation_max_90_days() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Key {kms_client.crypto_keys[0].name} is rotated every 90 days or less but the next rotation time is in more than 90 days." + ) + assert result[0].resource_id == kms_client.crypto_keys[0].id + assert result[0].resource_name == kms_client.crypto_keys[0].name + assert result[0].location == kms_client.crypto_keys[0].location + assert result[0].project_id == kms_client.crypto_keys[0].project_id + + def test_kms_key_next_rotation_time_without_fractional_seconds(self): + kms_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days.kms_client", + new=kms_client, + ), + ): + from prowler.providers.gcp.services.kms.kms_key_rotation_max_90_days.kms_key_rotation_max_90_days import ( + kms_key_rotation_max_90_days, + ) + from prowler.providers.gcp.services.kms.kms_service import ( + CriptoKey, + KeyLocation, + KeyRing, + ) + + kms_client.project_ids = [GCP_PROJECT_ID] + kms_client.region = GCP_US_CENTER1_LOCATION + + keyring = KeyRing( + name="projects/123/locations/us-central1/keyRings/keyring1", + project_id=GCP_PROJECT_ID, + ) + + keylocation = KeyLocation( + name=GCP_US_CENTER1_LOCATION, + project_id=GCP_PROJECT_ID, + ) + + kms_client.crypto_keys = [ + CriptoKey( + name="key1", + id="projects/123/locations/us-central1/keyRings/keyring1/cryptoKeys/key1", + project_id=GCP_PROJECT_ID, + rotation_period="7776000s", + # Next rotation time without fractional seconds, within 90 days + next_rotation_time=( + datetime.datetime.now() - datetime.timedelta(days=30) + ).strftime("%Y-%m-%dT%H:%M:%SZ"), + key_ring=keyring.name, + location=keylocation.name, + members=["user:jane@example.com"], + ) + ] + + check = kms_key_rotation_max_90_days() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Key {kms_client.crypto_keys[0].name} is rotated every 90 days or less and the next rotation time is in less than 90 days." + ) + assert result[0].resource_id == kms_client.crypto_keys[0].id + assert result[0].resource_name == kms_client.crypto_keys[0].name + assert result[0].location == kms_client.crypto_keys[0].location + assert result[0].project_id == kms_client.crypto_keys[0].project_id diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled_test.py index a38f97d81c..c0dc6b0a06 100644 --- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled_test.py +++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled_test.py @@ -259,3 +259,176 @@ class Test_logging_log_metric_filter_and_alert_for_audit_configuration_changes_e assert result[0].resource_name == "metric_name" assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == GCP_EU1_LOCATION + + def test_project_centrally_covered_via_org_aggregated_sink(self): + """A child project with NO local metric, but whose org has an aggregated + sink (includeChildren=True) routing its logs to a central bucket that has + a bucket-scoped metric + alert, should PASS (covered centrally) instead of + being falsely failed.""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled import ( + logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + from prowler.providers.gcp.services.monitoring.monitoring_service import ( + AlertPolicy, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + # Bucket-scoped central metric, in the scanned logging project. + logging_client.metrics = [ + Metric( + name="central-audit-config-metric", + type="logging.googleapis.com/user/central-audit-config-metric", + filter='protoPayload.methodName="SetIamPolicy" AND protoPayload.serviceData.policyDelta.auditConfigDeltas:*', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + # Org-level aggregated sink routing the child's logs to that bucket. + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [ + AlertPolicy( + name="projects/central-logging-project/alertPolicies/ap", + display_name="central-alert", + enabled=True, + filters=[ + 'metric.type = "logging.googleapis.com/user/central-audit-config-metric"' + ], + project_id="central-logging-project", + ) + ] + + check = ( + logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled() + ) + result = check.execute() + + assert any( + r.project_id == GCP_PROJECT_ID + and r.status == "PASS" + and "aggregated sink" in r.status_extended + for r in result + ), [(r.project_id, r.status, r.status_extended) for r in result] + + def test_aggregated_sink_metric_without_alert_still_fails(self): + """Guard: an org aggregated sink + a bucket-scoped metric matching the filter + but with NO alert must NOT credit the child project — it should still FAIL.""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled import ( + logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='protoPayload.methodName="SetIamPolicy" AND protoPayload.serviceData.policyDelta.auditConfigDeltas:*', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [] # no alert -> must NOT credit + + check = ( + logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled() + ) + result = check.execute() + + child = [r for r in result if r.project_id == GCP_PROJECT_ID] + assert child and all(r.status == "FAIL" for r in child), [ + (r.project_id, r.status) for r in result + ] diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled_test.py index e2b7b2d068..e9eeabf430 100644 --- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled_test.py +++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled_test.py @@ -397,3 +397,173 @@ class Test_logging_log_metric_filter_and_alert_for_bucket_permission_changes_ena assert result[0].resource_name == "GCP Project" assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == GCP_EU1_LOCATION + + def test_project_centrally_covered_via_org_aggregated_sink(self): + """A child project with NO local metric, but whose org has an aggregated + sink (includeChildren=True) routing its logs to a central bucket that has + a bucket-scoped metric + alert, should PASS (covered centrally).""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled import ( + logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + from prowler.providers.gcp.services.monitoring.monitoring_service import ( + AlertPolicy, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='resource.type="gcs_bucket" AND protoPayload.methodName="storage.setIamPermissions"', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [ + AlertPolicy( + name="projects/central-logging-project/alertPolicies/ap", + display_name="central-alert", + enabled=True, + filters=[ + 'metric.type = "logging.googleapis.com/user/central-metric"' + ], + project_id="central-logging-project", + ) + ] + + check = ( + logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled() + ) + result = check.execute() + + assert any( + r.project_id == GCP_PROJECT_ID + and r.status == "PASS" + and "aggregated sink" in r.status_extended + for r in result + ), [(r.project_id, r.status, r.status_extended) for r in result] + + def test_aggregated_sink_metric_without_alert_still_fails(self): + """Guard: an org aggregated sink + a bucket-scoped metric matching the filter + but with NO alert must NOT credit the child project — it should still FAIL.""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled import ( + logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='resource.type="gcs_bucket" AND protoPayload.methodName="storage.setIamPermissions"', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [] # no alert -> must NOT credit + + check = ( + logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled() + ) + result = check.execute() + + child = [r for r in result if r.project_id == GCP_PROJECT_ID] + assert child and all(r.status == "FAIL" for r in child), [ + (r.project_id, r.status) for r in result + ] diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled_test.py index 563b4e49ac..5347e3e42e 100644 --- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled_test.py +++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled_test.py @@ -346,3 +346,173 @@ class Test_logging_log_metric_filter_and_alert_for_compute_configuration_changes fail_result = [r for r in result if r.status == "FAIL"][0] assert fail_result.project_id == project_id_2 assert "no log metric filters" in fail_result.status_extended + + def test_project_centrally_covered_via_org_aggregated_sink(self): + """A child project with NO local metric, but whose org has an aggregated + sink (includeChildren=True) routing its logs to a central bucket that has + a bucket-scoped metric + alert, should PASS (covered centrally).""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled import ( + logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + from prowler.providers.gcp.services.monitoring.monitoring_service import ( + AlertPolicy, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='protoPayload.serviceName="compute.googleapis.com"', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [ + AlertPolicy( + name="projects/central-logging-project/alertPolicies/ap", + display_name="central-alert", + enabled=True, + filters=[ + 'metric.type = "logging.googleapis.com/user/central-metric"' + ], + project_id="central-logging-project", + ) + ] + + check = ( + logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled() + ) + result = check.execute() + + assert any( + r.project_id == GCP_PROJECT_ID + and r.status == "PASS" + and "aggregated sink" in r.status_extended + for r in result + ), [(r.project_id, r.status, r.status_extended) for r in result] + + def test_aggregated_sink_metric_without_alert_still_fails(self): + """Guard: an org aggregated sink + a bucket-scoped metric matching the filter + but with NO alert must NOT credit the child project — it should still FAIL.""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled import ( + logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='protoPayload.serviceName="compute.googleapis.com"', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [] # no alert -> must NOT credit + + check = ( + logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled() + ) + result = check.execute() + + child = [r for r in result if r.project_id == GCP_PROJECT_ID] + assert child and all(r.status == "FAIL" for r in child), [ + (r.project_id, r.status) for r in result + ] diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled_test.py index 4ec94be657..18d1807255 100644 --- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled_test.py +++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled_test.py @@ -259,3 +259,173 @@ class Test_logging_log_metric_filter_and_alert_for_custom_role_changes_enabled: assert result[0].resource_name == "metric_name" assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == GCP_EU1_LOCATION + + def test_project_centrally_covered_via_org_aggregated_sink(self): + """A child project with NO local metric, but whose org has an aggregated + sink (includeChildren=True) routing its logs to a central bucket that has + a bucket-scoped metric + alert, should PASS (covered centrally).""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled import ( + logging_log_metric_filter_and_alert_for_custom_role_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + from prowler.providers.gcp.services.monitoring.monitoring_service import ( + AlertPolicy, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='resource.type="iam_role" AND (protoPayload.methodName="google.iam.admin.v1.CreateRole" OR protoPayload.methodName="google.iam.admin.v1.DeleteRole" OR protoPayload.methodName="google.iam.admin.v1.UpdateRole")', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [ + AlertPolicy( + name="projects/central-logging-project/alertPolicies/ap", + display_name="central-alert", + enabled=True, + filters=[ + 'metric.type = "logging.googleapis.com/user/central-metric"' + ], + project_id="central-logging-project", + ) + ] + + check = ( + logging_log_metric_filter_and_alert_for_custom_role_changes_enabled() + ) + result = check.execute() + + assert any( + r.project_id == GCP_PROJECT_ID + and r.status == "PASS" + and "aggregated sink" in r.status_extended + for r in result + ), [(r.project_id, r.status, r.status_extended) for r in result] + + def test_aggregated_sink_metric_without_alert_still_fails(self): + """Guard: an org aggregated sink + a bucket-scoped metric matching the filter + but with NO alert must NOT credit the child project — it should still FAIL.""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled import ( + logging_log_metric_filter_and_alert_for_custom_role_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='resource.type="iam_role" AND (protoPayload.methodName="google.iam.admin.v1.CreateRole" OR protoPayload.methodName="google.iam.admin.v1.DeleteRole" OR protoPayload.methodName="google.iam.admin.v1.UpdateRole")', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [] # no alert -> must NOT credit + + check = ( + logging_log_metric_filter_and_alert_for_custom_role_changes_enabled() + ) + result = check.execute() + + child = [r for r in result if r.project_id == GCP_PROJECT_ID] + assert child and all(r.status == "FAIL" for r in child), [ + (r.project_id, r.status) for r in result + ] diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled_test.py index 0ea0798e03..3adb48e567 100644 --- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled_test.py +++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled_test.py @@ -392,3 +392,173 @@ class Test_logging_log_metric_filter_and_alert_for_project_ownership_changes_ena assert result[0].resource_name == "GCP Project" assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == GCP_EU1_LOCATION + + def test_project_centrally_covered_via_org_aggregated_sink(self): + """A child project with NO local metric, but whose org has an aggregated + sink (includeChildren=True) routing its logs to a central bucket that has + a bucket-scoped metric + alert, should PASS (covered centrally).""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled import ( + logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + from prowler.providers.gcp.services.monitoring.monitoring_service import ( + AlertPolicy, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='(protoPayload.serviceName="cloudresourcemanager.googleapis.com") AND (ProjectOwnership OR projectOwnerInvitee) OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="REMOVE" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner") OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="ADD" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner")', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [ + AlertPolicy( + name="projects/central-logging-project/alertPolicies/ap", + display_name="central-alert", + enabled=True, + filters=[ + 'metric.type = "logging.googleapis.com/user/central-metric"' + ], + project_id="central-logging-project", + ) + ] + + check = ( + logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled() + ) + result = check.execute() + + assert any( + r.project_id == GCP_PROJECT_ID + and r.status == "PASS" + and "aggregated sink" in r.status_extended + for r in result + ), [(r.project_id, r.status, r.status_extended) for r in result] + + def test_aggregated_sink_metric_without_alert_still_fails(self): + """Guard: an org aggregated sink + a bucket-scoped metric matching the filter + but with NO alert must NOT credit the child project — it should still FAIL.""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled import ( + logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='(protoPayload.serviceName="cloudresourcemanager.googleapis.com") AND (ProjectOwnership OR projectOwnerInvitee) OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="REMOVE" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner") OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="ADD" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner")', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [] # no alert -> must NOT credit + + check = ( + logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled() + ) + result = check.execute() + + child = [r for r in result if r.project_id == GCP_PROJECT_ID] + assert child and all(r.status == "FAIL" for r in child), [ + (r.project_id, r.status) for r in result + ] diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled_test.py index 1a8a1d0da3..9444f0cc61 100644 --- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled_test.py +++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled_test.py @@ -259,3 +259,173 @@ class Test_logging_log_metric_filter_and_alert_for_sql_instance_configuration_ch assert result[0].resource_name == "metric_name" assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == GCP_EU1_LOCATION + + def test_project_centrally_covered_via_org_aggregated_sink(self): + """A child project with NO local metric, but whose org has an aggregated + sink (includeChildren=True) routing its logs to a central bucket that has + a bucket-scoped metric + alert, should PASS (covered centrally).""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled import ( + logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + from prowler.providers.gcp.services.monitoring.monitoring_service import ( + AlertPolicy, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='protoPayload.methodName="cloudsql.instances.update"', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [ + AlertPolicy( + name="projects/central-logging-project/alertPolicies/ap", + display_name="central-alert", + enabled=True, + filters=[ + 'metric.type = "logging.googleapis.com/user/central-metric"' + ], + project_id="central-logging-project", + ) + ] + + check = ( + logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled() + ) + result = check.execute() + + assert any( + r.project_id == GCP_PROJECT_ID + and r.status == "PASS" + and "aggregated sink" in r.status_extended + for r in result + ), [(r.project_id, r.status, r.status_extended) for r in result] + + def test_aggregated_sink_metric_without_alert_still_fails(self): + """Guard: an org aggregated sink + a bucket-scoped metric matching the filter + but with NO alert must NOT credit the child project — it should still FAIL.""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled import ( + logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='protoPayload.methodName="cloudsql.instances.update"', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [] # no alert -> must NOT credit + + check = ( + logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled() + ) + result = check.execute() + + child = [r for r in result if r.project_id == GCP_PROJECT_ID] + assert child and all(r.status == "FAIL" for r in child), [ + (r.project_id, r.status) for r in result + ] diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled_test.py index a9460e6b46..3d34a3c295 100644 --- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled_test.py +++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled_test.py @@ -259,3 +259,173 @@ class Test_logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_ena assert result[0].resource_name == "metric_name" assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == GCP_EU1_LOCATION + + def test_project_centrally_covered_via_org_aggregated_sink(self): + """A child project with NO local metric, but whose org has an aggregated + sink (includeChildren=True) routing its logs to a central bucket that has + a bucket-scoped metric + alert, should PASS (covered centrally).""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled import ( + logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + from prowler.providers.gcp.services.monitoring.monitoring_service import ( + AlertPolicy, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='resource.type="gce_firewall_rule" AND (protoPayload.methodName:"compute.firewalls.patch" OR protoPayload.methodName:"compute.firewalls.insert" OR protoPayload.methodName:"compute.firewalls.delete")', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [ + AlertPolicy( + name="projects/central-logging-project/alertPolicies/ap", + display_name="central-alert", + enabled=True, + filters=[ + 'metric.type = "logging.googleapis.com/user/central-metric"' + ], + project_id="central-logging-project", + ) + ] + + check = ( + logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled() + ) + result = check.execute() + + assert any( + r.project_id == GCP_PROJECT_ID + and r.status == "PASS" + and "aggregated sink" in r.status_extended + for r in result + ), [(r.project_id, r.status, r.status_extended) for r in result] + + def test_aggregated_sink_metric_without_alert_still_fails(self): + """Guard: an org aggregated sink + a bucket-scoped metric matching the filter + but with NO alert must NOT credit the child project — it should still FAIL.""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled import ( + logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='resource.type="gce_firewall_rule" AND (protoPayload.methodName:"compute.firewalls.patch" OR protoPayload.methodName:"compute.firewalls.insert" OR protoPayload.methodName:"compute.firewalls.delete")', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [] # no alert -> must NOT credit + + check = ( + logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled() + ) + result = check.execute() + + child = [r for r in result if r.project_id == GCP_PROJECT_ID] + assert child and all(r.status == "FAIL" for r in child), [ + (r.project_id, r.status) for r in result + ] diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled_test.py index 9c59d56a81..a71a21ceb6 100644 --- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled_test.py +++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled_test.py @@ -259,3 +259,173 @@ class Test_logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled: assert result[0].resource_name == "metric_name" assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == GCP_EU1_LOCATION + + def test_project_centrally_covered_via_org_aggregated_sink(self): + """A child project with NO local metric, but whose org has an aggregated + sink (includeChildren=True) routing its logs to a central bucket that has + a bucket-scoped metric + alert, should PASS (covered centrally).""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled import ( + logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + from prowler.providers.gcp.services.monitoring.monitoring_service import ( + AlertPolicy, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='resource.type="gce_network" AND (protoPayload.methodName:"compute.networks.insert" OR protoPayload.methodName:"compute.networks.patch" OR protoPayload.methodName:"compute.networks.delete" OR protoPayload.methodName:"compute.networks.removePeering" OR protoPayload.methodName:"compute.networks.addPeering")', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [ + AlertPolicy( + name="projects/central-logging-project/alertPolicies/ap", + display_name="central-alert", + enabled=True, + filters=[ + 'metric.type = "logging.googleapis.com/user/central-metric"' + ], + project_id="central-logging-project", + ) + ] + + check = ( + logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled() + ) + result = check.execute() + + assert any( + r.project_id == GCP_PROJECT_ID + and r.status == "PASS" + and "aggregated sink" in r.status_extended + for r in result + ), [(r.project_id, r.status, r.status_extended) for r in result] + + def test_aggregated_sink_metric_without_alert_still_fails(self): + """Guard: an org aggregated sink + a bucket-scoped metric matching the filter + but with NO alert must NOT credit the child project — it should still FAIL.""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled import ( + logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='resource.type="gce_network" AND (protoPayload.methodName:"compute.networks.insert" OR protoPayload.methodName:"compute.networks.patch" OR protoPayload.methodName:"compute.networks.delete" OR protoPayload.methodName:"compute.networks.removePeering" OR protoPayload.methodName:"compute.networks.addPeering")', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [] # no alert -> must NOT credit + + check = ( + logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled() + ) + result = check.execute() + + child = [r for r in result if r.project_id == GCP_PROJECT_ID] + assert child and all(r.status == "FAIL" for r in child), [ + (r.project_id, r.status) for r in result + ] diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled_test.py index 254c41bb5f..3a7f41a485 100644 --- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled_test.py +++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled_test.py @@ -259,3 +259,173 @@ class Test_logging_log_metric_filter_and_alert_for_vpc_network_route_changes_ena assert result[0].resource_name == "metric_name" assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == GCP_EU1_LOCATION + + def test_project_centrally_covered_via_org_aggregated_sink(self): + """A child project with NO local metric, but whose org has an aggregated + sink (includeChildren=True) routing its logs to a central bucket that has + a bucket-scoped metric + alert, should PASS (covered centrally).""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled import ( + logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + from prowler.providers.gcp.services.monitoring.monitoring_service import ( + AlertPolicy, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='resource.type="gce_route" AND (protoPayload.methodName:"compute.routes.delete" OR protoPayload.methodName:"compute.routes.insert")', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [ + AlertPolicy( + name="projects/central-logging-project/alertPolicies/ap", + display_name="central-alert", + enabled=True, + filters=[ + 'metric.type = "logging.googleapis.com/user/central-metric"' + ], + project_id="central-logging-project", + ) + ] + + check = ( + logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled() + ) + result = check.execute() + + assert any( + r.project_id == GCP_PROJECT_ID + and r.status == "PASS" + and "aggregated sink" in r.status_extended + for r in result + ), [(r.project_id, r.status, r.status_extended) for r in result] + + def test_aggregated_sink_metric_without_alert_still_fails(self): + """Guard: an org aggregated sink + a bucket-scoped metric matching the filter + but with NO alert must NOT credit the child project — it should still FAIL.""" + logging_client = MagicMock() + monitoring_client = MagicMock() + org_id = "111222333" + central_bucket = ( + "projects/central-logging-project/locations/eu/buckets/central-bucket" + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled import ( + logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled, + ) + from prowler.providers.gcp.services.logging.logging_service import ( + Metric, + Sink, + ) + + logging_client.region = GCP_EU1_LOCATION + logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter='resource.type="gce_route" AND (protoPayload.methodName:"compute.routes.delete" OR protoPayload.methodName:"compute.routes.insert")', + project_id="central-logging-project", + bucket_name=central_bucket, + ) + ] + logging_client.sinks = [ + Sink( + name="org-aggregated-sink", + destination=f"logging.googleapis.com/{central_bucket}", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + monitoring_client.alert_policies = [] # no alert -> must NOT credit + + check = ( + logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled() + ) + result = check.execute() + + child = [r for r in result if r.project_id == GCP_PROJECT_ID] + assert child and all(r.status == "FAIL" for r in child), [ + (r.project_id, r.status) for r in result + ] diff --git a/tests/providers/gcp/services/logging/logging_service_test.py b/tests/providers/gcp/services/logging/logging_service_test.py index 0396130c2f..e466a17314 100644 --- a/tests/providers/gcp/services/logging/logging_service_test.py +++ b/tests/providers/gcp/services/logging/logging_service_test.py @@ -1,4 +1,6 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch + +import pytest from prowler.providers.gcp.services.logging.logging_service import Logging from tests.providers.gcp.gcp_fixtures import ( @@ -66,3 +68,324 @@ class TestLoggingService: == "resource.type=gae_app AND severity>=ERROR" ) assert logging_client.metrics[1].project_id == GCP_PROJECT_ID + + def test_org_sinks_fetched_when_project_has_organization(self): + """_get_org_sinks() appends org-level sinks when projects have an org.""" + from prowler.providers.gcp.models import GCPOrganization, GCPProject + + org_id = "999888777" + provider = set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + provider.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="test", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization(id=org_id, name=f"organizations/{org_id}"), + ) + } + + mock_client = MagicMock() + mock_client.sinks().list().execute.return_value = { + "sinks": [ + { + "name": "org-sink", + "destination": "storage.googleapis.com/org-bucket", + "filter": "all", + "includeChildren": True, + } + ] + } + mock_client.sinks().list_next.return_value = None + mock_client.projects().metrics().list().execute.return_value = {"metrics": []} + mock_client.projects().metrics().list_next.return_value = None + + 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=mock_client, + ), + ): + logging_svc = Logging(provider) + + org_sinks = [ + s for s in logging_svc.sinks if s.project_id == f"organizations/{org_id}" + ] + assert len(org_sinks) == 1 + assert org_sinks[0].name == "org-sink" + assert org_sinks[0].include_children is True + assert org_sinks[0].filter == "all" + + def test_org_sinks_skipped_when_no_organization(self): + """_get_org_sinks() adds nothing when projects have no organization.""" + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + ): + logging_svc = Logging(set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID])) + + org_sinks = [ + s for s in logging_svc.sinks if s.project_id.startswith("organizations/") + ] + assert org_sinks == [] + + def test_get_metrics_populates_bucket_name(self): + """_get_metrics() captures a metric's bucketName (for aggregated-sink crediting).""" + bucket = "projects/central-logging-project/locations/eu/buckets/central-bucket" + mock_client = MagicMock() + mock_client.sinks().list().execute.return_value = {"sinks": []} + mock_client.sinks().list_next.return_value = None + mock_client.projects().metrics().list().execute.return_value = { + "metrics": [ + { + "name": "central-metric", + "metricDescriptor": { + "type": "logging.googleapis.com/user/central-metric" + }, + "filter": "severity>=ERROR", + "bucketName": bucket, + } + ] + } + mock_client.projects().metrics().list_next.return_value = None + + 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=mock_client, + ), + ): + logging_svc = Logging(set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID])) + + metrics = [m for m in logging_svc.metrics if m.name == "central-metric"] + assert len(metrics) == 1 + assert metrics[0].bucket_name == bucket + + +class TestGetProjectsCoveredByAggregatedMetric: + """Unit tests for the aggregated-sink crediting helper: one positive case and the + guards that must NOT credit a project (so the metric-filter checks never false-pass). + """ + + FILTER = 'protoPayload.methodName="SetIamPolicy"' + ORG = "111222333" + BUCKET = "projects/central-logging-project/locations/eu/buckets/central-bucket" + + def _clients( + self, + *, + include_children=True, + bucket_name=None, + sink_destination=None, + sink_filter="all", + with_alert=True, + project_org_id=None, + ): + from prowler.providers.gcp.models import GCPOrganization, GCPProject + from prowler.providers.gcp.services.logging.logging_service import Metric, Sink + from prowler.providers.gcp.services.monitoring.monitoring_service import ( + AlertPolicy, + ) + + bucket_name = self.BUCKET if bucket_name is None else bucket_name + sink_destination = ( + f"logging.googleapis.com/{self.BUCKET}" + if sink_destination is None + else sink_destination + ) + project_org_id = self.ORG if project_org_id is None else project_org_id + + logging_client = MagicMock() + logging_client.project_ids = [GCP_PROJECT_ID] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="child", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=project_org_id, name=f"organizations/{project_org_id}" + ), + ) + } + logging_client.metrics = [ + Metric( + name="central-metric", + type="logging.googleapis.com/user/central-metric", + filter=self.FILTER, + project_id="central-logging-project", + bucket_name=bucket_name, + ) + ] + logging_client.sinks = [ + Sink( + name="org-sink", + destination=sink_destination, + filter=sink_filter, + project_id=f"organizations/{self.ORG}", + include_children=include_children, + ) + ] + monitoring_client = MagicMock() + monitoring_client.alert_policies = ( + [ + AlertPolicy( + name="projects/central-logging-project/alertPolicies/ap", + display_name="central-alert", + enabled=True, + filters=[ + 'metric.type = "logging.googleapis.com/user/central-metric"' + ], + project_id="central-logging-project", + ) + ] + if with_alert + else [] + ) + return logging_client, monitoring_client + + def _run(self, logging_client, monitoring_client): + from prowler.providers.gcp.services.logging.logging_service import ( + get_projects_covered_by_aggregated_metric, + ) + + return get_projects_covered_by_aggregated_metric( + logging_client, monitoring_client, self.FILTER + ) + + def test_covered_when_all_conditions_met(self): + logging_client, monitoring_client = self._clients() + assert self._run(logging_client, monitoring_client) == { + GCP_PROJECT_ID: "central-metric" + } + + def test_not_covered_without_alert(self): + logging_client, monitoring_client = self._clients(with_alert=False) + assert self._run(logging_client, monitoring_client) == {} + + def test_not_covered_when_metric_not_bucket_scoped(self): + logging_client, monitoring_client = self._clients(bucket_name="") + assert self._run(logging_client, monitoring_client) == {} + + def test_not_covered_when_sink_not_include_children(self): + logging_client, monitoring_client = self._clients(include_children=False) + assert self._run(logging_client, monitoring_client) == {} + + def test_not_covered_when_sink_filter_is_restrictive(self): + logging_client, monitoring_client = self._clients( + sink_filter='resource.type="gce_instance"' + ) + assert self._run(logging_client, monitoring_client) == {} + + def test_not_covered_when_sink_filter_omits_activity_stream(self): + """A sink that routes cloudaudit streams but NOT Admin Activity (here, + data_access only) does not deliver the entries the CIS metric filters + match, so it must not be credited — right service, wrong stream.""" + logging_client, monitoring_client = self._clients( + sink_filter="logName: /logs/cloudaudit.googleapis.com%2Fdata_access" + ) + assert self._run(logging_client, monitoring_client) == {} + + def test_covered_when_sink_filter_carries_activity_stream_encoded(self): + """A sink filtered to the cloudaudit streams (URL-encoded logName form, + as returned by the Logging API) delivers every Admin Activity entry the + CIS metric filters can match, so it must be credited.""" + logging_client, monitoring_client = self._clients( + sink_filter=( + "logName: /logs/cloudaudit.googleapis.com%2Factivity OR " + "logName: /logs/cloudaudit.googleapis.com%2Fdata_access" + ) + ) + assert self._run(logging_client, monitoring_client) == { + GCP_PROJECT_ID: "central-metric" + } + + def test_covered_when_sink_filter_carries_activity_stream_plain(self): + logging_client, monitoring_client = self._clients( + sink_filter='logName="projects/p/logs/cloudaudit.googleapis.com/activity"' + ) + assert self._run(logging_client, monitoring_client) == { + GCP_PROJECT_ID: "central-metric" + } + + @pytest.mark.parametrize( + "sink_filter", + [ + # --- Negation: the stream is named but excluded. --- + 'NOT logName:"projects/p/logs/cloudaudit.googleapis.com%2Factivity"', + '-logName:"projects/p/logs/cloudaudit.googleapis.com%2Factivity"', + 'NOT log_id("cloudaudit.googleapis.com/activity")', + # "!=" inequality (and its spaced form) excludes the stream. + 'logName!="projects/p/logs/cloudaudit.googleapis.com%2Factivity"', + 'logName != "projects/p/logs/cloudaudit.googleapis.com/activity"', + # Activity negated inside a compound filter. + 'resource.type="gce_instance" AND ' + 'NOT logName:"projects/p/logs/cloudaudit.googleapis.com%2Factivity"', + # --- Restriction: the stream is named but AND-narrowed, so only a + # subset of Admin Activity entries reaches the bucket. --- + 'logName:"projects/p/logs/cloudaudit.googleapis.com%2Factivity" ' + 'AND resource.type="gce_instance"', + 'log_id("cloudaudit.googleapis.com/activity") ' + 'AND resource.type="gce_instance"', + 'logName="projects/p/logs/cloudaudit.googleapis.com/activity" ' + "AND severity>=ERROR", + 'logName:"projects/p/logs/cloudaudit.googleapis.com%2Factivity" ' + 'AND protoPayload.methodName="SetIamPolicy"', + # --- OR-ed with a non-audit predicate: fail closed, since we credit + # only unions of provable Cloud Audit stream selectors. --- + 'logName:"projects/p/logs/cloudaudit.googleapis.com%2Factivity" ' + "OR severity>=ERROR", + ], + ) + def test_not_covered_when_sink_filter_negated_or_restrictive(self, sink_filter): + """A filter that names the Admin Activity stream but negates, narrows, or + mixes in an unprovable predicate is not credited — we credit only filters + we can prove deliver every Admin Activity entry the CIS metrics match.""" + logging_client, monitoring_client = self._clients(sink_filter=sink_filter) + assert self._run(logging_client, monitoring_client) == {} + + def test_covered_when_activity_logname_has_hyphenated_path(self): + """A hyphen in the project path must not be mistaken for the ``-`` (NOT) + negation operator — the activity stream is still delivered.""" + logging_client, monitoring_client = self._clients( + sink_filter='logName="projects/my-project/logs/cloudaudit.googleapis.com/activity"' + ) + assert self._run(logging_client, monitoring_client) == { + GCP_PROJECT_ID: "central-metric" + } + + def test_covered_when_sink_filter_uses_log_id_selector(self): + """The ``log_id()`` form is an equivalent positive full-coverage selector + of the Admin Activity stream and is credited like the ``logName`` form.""" + logging_client, monitoring_client = self._clients( + sink_filter='log_id("cloudaudit.googleapis.com/activity")' + ) + assert self._run(logging_client, monitoring_client) == { + GCP_PROJECT_ID: "central-metric" + } + + def test_not_covered_when_sink_destination_bucket_differs(self): + logging_client, monitoring_client = self._clients( + sink_destination="logging.googleapis.com/projects/x/locations/eu/buckets/other" + ) + assert self._run(logging_client, monitoring_client) == {} + + def test_not_covered_when_project_org_differs(self): + logging_client, monitoring_client = self._clients(project_org_id="999999999") + assert self._run(logging_client, monitoring_client) == {} diff --git a/tests/providers/gcp/services/logging/logging_sink_created/logging_sink_created_test.py b/tests/providers/gcp/services/logging/logging_sink_created/logging_sink_created_test.py index b9c6481d22..6ced615f65 100644 --- a/tests/providers/gcp/services/logging/logging_sink_created/logging_sink_created_test.py +++ b/tests/providers/gcp/services/logging/logging_sink_created/logging_sink_created_test.py @@ -1,6 +1,6 @@ from unittest.mock import MagicMock, patch -from prowler.providers.gcp.models import GCPProject +from prowler.providers.gcp.models import GCPOrganization, GCPProject from tests.providers.gcp.gcp_fixtures import ( GCP_EU1_LOCATION, GCP_PROJECT_ID, @@ -268,6 +268,7 @@ class Test_logging_sink_created: sink.name = None sink.filter = "all" sink.project_id = GCP_PROJECT_ID + sink.include_children = False logging_client.project_ids = [GCP_PROJECT_ID] logging_client.region = GCP_EU1_LOCATION @@ -311,9 +312,10 @@ class Test_logging_sink_created: ) # Create a MagicMock sink object without name attribute - sink = MagicMock(spec=["filter", "project_id"]) + sink = MagicMock(spec=["filter", "project_id", "include_children"]) sink.filter = "all" sink.project_id = GCP_PROJECT_ID + sink.include_children = False logging_client.project_ids = [GCP_PROJECT_ID] logging_client.region = GCP_EU1_LOCATION @@ -336,3 +338,175 @@ class Test_logging_sink_created: assert result[0].resource_id == "unknown" assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == GCP_EU1_LOCATION + + def test_org_level_sink_with_include_children_passes(self): + """Projects covered by an org-level sink with includeChildren=True should PASS.""" + logging_client = MagicMock() + org_id = "111222333" + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created.logging_client", + new=logging_client, + ), + ): + from prowler.providers.gcp.services.logging.logging_service import Sink + from prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created import ( + logging_sink_created, + ) + + logging_client.project_ids = [GCP_PROJECT_ID] + logging_client.region = GCP_EU1_LOCATION + logging_client.sinks = [ + Sink( + name="org-sink", + destination="storage.googleapis.com/org-bucket", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ) + ] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="test", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + + check = logging_sink_created() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Sink org-sink at organization level is exporting copies of all the log entries in project {GCP_PROJECT_ID}." + ) + assert result[0].resource_id == "org-sink" + assert result[0].project_id == GCP_PROJECT_ID + assert result[0].location == GCP_EU1_LOCATION + + def test_org_level_sink_without_include_children_fails(self): + """Projects NOT covered by includeChildren should still FAIL if no direct project sink.""" + logging_client = MagicMock() + org_id = "111222333" + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created.logging_client", + new=logging_client, + ), + ): + from prowler.providers.gcp.services.logging.logging_service import Sink + from prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created import ( + logging_sink_created, + ) + + logging_client.project_ids = [GCP_PROJECT_ID] + logging_client.region = GCP_EU1_LOCATION + logging_client.sinks = [ + Sink( + name="org-sink-no-children", + destination="storage.googleapis.com/org-bucket", + filter="all", + project_id=f"organizations/{org_id}", + include_children=False, + ) + ] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="test", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + + check = logging_sink_created() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"There are no logging sinks to export copies of all the log entries in project {GCP_PROJECT_ID}." + ) + assert result[0].resource_id == GCP_PROJECT_ID + assert result[0].project_id == GCP_PROJECT_ID + + def test_project_sink_takes_precedence_over_org_sink(self): + """A direct project sink should be reported even when an org-level sink also covers the project.""" + logging_client = MagicMock() + org_id = "111222333" + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created.logging_client", + new=logging_client, + ), + ): + from prowler.providers.gcp.services.logging.logging_service import Sink + from prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created import ( + logging_sink_created, + ) + + logging_client.project_ids = [GCP_PROJECT_ID] + logging_client.region = GCP_EU1_LOCATION + logging_client.sinks = [ + Sink( + name="project-sink", + destination="storage.googleapis.com/project-bucket", + filter="all", + project_id=GCP_PROJECT_ID, + ), + Sink( + name="org-sink", + destination="storage.googleapis.com/org-bucket", + filter="all", + project_id=f"organizations/{org_id}", + include_children=True, + ), + ] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="test", + labels={}, + lifecycle_state="ACTIVE", + organization=GCPOrganization( + id=org_id, name=f"organizations/{org_id}" + ), + ) + } + + check = logging_sink_created() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Sink project-sink is enabled exporting copies of all the log entries in project {GCP_PROJECT_ID}." + ) + assert result[0].resource_id == "project-sink" + assert result[0].project_id == GCP_PROJECT_ID diff --git a/tests/providers/m365/services/admincenter/admincenter_service_test.py b/tests/providers/m365/services/admincenter/admincenter_service_test.py index ce0a2a3050..4eedba05e1 100644 --- a/tests/providers/m365/services/admincenter/admincenter_service_test.py +++ b/tests/providers/m365/services/admincenter/admincenter_service_test.py @@ -252,3 +252,49 @@ def test_admincenter__get_groups_maps_group_types(): assert groups["id-2"].group_types == [] assert groups["id-3"].group_types == [] assert groups["id-3"].visibility == "Public" + + +def test_admincenter__get_groups_handles_pagination(): + admincenter_service = AdminCenter.__new__(AdminCenter) + + groups_response_page_one = SimpleNamespace( + value=[ + SimpleNamespace( + id="id-1", + display_name="First Unified Group", + visibility="Private", + group_types=["Unified"], + ) + ], + odata_next_link="next-link", + ) + groups_response_page_two = SimpleNamespace( + value=[ + SimpleNamespace( + id="id-2", + display_name="Second Unified Group", + visibility="Public", + group_types=["Unified"], + ) + ], + odata_next_link=None, + ) + + groups_with_url_builder = SimpleNamespace( + get=AsyncMock(return_value=groups_response_page_two) + ) + with_url_mock = MagicMock(return_value=groups_with_url_builder) + groups_builder = SimpleNamespace( + get=AsyncMock(return_value=groups_response_page_one), + with_url=with_url_mock, + ) + admincenter_service.client = SimpleNamespace(groups=groups_builder) + + groups = asyncio.run(admincenter_service._get_groups()) + + assert len(groups) == 2 + assert groups_builder.get.await_count == 1 + with_url_mock.assert_called_once_with("next-link") + assert groups["id-1"].name == "First Unified Group" + assert groups["id-2"].name == "Second Unified Group" + assert groups["id-2"].visibility == "Public" diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_no_deleted_object_references/entra_conditional_access_policy_no_deleted_object_references_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_no_deleted_object_references/entra_conditional_access_policy_no_deleted_object_references_test.py new file mode 100644 index 0000000000..6d3e7786cf --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_no_deleted_object_references/entra_conditional_access_policy_no_deleted_object_references_test.py @@ -0,0 +1,466 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.m365.services.entra.entra_service import ( + ApplicationsConditions, + ConditionalAccessPolicy, + ConditionalAccessPolicyState, + Conditions, + GrantControlOperator, + GrantControls, + PersistentBrowser, + SessionControls, + SignInFrequency, + UsersConditions, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + + +def _make_policy( + *, + display_name="Test Policy", + state=ConditionalAccessPolicyState.ENABLED, + included_users=None, + excluded_users=None, + included_groups=None, + excluded_groups=None, + included_roles=None, + excluded_roles=None, +): + """Build a ConditionalAccessPolicy with the minimum fields required by the model.""" + policy_id = str(uuid4()) + policy = ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=[], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_users=included_users or [], + excluded_users=excluded_users or [], + included_groups=included_groups or [], + excluded_groups=excluded_groups or [], + included_roles=included_roles or [], + excluded_roles=excluded_roles or [], + ), + client_app_types=[], + ), + grant_controls=GrantControls( + built_in_controls=[], + operator=GrantControlOperator.OR, + authentication_strength=None, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser(is_enabled=False, mode=""), + sign_in_frequency=SignInFrequency( + is_enabled=False, frequency=None, type=None, interval=None + ), + ), + state=state, + ) + return policy_id, policy + + +def _entra_client_mock(): + client = mock.MagicMock() + client.audited_tenant = "audited_tenant" + client.audited_domain = DOMAIN + # Default to clean resolution; individual tests override as needed. + client.unresolved_directory_object_references = set() + client.errored_directory_object_references = set() + return client + + +CHECK_MODULE = ( + "prowler.providers.m365.services.entra." + "entra_conditional_access_policy_no_deleted_object_references." + "entra_conditional_access_policy_no_deleted_object_references.entra_client" +) + + +class Test_entra_conditional_access_policy_no_deleted_object_references: + def test_no_policies(self): + """No Conditional Access policies in tenant: no findings.""" + entra_client = _entra_client_mock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(CHECK_MODULE, new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_no_deleted_object_references.entra_conditional_access_policy_no_deleted_object_references import ( + entra_conditional_access_policy_no_deleted_object_references, + ) + + entra_client.conditional_access_policies = {} + entra_client.unresolved_directory_object_references = set() + + check = entra_conditional_access_policy_no_deleted_object_references() + result = check.execute() + + assert len(result) == 0 + + def test_sentinel_only_references_pass(self): + """Policy with only sentinel values ('All', 'GuestsOrExternalUsers') passes.""" + entra_client = _entra_client_mock() + policy_id, policy = _make_policy( + display_name="MFA For All", + included_users=["All"], + excluded_users=["GuestsOrExternalUsers"], + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(CHECK_MODULE, new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_no_deleted_object_references.entra_conditional_access_policy_no_deleted_object_references import ( + entra_conditional_access_policy_no_deleted_object_references, + ) + + entra_client.conditional_access_policies = {policy_id: policy} + entra_client.unresolved_directory_object_references = set() + + check = entra_conditional_access_policy_no_deleted_object_references() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + "references no deleted directory objects" in result[0].status_extended + ) + assert result[0].resource_id == policy_id + assert result[0].resource_name == "MFA For All" + + def test_all_references_resolve_pass(self): + """Policy with real identifiers, none in the unresolved set: PASS.""" + entra_client = _entra_client_mock() + live_user = str(uuid4()) + live_group = str(uuid4()) + policy_id, policy = _make_policy( + display_name="Targeted Policy", + included_users=[live_user], + included_groups=[live_group], + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(CHECK_MODULE, new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_no_deleted_object_references.entra_conditional_access_policy_no_deleted_object_references import ( + entra_conditional_access_policy_no_deleted_object_references, + ) + + entra_client.conditional_access_policies = {policy_id: policy} + entra_client.unresolved_directory_object_references = set() + + check = entra_conditional_access_policy_no_deleted_object_references() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_deleted_user_in_include_fails(self): + """Policy referencing a deleted user in includeUsers fails with type+side reported.""" + entra_client = _entra_client_mock() + deleted_user = str(uuid4()) + policy_id, policy = _make_policy( + display_name="Require MFA", + included_users=[deleted_user], + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(CHECK_MODULE, new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_no_deleted_object_references.entra_conditional_access_policy_no_deleted_object_references import ( + entra_conditional_access_policy_no_deleted_object_references, + ) + + entra_client.conditional_access_policies = {policy_id: policy} + entra_client.unresolved_directory_object_references = { + ("user", deleted_user) + } + + check = entra_conditional_access_policy_no_deleted_object_references() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "1 deleted directory object(s)" in result[0].status_extended + assert "users:" in result[0].status_extended + assert deleted_user in result[0].status_extended + assert "(include)" in result[0].status_extended + + def test_deleted_group_in_exclude_fails(self): + """Policy referencing a deleted group in excludeGroups fails with exclude side reported.""" + entra_client = _entra_client_mock() + deleted_group = str(uuid4()) + policy_id, policy = _make_policy( + display_name="Block Legacy Auth", + included_users=["All"], + excluded_groups=[deleted_group], + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(CHECK_MODULE, new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_no_deleted_object_references.entra_conditional_access_policy_no_deleted_object_references import ( + entra_conditional_access_policy_no_deleted_object_references, + ) + + entra_client.conditional_access_policies = {policy_id: policy} + entra_client.unresolved_directory_object_references = { + ("group", deleted_group) + } + + check = entra_conditional_access_policy_no_deleted_object_references() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "groups:" in result[0].status_extended + assert "(exclude)" in result[0].status_extended + + def test_deleted_role_in_disabled_policy_still_fails(self): + """Disabled policy with a stale role reference still FAILs (per spec).""" + entra_client = _entra_client_mock() + deleted_role = str(uuid4()) + policy_id, policy = _make_policy( + display_name="Legacy Admin Policy", + state=ConditionalAccessPolicyState.DISABLED, + included_roles=[deleted_role], + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(CHECK_MODULE, new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_no_deleted_object_references.entra_conditional_access_policy_no_deleted_object_references import ( + entra_conditional_access_policy_no_deleted_object_references, + ) + + entra_client.conditional_access_policies = {policy_id: policy} + entra_client.unresolved_directory_object_references = { + ("role", deleted_role) + } + + check = entra_conditional_access_policy_no_deleted_object_references() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "roles:" in result[0].status_extended + assert deleted_role in result[0].status_extended + + def test_orphans_grouped_by_type_across_collections(self): + """A single policy with orphans of every type aggregates them grouped by type.""" + entra_client = _entra_client_mock() + deleted_user = str(uuid4()) + deleted_group = str(uuid4()) + deleted_role = str(uuid4()) + policy_id, policy = _make_policy( + display_name="Composite Policy", + included_users=[deleted_user], + excluded_groups=[deleted_group], + included_roles=[deleted_role], + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(CHECK_MODULE, new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_no_deleted_object_references.entra_conditional_access_policy_no_deleted_object_references import ( + entra_conditional_access_policy_no_deleted_object_references, + ) + + entra_client.conditional_access_policies = {policy_id: policy} + entra_client.unresolved_directory_object_references = { + ("user", deleted_user), + ("group", deleted_group), + ("role", deleted_role), + } + + check = entra_conditional_access_policy_no_deleted_object_references() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "3 deleted directory object(s)" in result[0].status_extended + assert "users:" in result[0].status_extended + assert "groups:" in result[0].status_extended + assert "roles:" in result[0].status_extended + + def test_report_only_policy_failure_notes_mode(self): + """A report-only policy with an orphan FAILs and flags the not-yet-enforced state.""" + entra_client = _entra_client_mock() + deleted_user = str(uuid4()) + policy_id, policy = _make_policy( + display_name="Report Only MFA", + state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, + included_users=[deleted_user], + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(CHECK_MODULE, new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_no_deleted_object_references.entra_conditional_access_policy_no_deleted_object_references import ( + entra_conditional_access_policy_no_deleted_object_references, + ) + + entra_client.conditional_access_policies = {policy_id: policy} + entra_client.unresolved_directory_object_references = { + ("user", deleted_user) + } + + check = entra_conditional_access_policy_no_deleted_object_references() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "report-only mode" in result[0].status_extended + + def test_unverified_reference_is_manual(self): + """A reference that errored (non-404) yields MANUAL, not PASS.""" + entra_client = _entra_client_mock() + errored_group = str(uuid4()) + policy_id, policy = _make_policy( + display_name="Throttled Lookup Policy", + included_users=["All"], + excluded_groups=[errored_group], + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(CHECK_MODULE, new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_no_deleted_object_references.entra_conditional_access_policy_no_deleted_object_references import ( + entra_conditional_access_policy_no_deleted_object_references, + ) + + entra_client.conditional_access_policies = {policy_id: policy} + entra_client.unresolved_directory_object_references = set() + entra_client.errored_directory_object_references = { + ("group", errored_group) + } + + check = entra_conditional_access_policy_no_deleted_object_references() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "could not be fully evaluated" in result[0].status_extended + assert errored_group in result[0].status_extended + + def test_orphan_takes_precedence_over_unverified(self): + """A confirmed deletion FAILs even when another reference is unverified.""" + entra_client = _entra_client_mock() + deleted_user = str(uuid4()) + errored_group = str(uuid4()) + policy_id, policy = _make_policy( + display_name="Mixed Policy", + included_users=[deleted_user], + excluded_groups=[errored_group], + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(CHECK_MODULE, new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_no_deleted_object_references.entra_conditional_access_policy_no_deleted_object_references import ( + entra_conditional_access_policy_no_deleted_object_references, + ) + + entra_client.conditional_access_policies = {policy_id: policy} + entra_client.unresolved_directory_object_references = { + ("user", deleted_user) + } + entra_client.errored_directory_object_references = { + ("group", errored_group) + } + + check = entra_conditional_access_policy_no_deleted_object_references() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "1 deleted directory object(s)" in result[0].status_extended + assert "could not be verified" in result[0].status_extended + + def test_multiple_policies_mixed(self): + """Two policies: one clean, one with an orphan. Distinct PASS/FAIL findings.""" + entra_client = _entra_client_mock() + deleted_user = str(uuid4()) + + clean_id, clean_policy = _make_policy( + display_name="Clean Policy", + included_users=["All"], + ) + dirty_id, dirty_policy = _make_policy( + display_name="Stale Reference Policy", + excluded_users=[deleted_user], + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(CHECK_MODULE, new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_no_deleted_object_references.entra_conditional_access_policy_no_deleted_object_references import ( + entra_conditional_access_policy_no_deleted_object_references, + ) + + entra_client.conditional_access_policies = { + clean_id: clean_policy, + dirty_id: dirty_policy, + } + entra_client.unresolved_directory_object_references = { + ("user", deleted_user) + } + + check = entra_conditional_access_policy_no_deleted_object_references() + result = check.execute() + + assert len(result) == 2 + + clean_result = next(r for r in result if r.resource_id == clean_id) + dirty_result = next(r for r in result if r.resource_id == dirty_id) + + assert clean_result.status == "PASS" + assert dirty_result.status == "FAIL" + assert "(exclude)" in dirty_result.status_extended diff --git a/tests/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked_test.py b/tests/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked_test.py new file mode 100644 index 0000000000..6c8696da55 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked_test.py @@ -0,0 +1,339 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + DirectorySyncSettings, + Organization, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + +CHECK_MODULE = ( + "prowler.providers.m365.services.entra." + "entra_directory_sync_object_takeover_blocked." + "entra_directory_sync_object_takeover_blocked" +) + + +def _hybrid_org(): + return Organization( + id="org-001", + name="Hybrid Org", + on_premises_sync_enabled=True, + ) + + +def _cloud_only_org(): + return Organization( + id="org-001", + name="Cloud Only Org", + on_premises_sync_enabled=False, + ) + + +class Test_entra_directory_sync_object_takeover_blocked: + def test_both_blocks_enabled(self): + """PASS when both soft-match and hard-match blocks are enabled.""" + 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}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_directory_sync_object_takeover_blocked.entra_directory_sync_object_takeover_blocked import ( + entra_directory_sync_object_takeover_blocked, + ) + + entra_client.directory_sync_settings = [ + DirectorySyncSettings( + id="sync-001", + block_soft_match_enabled=True, + block_cloud_object_takeover_through_hard_match_enabled=True, + ) + ] + entra_client.directory_sync_error = None + entra_client.organizations = [_hybrid_org()] + + check = entra_directory_sync_object_takeover_blocked() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "blocks both soft-match and hard-match" in result[0].status_extended + + def test_soft_match_disabled(self): + """FAIL when soft-match block is disabled on a hybrid tenant.""" + 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}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_directory_sync_object_takeover_blocked.entra_directory_sync_object_takeover_blocked import ( + entra_directory_sync_object_takeover_blocked, + ) + + entra_client.directory_sync_settings = [ + DirectorySyncSettings( + id="sync-001", + block_soft_match_enabled=False, + block_cloud_object_takeover_through_hard_match_enabled=True, + ) + ] + entra_client.directory_sync_error = None + entra_client.organizations = [_hybrid_org()] + + check = entra_directory_sync_object_takeover_blocked() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "blockSoftMatchEnabled" in result[0].status_extended + + def test_hard_match_disabled(self): + """FAIL when hard-match block is disabled on a hybrid tenant.""" + 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}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_directory_sync_object_takeover_blocked.entra_directory_sync_object_takeover_blocked import ( + entra_directory_sync_object_takeover_blocked, + ) + + entra_client.directory_sync_settings = [ + DirectorySyncSettings( + id="sync-001", + block_soft_match_enabled=True, + block_cloud_object_takeover_through_hard_match_enabled=False, + ) + ] + entra_client.directory_sync_error = None + entra_client.organizations = [_hybrid_org()] + + check = entra_directory_sync_object_takeover_blocked() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "blockCloudObjectTakeoverThroughHardMatchEnabled" + in result[0].status_extended + ) + + def test_both_blocks_disabled(self): + """FAIL when both blocks are disabled on a hybrid tenant.""" + 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}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_directory_sync_object_takeover_blocked.entra_directory_sync_object_takeover_blocked import ( + entra_directory_sync_object_takeover_blocked, + ) + + entra_client.directory_sync_settings = [ + DirectorySyncSettings( + id="sync-001", + block_soft_match_enabled=False, + block_cloud_object_takeover_through_hard_match_enabled=False, + ) + ] + entra_client.directory_sync_error = None + entra_client.organizations = [_hybrid_org()] + + check = entra_directory_sync_object_takeover_blocked() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "blockSoftMatchEnabled" in result[0].status_extended + assert ( + "blockCloudObjectTakeoverThroughHardMatchEnabled" + in result[0].status_extended + ) + + def test_cloud_only_tenant(self): + """PASS when tenant is cloud-only and no sync object is returned.""" + 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}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_directory_sync_object_takeover_blocked.entra_directory_sync_object_takeover_blocked import ( + entra_directory_sync_object_takeover_blocked, + ) + + entra_client.directory_sync_settings = [] + entra_client.directory_sync_error = None + entra_client.organizations = [_cloud_only_org()] + + check = entra_directory_sync_object_takeover_blocked() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "cloud-only" in result[0].status_extended + + def test_cloud_only_tenant_with_sync_object_returned(self): + """PASS for cloud-only tenants even when Graph returns a sync object. + + Microsoft Graph returns an onPremisesSynchronization object (with all + features disabled) for cloud-only tenants. The check must not treat the + disabled flags as a FAIL when on-premises sync is not enabled. + """ + 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}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_directory_sync_object_takeover_blocked.entra_directory_sync_object_takeover_blocked import ( + entra_directory_sync_object_takeover_blocked, + ) + + entra_client.directory_sync_settings = [ + DirectorySyncSettings( + id="tenant-id", + password_sync_enabled=False, + seamless_sso_enabled=False, + block_soft_match_enabled=False, + block_cloud_object_takeover_through_hard_match_enabled=False, + ) + ] + entra_client.directory_sync_error = None + entra_client.organizations = [_cloud_only_org()] + + check = entra_directory_sync_object_takeover_blocked() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "cloud-only" in result[0].status_extended + + def test_permission_error_hybrid(self): + """MANUAL when permissions are insufficient for a hybrid tenant.""" + 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}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_directory_sync_object_takeover_blocked.entra_directory_sync_object_takeover_blocked import ( + entra_directory_sync_object_takeover_blocked, + ) + + entra_client.directory_sync_settings = [] + entra_client.directory_sync_error = "Insufficient privileges" + entra_client.organizations = [_hybrid_org()] + + check = entra_directory_sync_object_takeover_blocked() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "Cannot verify" in result[0].status_extended + assert "Insufficient privileges" in result[0].status_extended + + def test_permission_error_cloud_only(self): + """PASS when settings cannot be read but the tenant is cloud-only.""" + 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}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_directory_sync_object_takeover_blocked.entra_directory_sync_object_takeover_blocked import ( + entra_directory_sync_object_takeover_blocked, + ) + + entra_client.directory_sync_settings = [] + entra_client.directory_sync_error = "Insufficient privileges" + entra_client.organizations = [_cloud_only_org()] + + check = entra_directory_sync_object_takeover_blocked() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "cloud-only" in result[0].status_extended + + def test_hybrid_no_settings_returned(self): + """MANUAL when a hybrid tenant returns no directory sync settings.""" + 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}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_directory_sync_object_takeover_blocked.entra_directory_sync_object_takeover_blocked import ( + entra_directory_sync_object_takeover_blocked, + ) + + entra_client.directory_sync_settings = [] + entra_client.directory_sync_error = None + entra_client.organizations = [_hybrid_org()] + + check = entra_directory_sync_object_takeover_blocked() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + "no directory sync settings were returned" in result[0].status_extended + ) diff --git a/tests/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners_test.py b/tests/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners_test.py new file mode 100644 index 0000000000..e62f9eaa08 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_service_principal_privileged_role_no_owners/entra_service_principal_privileged_role_no_owners_test.py @@ -0,0 +1,299 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.m365.services.entra.entra_service import ServicePrincipal +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +GLOBAL_ADMIN_ROLE = "62e90394-69f5-4237-9190-012177145e10" +PRIV_ROLE_ADMIN = "e8611ab8-c189-46e8-94e1-60213ab1f814" + + +class Test_entra_service_principal_privileged_role_no_owners: + """Tests for the entra_service_principal_privileged_role_no_owners check.""" + + def test_no_service_principals(self): + """No service principals configured: expected 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_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import ( + entra_service_principal_privileged_role_no_owners, + ) + + entra_client.service_principals = {} + + check = entra_service_principal_privileged_role_no_owners() + result = check.execute() + + assert len(result) == 0 + + def test_service_principal_no_tier0_roles(self): + """Service principal without Tier 0 roles: expected PASS.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + sp_id = str(uuid4()) + + 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_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import ( + entra_service_principal_privileged_role_no_owners, + ) + + entra_client.service_principals = { + sp_id: ServicePrincipal( + id=sp_id, + name="NonPrivilegedApp", + app_id=str(uuid4()), + directory_role_template_ids=[], + sp_owner_ids=[], + app_owner_ids=[], + ) + } + + check = entra_service_principal_privileged_role_no_owners() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == sp_id + assert result[0].resource_name == "NonPrivilegedApp" + assert ( + "no permanent Tier 0 directory role assignments" + in result[0].status_extended + ) + + def test_service_principal_tier0_no_owners(self): + """Privileged SP with no owners on SP or app: expected PASS.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + sp_id = str(uuid4()) + + 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_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import ( + entra_service_principal_privileged_role_no_owners, + ) + + entra_client.service_principals = { + sp_id: ServicePrincipal( + id=sp_id, + name="SecureApp", + app_id=str(uuid4()), + directory_role_template_ids=[GLOBAL_ADMIN_ROLE], + sp_owner_ids=[], + app_owner_ids=[], + ) + } + + check = entra_service_principal_privileged_role_no_owners() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == sp_id + assert result[0].resource_name == "SecureApp" + assert "no owners" in result[0].status_extended + + def test_service_principal_tier0_with_sp_owners(self): + """Privileged SP with owners on SP only: expected FAIL.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + sp_id = str(uuid4()) + owner_id = str(uuid4()) + + 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_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import ( + entra_service_principal_privileged_role_no_owners, + ) + + entra_client.service_principals = { + sp_id: ServicePrincipal( + id=sp_id, + name="RiskyApp", + app_id=str(uuid4()), + directory_role_template_ids=[GLOBAL_ADMIN_ROLE], + sp_owner_ids=[owner_id], + app_owner_ids=[], + ) + } + + check = entra_service_principal_privileged_role_no_owners() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == sp_id + assert "1 owner(s)" in result[0].status_extended + assert "1 on the service principal" in result[0].status_extended + assert "0 on the parent app registration" in result[0].status_extended + + def test_service_principal_tier0_with_app_owners(self): + """Privileged SP with owners on parent app only: expected FAIL.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + sp_id = str(uuid4()) + app_owner_id = str(uuid4()) + + 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_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import ( + entra_service_principal_privileged_role_no_owners, + ) + + entra_client.service_principals = { + sp_id: ServicePrincipal( + id=sp_id, + name="AppRegOwnerRisk", + app_id=str(uuid4()), + directory_role_template_ids=[GLOBAL_ADMIN_ROLE], + sp_owner_ids=[], + app_owner_ids=[app_owner_id], + ) + } + + check = entra_service_principal_privileged_role_no_owners() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "1 owner(s)" in result[0].status_extended + assert "0 on the service principal" in result[0].status_extended + assert "1 on the parent app registration" in result[0].status_extended + + def test_service_principal_tier0_with_both_owners(self): + """Privileged SP with distinct owners on both SP and app: expected FAIL.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + sp_id = str(uuid4()) + sp_owner_id = str(uuid4()) + app_owner_id = str(uuid4()) + + 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_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import ( + entra_service_principal_privileged_role_no_owners, + ) + + entra_client.service_principals = { + sp_id: ServicePrincipal( + id=sp_id, + name="HighRiskApp", + app_id=str(uuid4()), + directory_role_template_ids=[GLOBAL_ADMIN_ROLE, PRIV_ROLE_ADMIN], + sp_owner_ids=[sp_owner_id], + app_owner_ids=[app_owner_id], + ) + } + + check = entra_service_principal_privileged_role_no_owners() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "2 permanent Tier 0 directory role(s)" in result[0].status_extended + assert "2 owner(s)" in result[0].status_extended + + def test_service_principal_tier0_same_owner_on_sp_and_app(self): + """Same principal owns both SP and parent app: owner count deduplicated.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + sp_id = str(uuid4()) + shared_owner_id = str(uuid4()) + + 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_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import ( + entra_service_principal_privileged_role_no_owners, + ) + + entra_client.service_principals = { + sp_id: ServicePrincipal( + id=sp_id, + name="DualOwnedApp", + app_id=str(uuid4()), + directory_role_template_ids=[GLOBAL_ADMIN_ROLE], + sp_owner_ids=[shared_owner_id], + app_owner_ids=[shared_owner_id], + ) + } + + check = entra_service_principal_privileged_role_no_owners() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "1 owner(s)" in result[0].status_extended + assert "1 on the service principal" in result[0].status_extended + assert "1 on the parent app registration" in result[0].status_extended 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 8bf0c88f77..86e8e38f22 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 @@ -1,3 +1,4 @@ +from datetime import datetime, timedelta, timezone from unittest import mock from uuid import uuid4 @@ -326,6 +327,133 @@ class Test_entra_users_mfa_capable: assert len(result) == 0 + def test_future_hire_member_user_not_checked(self): + """Future-hire member user is not active yet: expected no results.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + entra_client.user_registration_details_error = 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_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, + ) + + user_id = str(uuid4()) + entra_client.users = { + user_id: User( + id=user_id, + name="Future Hire", + on_premises_sync_enabled=False, + directory_roles_ids=[], + is_mfa_capable=False, + account_enabled=True, + user_type="Member", + employee_hire_date=datetime.now(timezone.utc) + timedelta(days=1), + ) + } + + check = entra_users_mfa_capable() + result = check.execute() + + assert len(result) == 0 + + def test_naive_future_hire_member_user_not_checked(self): + """Naive future-hire datetimes are treated as UTC and skipped.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + entra_client.user_registration_details_error = 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_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, + ) + + user_id = str(uuid4()) + entra_client.users = { + user_id: User( + id=user_id, + name="Future Hire", + on_premises_sync_enabled=False, + directory_roles_ids=[], + is_mfa_capable=False, + account_enabled=True, + user_type="Member", + employee_hire_date=( + datetime.now(timezone.utc) + timedelta(days=1) + ).replace(tzinfo=None), + ) + } + + check = entra_users_mfa_capable() + result = check.execute() + + assert len(result) == 0 + + def test_current_hire_member_user_is_checked(self): + """Current-hire member user is active now: expected evaluation.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + entra_client.user_registration_details_error = 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_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, + ) + + user_id = str(uuid4()) + entra_client.users = { + user_id: User( + id=user_id, + name="Current Hire", + on_premises_sync_enabled=False, + directory_roles_ids=[], + is_mfa_capable=False, + account_enabled=True, + user_type="Member", + employee_hire_date=datetime.now(timezone.utc), + ) + } + + check = entra_users_mfa_capable() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == "User Current Hire is not MFA capable." + assert result[0].resource == entra_client.users[user_id] + assert result[0].resource_name == "Current Hire" + assert result[0].resource_id == user_id + def test_member_and_guest_users(self): """Mix of member and guest users: only member users should be checked.""" entra_client = mock.MagicMock 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 f2ee0b2c84..ba6247c7bd 100644 --- a/tests/providers/m365/services/entra/microsoft365_entra_service_test.py +++ b/tests/providers/m365/services/entra/microsoft365_entra_service_test.py @@ -411,6 +411,7 @@ class Test_Entra_Service: id="user-1", display_name="User 1", on_premises_sync_enabled=True, + employee_hire_date=datetime(2026, 6, 10, tzinfo=timezone.utc), ), SimpleNamespace( id="user-2", @@ -535,6 +536,7 @@ class Test_Entra_Service: "userType", "accountEnabled", "onPremisesSyncEnabled", + "employeeHireDate", } with_url_mock.assert_called_once_with("next-link") assert users["user-1"].directory_roles_ids == ["role-template-1"] @@ -548,6 +550,9 @@ class Test_Entra_Service: assert users["user-1"].authentication_methods == ["fido2SecurityKey"] assert users["user-6"].authentication_methods == ["mobilePhone"] assert users["user-2"].authentication_methods == [] + assert users["user-1"].employee_hire_date == datetime( + 2026, 6, 10, tzinfo=timezone.utc + ) def test__get_users_uses_graph_account_enabled_for_disabled_guests(self): """Regression test for https://github.com/prowler-cloud/prowler/issues/10921. @@ -873,3 +878,189 @@ class Test_Entra_Service: assert merged.password_credentials[0].key_id == "cred-app" assert merged.password_credentials[0].display_name == "app-level-secret" assert merged.password_credentials[0].is_active() + + def test__resolve_identifiers_for_type_flags_only_404(self): + """Only HTTP 404 / Request_ResourceNotFound mark an id as deleted. + + Transient errors (5xx, throttling) and successful resolutions must + never be added to the unresolved set — that is the contract the check + relies on to avoid false positives during Graph outages. + """ + from msgraph.generated.models.o_data_errors.main_error import MainError + from msgraph.generated.models.o_data_errors.o_data_error import ODataError + + deleted_by_status = "deleted-status-404" + deleted_by_code = "deleted-code-rnf" + transient = "transient-503" + live = "live-user" + + error_404 = ODataError() + error_404.response_status_code = 404 + error_404.error = None # status code alone is enough + + error_rnf = ODataError() + error_rnf.response_status_code = None + error_rnf.error = MainError() + error_rnf.error.code = "Request_ResourceNotFound" + + error_503 = ODataError() + error_503.response_status_code = 503 + error_503.error = MainError() + error_503.error.code = "ServiceUnavailable" + + user_builders = { + deleted_by_status: SimpleNamespace(get=AsyncMock(side_effect=error_404)), + deleted_by_code: SimpleNamespace(get=AsyncMock(side_effect=error_rnf)), + transient: SimpleNamespace(get=AsyncMock(side_effect=error_503)), + live: SimpleNamespace(get=AsyncMock(return_value=SimpleNamespace(id=live))), + } + + entra_service = Entra.__new__(Entra) + entra_service.client = SimpleNamespace( + users=SimpleNamespace( + by_user_id=MagicMock(side_effect=lambda uid: user_builders[uid]) + ) + ) + + unresolved = set() + errored = set() + asyncio.run( + entra_service._resolve_identifiers_for_type( + "user", set(user_builders), unresolved, errored + ) + ) + + assert unresolved == { + ("user", deleted_by_status), + ("user", deleted_by_code), + } + # The transient 503 must be recorded as errored (unverified), never as + # deleted and never silently dropped. + assert errored == {("user", transient)} + + def test__resolve_identifiers_for_type_role_uses_role_definitions_endpoint(self): + """A deleted role is resolved against the roleDefinitions endpoint.""" + from msgraph.generated.models.o_data_errors.o_data_error import ODataError + + deleted_role = "deleted-role-id" + + error_404 = ODataError() + error_404.response_status_code = 404 + error_404.error = None + + by_role_id = MagicMock( + return_value=SimpleNamespace(get=AsyncMock(side_effect=error_404)) + ) + + entra_service = Entra.__new__(Entra) + entra_service.client = SimpleNamespace( + role_management=SimpleNamespace( + directory=SimpleNamespace( + role_definitions=SimpleNamespace( + by_unified_role_definition_id=by_role_id + ) + ) + ) + ) + + unresolved = set() + errored = set() + asyncio.run( + entra_service._resolve_identifiers_for_type( + "role", {deleted_role}, unresolved, errored + ) + ) + + assert unresolved == {("role", deleted_role)} + assert errored == set() + by_role_id.assert_called_once_with(deleted_role) + + def test__resolve_directory_object_references_skips_sentinels_and_dedups(self): + """End-to-end resolver: sentinels are never queried, ids are deduped + across policies, and only deleted ids land in the unresolved set.""" + from msgraph.generated.models.o_data_errors.o_data_error import ODataError + + deleted_user = "deleted-user-id" + live_user = "live-user-id" + deleted_group = "deleted-group-id" + errored_group = "errored-group-id" + + def _user_conditions(**kwargs): + base = { + "included_users": [], + "excluded_users": [], + "included_groups": [], + "excluded_groups": [], + "included_roles": [], + "excluded_roles": [], + } + base.update(kwargs) + return SimpleNamespace(**base) + + def _policy(user_conditions): + return SimpleNamespace( + conditions=SimpleNamespace(user_conditions=user_conditions) + ) + + policies = { + "policy-a": _policy( + _user_conditions( + included_users=["All", deleted_user, live_user], + excluded_groups=[deleted_group, errored_group], + ) + ), + # Same deleted_user referenced again — must be resolved only once. + "policy-b": _policy( + _user_conditions( + included_users=[deleted_user], + excluded_users=["GuestsOrExternalUsers"], + ) + ), + # Policy without user conditions must be skipped without error. + "policy-c": SimpleNamespace( + conditions=SimpleNamespace(user_conditions=None) + ), + } + + error_404 = ODataError() + error_404.response_status_code = 404 + error_404.error = None + + error_503 = ODataError() + error_503.response_status_code = 503 + error_503.error = None + + user_builders = { + deleted_user: SimpleNamespace(get=AsyncMock(side_effect=error_404)), + live_user: SimpleNamespace( + get=AsyncMock(return_value=SimpleNamespace(id=live_user)) + ), + } + group_builders = { + deleted_group: SimpleNamespace(get=AsyncMock(side_effect=error_404)), + errored_group: SimpleNamespace(get=AsyncMock(side_effect=error_503)), + } + by_user_id = MagicMock(side_effect=lambda uid: user_builders[uid]) + by_group_id = MagicMock(side_effect=lambda gid: group_builders[gid]) + + entra_service = Entra.__new__(Entra) + entra_service.client = SimpleNamespace( + users=SimpleNamespace(by_user_id=by_user_id), + groups=SimpleNamespace(by_group_id=by_group_id), + ) + + unresolved, errored = asyncio.run( + entra_service._resolve_directory_object_references(policies) + ) + + assert unresolved == { + ("user", deleted_user), + ("group", deleted_group), + } + # The 503 group is unverified, not deleted — it lands in errored. + assert errored == {("group", errored_group)} + # Sentinels are filtered before any Graph call; only the two real user + # ids are queried, and the deduped deleted_user is queried exactly once. + queried_users = {call.args[0] for call in by_user_id.call_args_list} + assert queried_users == {deleted_user, live_user} + assert user_builders[deleted_user].get.await_count == 1 diff --git a/tests/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain_test.py b/tests/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain_test.py new file mode 100644 index 0000000000..9ec073a922 --- /dev/null +++ b/tests/providers/m365/services/exchange/exchange_mailbox_primary_smtp_uses_custom_domain/exchange_mailbox_primary_smtp_uses_custom_domain_test.py @@ -0,0 +1,300 @@ +from unittest import mock + +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + + +class Test_exchange_mailbox_primary_smtp_uses_custom_domain: + + def test_powershell_unavailable_manual(self): + """MANUAL: Exchange Online PowerShell unavailable (mailboxes is None).""" + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_client.audited_domain = DOMAIN + exchange_client.mailboxes = None + + 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_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_client", + new=exchange_client, + ), + ): + from prowler.providers.m365.services.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain import ( + exchange_mailbox_primary_smtp_uses_custom_domain, + ) + + check = exchange_mailbox_primary_smtp_uses_custom_domain() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "PowerShell" in result[0].status_extended + assert result[0].resource_name == "Exchange Online Mailboxes" + assert result[0].resource_id == "exchange_mailboxes" + + def test_empty_tenant_no_findings(self): + """Empty tenant (no mailboxes) produces zero findings, not MANUAL.""" + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_client.audited_domain = DOMAIN + exchange_client.mailboxes = [] + + 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_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_client", + new=exchange_client, + ), + ): + from prowler.providers.m365.services.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain import ( + exchange_mailbox_primary_smtp_uses_custom_domain, + ) + + check = exchange_mailbox_primary_smtp_uses_custom_domain() + result = check.execute() + + assert result == [] + + def test_custom_domain_passes(self): + """PASS: Mailbox primary SMTP uses a custom domain.""" + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_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.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_client", + new=exchange_client, + ), + ): + from prowler.providers.m365.services.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain import ( + exchange_mailbox_primary_smtp_uses_custom_domain, + ) + from prowler.providers.m365.services.exchange.exchange_service import ( + Mailbox, + ) + + exchange_client.mailboxes = [ + Mailbox( + identity="user1@contoso.com", + name="User One", + primary_smtp_address="user1@contoso.com", + recipient_type_details="UserMailbox", + ) + ] + + check = exchange_mailbox_primary_smtp_uses_custom_domain() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "custom domain" in result[0].status_extended + assert result[0].resource_name == "User One" + assert result[0].resource_id == "user1@contoso.com" + assert result[0].location == "global" + + def test_onmicrosoft_domain_fails(self): + """FAIL: Mailbox primary SMTP uses .onmicrosoft.com.""" + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_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.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_client", + new=exchange_client, + ), + ): + from prowler.providers.m365.services.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain import ( + exchange_mailbox_primary_smtp_uses_custom_domain, + ) + from prowler.providers.m365.services.exchange.exchange_service import ( + Mailbox, + ) + + exchange_client.mailboxes = [ + Mailbox( + identity="user1@contoso.onmicrosoft.com", + name="User One", + primary_smtp_address="user1@contoso.onmicrosoft.com", + recipient_type_details="UserMailbox", + ) + ] + + check = exchange_mailbox_primary_smtp_uses_custom_domain() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ".onmicrosoft.com" in result[0].status_extended + assert result[0].resource_name == "User One" + assert result[0].resource_id == "user1@contoso.onmicrosoft.com" + assert result[0].location == "global" + + def test_mixed_mailboxes(self): + """Test multiple mailboxes with mixed domain status.""" + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_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.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_client", + new=exchange_client, + ), + ): + from prowler.providers.m365.services.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain import ( + exchange_mailbox_primary_smtp_uses_custom_domain, + ) + from prowler.providers.m365.services.exchange.exchange_service import ( + Mailbox, + ) + + exchange_client.mailboxes = [ + Mailbox( + identity="user1@contoso.com", + name="User One", + primary_smtp_address="user1@contoso.com", + recipient_type_details="UserMailbox", + ), + Mailbox( + identity="shared@contoso.onmicrosoft.com", + name="Shared Mailbox", + primary_smtp_address="shared@contoso.onmicrosoft.com", + recipient_type_details="SharedMailbox", + ), + ] + + check = exchange_mailbox_primary_smtp_uses_custom_domain() + result = check.execute() + + assert len(result) == 2 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Mailbox user1@contoso.com (UserMailbox) has primary SMTP address user1@contoso.com using a custom domain." + ) + assert result[1].status == "FAIL" + assert ( + result[1].status_extended + == "Mailbox shared@contoso.onmicrosoft.com (SharedMailbox) has primary SMTP address shared@contoso.onmicrosoft.com using the .onmicrosoft.com domain instead of a custom domain." + ) + + def test_room_mailbox_custom_domain(self): + """PASS: Room mailbox using a custom domain.""" + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_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.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_client", + new=exchange_client, + ), + ): + from prowler.providers.m365.services.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain import ( + exchange_mailbox_primary_smtp_uses_custom_domain, + ) + from prowler.providers.m365.services.exchange.exchange_service import ( + Mailbox, + ) + + exchange_client.mailboxes = [ + Mailbox( + identity="boardroom@contoso.com", + name="Board Room", + primary_smtp_address="boardroom@contoso.com", + recipient_type_details="RoomMailbox", + ) + ] + + check = exchange_mailbox_primary_smtp_uses_custom_domain() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "boardroom@contoso.com" + assert result[0].location == "global" + + def test_equipment_mailbox_onmicrosoft(self): + """FAIL: Equipment mailbox using .onmicrosoft.com.""" + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_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.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_client", + new=exchange_client, + ), + ): + from prowler.providers.m365.services.exchange.exchange_mailbox_primary_smtp_uses_custom_domain.exchange_mailbox_primary_smtp_uses_custom_domain import ( + exchange_mailbox_primary_smtp_uses_custom_domain, + ) + from prowler.providers.m365.services.exchange.exchange_service import ( + Mailbox, + ) + + exchange_client.mailboxes = [ + Mailbox( + identity="projector@contoso.onmicrosoft.com", + name="Projector", + primary_smtp_address="projector@contoso.onmicrosoft.com", + recipient_type_details="EquipmentMailbox", + ) + ] + + check = exchange_mailbox_primary_smtp_uses_custom_domain() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "projector@contoso.onmicrosoft.com" + assert result[0].location == "global" diff --git a/tests/providers/m365/services/exchange/exchange_service_test.py b/tests/providers/m365/services/exchange/exchange_service_test.py index 8eed77ca99..8d812e3bb3 100644 --- a/tests/providers/m365/services/exchange/exchange_service_test.py +++ b/tests/providers/m365/services/exchange/exchange_service_test.py @@ -495,6 +495,104 @@ class Test_Exchange_Service: exchange_client.powershell.close() + @patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.get_mailboxes", + return_value=[ + { + "Identity": "user1@contoso.com", + "DisplayName": "User One", + "PrimarySmtpAddress": "user1@contoso.com", + "RecipientTypeDetails": "UserMailbox", + }, + { + "Identity": "room@contoso.com", + "DisplayName": "Boardroom", + "PrimarySmtpAddress": "room@contoso.com", + "RecipientTypeDetails": "RoomMailbox", + }, + { + "Identity": "DiscoverySearchMailbox{D919BA05}", + "DisplayName": "Discovery Search Mailbox", + "PrimarySmtpAddress": "DiscoverySearchMailbox@contoso.onmicrosoft.com", + "RecipientTypeDetails": "DiscoveryMailbox", + }, + { + "Identity": "SystemMailbox{1f05a927}", + "DisplayName": "Microsoft Exchange", + "PrimarySmtpAddress": "SystemMailbox@contoso.onmicrosoft.com", + "RecipientTypeDetails": "SystemMailbox", + }, + ], + ) + def test_get_mailboxes_excludes_system_types(self, _mock_get_mailboxes): + with ( + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online", + return_value=True, + ), + ): + exchange_client = Exchange( + set_mocked_m365_provider( + identity=M365IdentityInfo(tenant_domain=DOMAIN) + ) + ) + mailboxes = exchange_client.mailboxes + assert mailboxes is not None + assert len(mailboxes) == 2 + identities = {m.identity for m in mailboxes} + assert identities == {"user1@contoso.com", "room@contoso.com"} + assert all( + m.recipient_type_details not in {"DiscoveryMailbox", "SystemMailbox"} + for m in mailboxes + ) + exchange_client.powershell.close() + + @patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.get_mailboxes", + return_value={ + "Identity": "user1@contoso.com", + "DisplayName": "User One", + "PrimarySmtpAddress": "user1@contoso.com", + "RecipientTypeDetails": "UserMailbox", + }, + ) + def test_get_mailboxes_single_dict(self, _mock_get_mailboxes): + with ( + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online", + return_value=True, + ), + ): + exchange_client = Exchange( + set_mocked_m365_provider( + identity=M365IdentityInfo(tenant_domain=DOMAIN) + ) + ) + mailboxes = exchange_client.mailboxes + assert mailboxes is not None + assert len(mailboxes) == 1 + assert mailboxes[0].identity == "user1@contoso.com" + exchange_client.powershell.close() + + @patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.get_mailboxes", + side_effect=Exception("Get-EXOMailbox failed"), + ) + def test_get_mailboxes_returns_none_on_exception(self, _mock_get_mailboxes): + with ( + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online", + return_value=True, + ), + ): + exchange_client = Exchange( + set_mocked_m365_provider( + identity=M365IdentityInfo(tenant_domain=DOMAIN) + ) + ) + assert exchange_client.mailboxes is None + exchange_client.powershell.close() + def test_get_total_paid_licenses_none(self): with ( mock.patch( diff --git a/tests/providers/okta/lib/service/pagination_test.py b/tests/providers/okta/lib/service/pagination_test.py new file mode 100644 index 0000000000..a14baeb51e --- /dev/null +++ b/tests/providers/okta/lib/service/pagination_test.py @@ -0,0 +1,147 @@ +"""Tests for the shared Okta pagination helpers in +`prowler.providers.okta.lib.service.pagination`. + +Covers `next_after_cursor` (extracts the `after` query param from an +RFC 5988 `Link: rel="next"` header) and `paginate` (drains all pages +of an SDK list call by following the cursor). + +These tests were carved out of `network_zone_service_test.py` when its +local pagination helpers were replaced by the shared module — they now +cover code that six Okta services depend on. +""" + +import asyncio +from types import SimpleNamespace + +from prowler.providers.okta.lib.service.pagination import ( + next_after_cursor, + paginate, +) + + +def _run(coro): + return asyncio.run(coro) + + +def _resp(headers: dict = None): + return SimpleNamespace(headers=headers or {}) + + +class Test_next_after_cursor: + """Behaviours previously covered in `network_zone_service_test.py` + under `Test_network_zone_pagination` — relocated here when the + local helper was replaced by the shared module. + """ + + def test_returns_none_when_response_is_none(self): + assert next_after_cursor(None) is None + + def test_returns_none_when_no_link_header(self): + assert next_after_cursor(_resp({})) is None + + def test_extracts_next_after_cursor(self): + link = ( + '; rel="self", ' + '; rel="next"' + ) + assert next_after_cursor(_resp({"Link": link})) == "next-page" + + def test_reads_lowercase_link_header(self): + # aiohttp's `CIMultiDict` is case-insensitive in practice, but + # callers occasionally pass a dict, so we check both spellings. + link = '; rel="next"' + assert next_after_cursor(_resp({"link": link})) == "cursor-1" + + def test_next_link_without_after_query_returns_none(self): + link = ( + '; rel="self", ' + '; rel="next"' + ) + assert next_after_cursor(_resp({"Link": link})) is None + + def test_no_next_segment_returns_none(self): + link = '; rel="self"' + assert next_after_cursor(_resp({"Link": link})) is None + + def test_url_decodes_after_cursor(self): + # `parse_qs` decodes percent-encoded values — opaque cursors with + # `=` or `+` must round-trip through callers that re-encode. + link = ( + "; " 'rel="next"' + ) + assert next_after_cursor(_resp({"Link": link})) == "cursor=abc+1" + + +class Test_paginate: + def test_returns_items_for_single_page_response(self): + async def fetch(_after): + return (["a", "b"], _resp({}), None) + + items, err = _run(paginate(fetch)) + assert items == ["a", "b"] + assert err is None + + def test_drains_multiple_pages(self): + link = '; rel="next"' + seen_cursors: list = [] + + async def fetch(after): + seen_cursors.append(after) + if after is None: + return (["a"], _resp({"link": link}), None) + return (["b"], _resp({}), None) + + items, err = _run(paginate(fetch)) + assert items == ["a", "b"] + assert err is None + assert seen_cursors == [None, "p2"] + + def test_returns_empty_when_first_page_is_empty(self): + async def fetch(_after): + return ([], _resp({}), None) + + items, err = _run(paginate(fetch)) + assert items == [] + assert err is None + + def test_returns_empty_and_error_when_first_page_fails(self): + async def fetch(_after): + return ([], _resp({}), Exception("forbidden")) + + items, err = _run(paginate(fetch)) + assert items == [] + assert str(err) == "forbidden" + + def test_returns_partial_items_when_subsequent_page_errors(self): + # Carved out of `network_zone_service_test.py`'s + # `test_pagination_returns_partial_items_when_second_page_errors`. + link = '; rel="next"' + + async def fetch(after): + if after is None: + return (["page-1"], _resp({"link": link}), None) + return ([], _resp({}), Exception("page failed")) + + items, err = _run(paginate(fetch)) + assert items == ["page-1"] + assert str(err) == "page failed" + + def test_accepts_early_error_two_tuple_shape(self): + # The Okta SDK returns `(items, err)` on request-build failures + # (no response) and `(items, resp, err)` on transport responses. + # `paginate` reads `result[-1]` for err so the 2-tuple shape is + # handled — verify explicitly. + async def fetch(_after): + return ([], Exception("create failed")) + + items, err = _run(paginate(fetch)) + assert items == [] + assert str(err) == "create failed" + + def test_treats_none_items_as_empty_list(self): + async def fetch(_after): + return (None, _resp({}), None) + + items, err = _run(paginate(fetch)) + assert items == [] + assert err is None diff --git a/tests/providers/okta/lib/service/raw_fetch_test.py b/tests/providers/okta/lib/service/raw_fetch_test.py new file mode 100644 index 0000000000..173e78888a --- /dev/null +++ b/tests/providers/okta/lib/service/raw_fetch_test.py @@ -0,0 +1,152 @@ +"""Tests for the raw-JSON HTTP helpers in +`prowler.providers.okta.lib.service.raw_fetch`. + +Covers `get_json` (single-shot) and `get_json_paginated` +(drains list endpoints via the `Link: rel="next"` cursor). +""" + +import asyncio +import json +from unittest import mock + +from prowler.providers.okta.lib.service.raw_fetch import ( + get_json, + get_json_paginated, +) + + +def _run(coro): + return asyncio.run(coro) + + +def _mock_response(headers: dict = None): + r = mock.MagicMock() + r.headers = headers or {} + return r + + +class Test_get_json: + def test_returns_parsed_json_on_success(self): + client = mock.MagicMock() + + async def create(*_a, **_k): + return ({"url": "/x"}, None) + + async def execute(_req): + return (_mock_response(), json.dumps({"hello": "world"}), None) + + client._request_executor.create_request = create + client._request_executor.execute = execute + + assert _run(get_json(client, "/x")) == {"hello": "world"} + + def test_returns_none_on_create_request_error(self): + client = mock.MagicMock() + + async def create(*_a, **_k): + return (None, Exception("boom")) + + client._request_executor.create_request = create + assert _run(get_json(client, "/x")) is None + + def test_returns_none_on_execute_error(self): + client = mock.MagicMock() + + async def create(*_a, **_k): + return ({"url": "/x"}, None) + + async def execute(_req): + return (_mock_response(), None, Exception("boom")) + + client._request_executor.create_request = create + client._request_executor.execute = execute + assert _run(get_json(client, "/x")) is None + + +class Test_get_json_paginated: + def test_drains_all_pages_following_link_rel_next(self): + # Two pages: first carries `Link: <…?after=cur1>; rel="next"`, + # second has no `next`, so iteration stops. + client = mock.MagicMock() + + page1 = [{"id": "a"}, {"id": "b"}] + page2 = [{"id": "c"}] + page1_headers = { + "link": '; rel="next"' + } + + seen_urls = [] + + async def create(**kwargs): + seen_urls.append(kwargs["url"]) + return ({"url": kwargs["url"]}, None) + + async def execute(request): + if "after=cur1" in request["url"]: + return (_mock_response({}), json.dumps(page2), None) + return (_mock_response(page1_headers), json.dumps(page1), None) + + client._request_executor.create_request = create + client._request_executor.execute = execute + + items = _run(get_json_paginated(client, "/api/v1/items", page_size=2)) + + assert items == [{"id": "a"}, {"id": "b"}, {"id": "c"}] + assert len(seen_urls) == 2 + assert "limit=2" in seen_urls[0] + # The cursor was carried into the second request. + assert "after=cur1" in seen_urls[1] + assert "limit=2" in seen_urls[1] + + def test_single_page_terminates_immediately(self): + client = mock.MagicMock() + + async def create(**kwargs): + return ({"url": kwargs["url"]}, None) + + async def execute(_req): + return (_mock_response({}), json.dumps([{"id": "only"}]), None) + + client._request_executor.create_request = create + client._request_executor.execute = execute + + assert _run(get_json_paginated(client, "/api/v1/items")) == [{"id": "only"}] + + def test_returns_none_when_response_is_not_a_list(self): + client = mock.MagicMock() + + async def create(**kwargs): + return ({"url": kwargs["url"]}, None) + + async def execute(_req): + return (_mock_response({}), json.dumps({"error": "nope"}), None) + + client._request_executor.create_request = create + client._request_executor.execute = execute + + assert _run(get_json_paginated(client, "/api/v1/items")) is None + + def test_preserves_existing_query_string_and_overrides_limit(self): + # Caller already passes `type=USER_LIFECYCLE` — pagination must + # merge `limit` without clobbering existing params. + client = mock.MagicMock() + seen = [] + + async def create(**kwargs): + seen.append(kwargs["url"]) + return ({"url": kwargs["url"]}, None) + + async def execute(_req): + return (_mock_response({}), "[]", None) + + client._request_executor.create_request = create + client._request_executor.execute = execute + + _run( + get_json_paginated( + client, "/api/v1/policies?type=USER_LIFECYCLE", page_size=50 + ) + ) + + assert "type=USER_LIFECYCLE" in seen[0] + assert "limit=50" in seen[0] diff --git a/tests/providers/okta/okta_fixtures.py b/tests/providers/okta/okta_fixtures.py index 23d770cf88..5c3d0e43c3 100644 --- a/tests/providers/okta/okta_fixtures.py +++ b/tests/providers/okta/okta_fixtures.py @@ -16,7 +16,18 @@ def set_mocked_okta_provider( session = OktaSession( org_domain=OKTA_ORG_DOMAIN, client_id=OKTA_CLIENT_ID, - scopes=["okta.policies.read", "okta.brands.read", "okta.apps.read"], + 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", + "okta.idps.read", + ], private_key=OKTA_PRIVATE_KEY, ) if identity is None: @@ -27,6 +38,13 @@ def set_mocked_okta_provider( "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", + "okta.idps.read", ], ) diff --git a/tests/providers/okta/services/api_token/api_token_fixtures.py b/tests/providers/okta/services/api_token/api_token_fixtures.py new file mode 100644 index 0000000000..63a67c1605 --- /dev/null +++ b/tests/providers/okta/services/api_token/api_token_fixtures.py @@ -0,0 +1,46 @@ +from unittest import mock + +from prowler.providers.okta.services.apitoken.api_token_service import OktaApiToken +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def build_api_token_client( + tokens: dict = None, + known_network_zone_ids: set[str] = None, + missing_scope: dict = None, +): + client = mock.MagicMock() + client.api_tokens = tokens or {} + client.known_network_zone_ids = known_network_zone_ids or {"nzo-corp"} + client.missing_scope = missing_scope or { + "api_tokens": None, + "network_zones": None, + "user_roles": None, + "user_groups": None, + } + client.provider = set_mocked_okta_provider() + return client + + +def api_token( + token_id: str = "00Tabcdefg1234567890", + name: str = "CI token", + *, + user_id: str = "00uabcdefg1234567890", + network_connection: str = "ZONE", + network_includes: list[str] = None, + network_excludes: list[str] = None, + owner_roles: list[str] = None, +): + return OktaApiToken( + id=token_id, + name=name, + client_name="Okta API", + user_id=user_id, + network_connection=network_connection, + network_includes=( + network_includes if network_includes is not None else ["nzo-corp"] + ), + network_excludes=network_excludes or [], + owner_roles=owner_roles or ["READ_ONLY_ADMIN"], + ) diff --git a/tests/providers/okta/services/api_token/api_token_service_test.py b/tests/providers/okta/services/api_token/api_token_service_test.py new file mode 100644 index 0000000000..0958bdaaad --- /dev/null +++ b/tests/providers/okta/services/api_token/api_token_service_test.py @@ -0,0 +1,616 @@ +import json +from types import SimpleNamespace +from unittest import mock + +from prowler.providers.okta.models import OktaIdentityInfo +from prowler.providers.okta.services.apitoken.api_token_service import ApiToken +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def _resp(headers: dict = None): + return SimpleNamespace(headers=headers or {}) + + +def _sdk_token( + token_id: str = "00Tabcdefg1234567890", + name: str = "CI token", + *, + user_id: str = "00uabcdefg1234567890", + connection: str = "ZONE", + include: list[str] = None, + exclude: list[str] = None, +): + return SimpleNamespace( + id=token_id, + name=name, + client_name="Okta API", + user_id=user_id, + network=SimpleNamespace( + connection=connection, + include=include if include is not None else ["nzo-corp"], + exclude=exclude or [], + ), + ) + + +def _sdk_role(role_type: str): + return SimpleNamespace(type=role_type, label=role_type.replace("_", " ").title()) + + +def _sdk_role_wrapped(role_type: str): + """Mimic `ListGroupAssignedRoles200ResponseInner` — a oneOf wrapper + holding the real StandardRole on `.actual_instance`. The Okta SDK + actually returns this shape; treating it like the bare role yields + `type=None, label=None` and the role silently vanishes from the + check. + """ + inner = _sdk_role(role_type) + return SimpleNamespace(actual_instance=inner, type=None, label=None) + + +def _sdk_zone(zone_id: str, name: str): + return SimpleNamespace(id=zone_id, name=name) + + +def _sdk_group(group_id: str): + return SimpleNamespace(id=group_id) + + +async def _empty_list(*_a, **_k): + return ([], _resp({}), None) + + +class Test_ApiToken_service: + def test_fetches_tokens_roles_and_known_network_zones(self): + provider = set_mocked_okta_provider() + token = _sdk_token() + + async def fake_list_api_tokens(*_a, **_k): + return ([token], _resp({}), None) + + async def fake_list_assigned_roles_for_user(user_id, *_a, **_k): + assert user_id == token.user_id + return ([_sdk_role("READ_ONLY_ADMIN")], _resp({}), None) + + async def fake_list_network_zones(*_a, **_k): + return ([_sdk_zone("nzo-corp", "Corporate")], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fake_list_assigned_roles_for_user + mocked.list_user_groups = _empty_list + mocked.list_group_assigned_roles = _empty_list + mocked.list_network_zones = fake_list_network_zones + mocked_client_cls.return_value = mocked + + service = ApiToken(provider) + + assert set(service.api_tokens.keys()) == {token.id} + assert service.api_tokens[token.id].network_connection == "ZONE" + assert service.api_tokens[token.id].owner_roles == ["READ_ONLY_ADMIN"] + assert service.known_network_zone_ids == {"nzo-corp", "Corporate"} + + def test_role_fetch_error_keeps_token_with_empty_roles(self): + provider = set_mocked_okta_provider() + token = _sdk_token() + + async def fake_list_api_tokens(*_a, **_k): + return ([token], _resp({}), None) + + async def fake_roles_error(*_a, **_k): + return ([], _resp({}), Exception("forbidden")) + + async def fake_list_network_zones(*_a, **_k): + return ([], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fake_roles_error + mocked.list_user_groups = _empty_list + mocked.list_group_assigned_roles = _empty_list + mocked.list_network_zones = fake_list_network_zones + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.api_tokens[token.id].owner_roles == [] + + def test_falls_back_to_raw_roles_when_sdk_role_is_empty(self): + provider = set_mocked_okta_provider() + token = _sdk_token() + + async def fake_list_api_tokens(*_a, **_k): + return ([token], _resp({}), None) + + async def fake_list_assigned_roles_for_user(user_id, *_a, **_k): + assert user_id == token.user_id + return ([SimpleNamespace(type=None, label=None)], _resp({}), None) + + async def fake_create_request(*_a, **_k): + return ("raw-role-request", None) + + async def fake_execute(request, *_a, **_k): + assert request == "raw-role-request" + return ( + _resp({}), + json.dumps( + [ + { + "id": "ra-super-admin", + "type": "SUPER_ADMIN", + "label": "Super Administrator", + } + ] + ), + None, + ) + + async def fake_list_network_zones(*_a, **_k): + return ([_sdk_zone("nzo-corp", "Corporate")], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fake_list_assigned_roles_for_user + mocked._list_assigned_roles_for_user_serialize.return_value = ( + "GET", + "/api/v1/users/00uabcdefg1234567890/roles", + {}, + None, + None, + ) + mocked._request_executor.create_request = fake_create_request + mocked._request_executor.execute = fake_execute + mocked.list_network_zones = fake_list_network_zones + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.api_tokens[token.id].owner_roles == ["SUPER_ADMIN"] + + def test_paginates_known_network_zones_for_token_validation(self): + provider = set_mocked_okta_provider() + token = _sdk_token(include=["nzo-page-2"]) + next_link = '; rel="next"' + + async def fake_list_api_tokens(*_a, **_k): + return ([token], _resp({}), None) + + async def fake_list_assigned_roles_for_user(*_a, **_k): + return ([_sdk_role("READ_ONLY_ADMIN")], _resp({}), None) + + async def fake_list_network_zones(*_a, **kwargs): + if kwargs.get("after") is None: + return ( + [_sdk_zone("nzo-page-1", "First")], + _resp({"link": next_link}), + None, + ) + return ([_sdk_zone("nzo-page-2", "Second")], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fake_list_assigned_roles_for_user + mocked.list_user_groups = _empty_list + mocked.list_group_assigned_roles = _empty_list + mocked.list_network_zones = fake_list_network_zones + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.known_network_zone_ids == { + "nzo-page-1", + "First", + "nzo-page-2", + "Second", + } + + def test_falls_back_to_raw_network_zones_when_sdk_listing_fails(self): + provider = set_mocked_okta_provider() + token = _sdk_token(include=["nzo-raw"]) + + async def fake_list_api_tokens(*_a, **_k): + return ([token], _resp({}), None) + + async def fake_list_assigned_roles_for_user(*_a, **_k): + return ([_sdk_role("READ_ONLY_ADMIN")], _resp({}), None) + + async def fake_list_network_zones(*_a, **_k): + raise ValueError("EnhancedDynamicNetworkZone SDK deserialization failed") + + async def fake_create_request(*_a, **_k): + return ("raw-zones-request", None) + + async def fake_execute(request, *_a, **_k): + assert request == "raw-zones-request" + return ( + _resp({}), + json.dumps([{"id": "nzo-raw", "name": "Raw Corporate"}]), + None, + ) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fake_list_assigned_roles_for_user + mocked.list_network_zones = fake_list_network_zones + mocked._list_network_zones_serialize.return_value = ( + "GET", + "/api/v1/zones", + {}, + None, + None, + ) + mocked._request_executor.create_request = fake_create_request + mocked._request_executor.execute = fake_execute + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.known_network_zone_ids == {"nzo-raw", "Raw Corporate"} + + def test_returns_empty_on_token_api_error(self): + provider = set_mocked_okta_provider() + + async def failing(*_a, **_k): + return ([], _resp({}), Exception("forbidden")) + + async def fake_list_network_zones(*_a, **_k): + return ([], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = failing + mocked.list_network_zones = fake_list_network_zones + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.api_tokens == {} + + def test_missing_api_token_scope_skips_dependent_api_calls(self): + provider = set_mocked_okta_provider( + identity=OktaIdentityInfo( + org_domain="acme.okta.com", + client_id="0oa1234567890abcdef", + granted_scopes=["okta.networkZones.read", "okta.roles.read"], + ) + ) + + async def fail_if_called(*_a, **_k): + raise AssertionError("API calls should not run without apiTokens scope") + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fail_if_called + mocked.list_network_zones = fail_if_called + mocked.list_assigned_roles_for_user = fail_if_called + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.missing_scope["api_tokens"] == "okta.apiTokens.read" + assert service.api_tokens == {} + assert service.known_network_zone_ids == set() + + def test_missing_network_zone_scope_skips_zone_api_call(self): + provider = set_mocked_okta_provider( + identity=OktaIdentityInfo( + org_domain="acme.okta.com", + client_id="0oa1234567890abcdef", + granted_scopes=["okta.apiTokens.read", "okta.roles.read"], + ) + ) + token = _sdk_token() + + async def fake_list_api_tokens(*_a, **_k): + return ([token], _resp({}), None) + + async def fake_list_assigned_roles_for_user(*_a, **_k): + return ([_sdk_role("READ_ONLY_ADMIN")], _resp({}), None) + + async def fail_if_called(*_a, **_k): + raise AssertionError("list_network_zones should not be called") + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fake_list_assigned_roles_for_user + mocked.list_network_zones = fail_if_called + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.missing_scope["network_zones"] == "okta.networkZones.read" + assert service.known_network_zone_ids == set() + assert set(service.api_tokens.keys()) == {token.id} + + def test_missing_role_scope_skips_role_api_call(self): + provider = set_mocked_okta_provider( + identity=OktaIdentityInfo( + org_domain="acme.okta.com", + client_id="0oa1234567890abcdef", + granted_scopes=["okta.apiTokens.read", "okta.networkZones.read"], + ) + ) + token = _sdk_token() + + async def fake_list_api_tokens(*_a, **_k): + return ([token], _resp({}), None) + + async def fail_if_called(*_a, **_k): + raise AssertionError("list_assigned_roles_for_user should not be called") + + async def fake_list_network_zones(*_a, **_k): + return ([_sdk_zone("nzo-corp", "Corporate")], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fail_if_called + mocked.list_network_zones = fake_list_network_zones + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.missing_scope["user_roles"] == "okta.roles.read" + assert service.api_tokens[token.id].owner_roles == [] + + +class Test_ApiToken_service_group_inherited_roles: + """Verifies effective-role resolution combines direct + group-inherited. + + Okta's `/api/v1/users/{userId}/roles` returns only directly-assigned + admin roles. Roles inherited via group membership — the common path + for Super Admin on trial tenants — are invisible to that endpoint. + The service must enumerate the user's groups and combine each + group's role assignments. + """ + + def test_group_inherited_super_admin_surfaces(self): + provider = set_mocked_okta_provider() + token = _sdk_token() + + async def fake_list_api_tokens(*_a, **_k): + return ([token], _resp({}), None) + + async def fake_direct_roles(*_a, **_k): + return ([], _resp({}), None) + + async def fake_user_groups(user_id, *_a, **_k): + assert user_id == token.user_id + return ( + [_sdk_group("0gp-admins"), _sdk_group("0gp-eng")], + _resp({}), + None, + ) + + async def fake_group_roles(group_id, *_a, **_k): + if group_id == "0gp-admins": + return ([_sdk_role("SUPER_ADMIN")], _resp({}), None) + return ([], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fake_direct_roles + mocked.list_user_groups = fake_user_groups + mocked.list_group_assigned_roles = fake_group_roles + mocked.list_network_zones = _empty_list + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.api_tokens[token.id].owner_roles == ["SUPER_ADMIN"] + + def test_direct_plus_group_roles_combined_and_deduped(self): + provider = set_mocked_okta_provider() + token = _sdk_token() + + async def fake_list_api_tokens(*_a, **_k): + return ([token], _resp({}), None) + + async def fake_direct_roles(*_a, **_k): + return ([_sdk_role("READ_ONLY_ADMIN")], _resp({}), None) + + async def fake_user_groups(*_a, **_k): + return ([_sdk_group("0gp-1")], _resp({}), None) + + async def fake_group_roles(*_a, **_k): + # READ_ONLY_ADMIN already comes from the direct path; the + # dedupe should keep a single entry. SUPER_ADMIN is new. + return ( + [_sdk_role("READ_ONLY_ADMIN"), _sdk_role("SUPER_ADMIN")], + _resp({}), + None, + ) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fake_direct_roles + mocked.list_user_groups = fake_user_groups + mocked.list_group_assigned_roles = fake_group_roles + mocked.list_network_zones = _empty_list + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.api_tokens[token.id].owner_roles == [ + "READ_ONLY_ADMIN", + "SUPER_ADMIN", + ] + + def test_role_resolution_cached_per_user_and_group(self): + provider = set_mocked_okta_provider() + token_a = _sdk_token(token_id="00Ttoken-a", user_id="00uowner-1") + token_b = _sdk_token(token_id="00Ttoken-b", user_id="00uowner-1") + token_c = _sdk_token(token_id="00Ttoken-c", user_id="00uowner-2") + + direct_calls: list[str] = [] + groups_calls: list[str] = [] + group_role_calls: list[str] = [] + + async def fake_list_api_tokens(*_a, **_k): + return ([token_a, token_b, token_c], _resp({}), None) + + async def fake_direct_roles(user_id, *_a, **_k): + direct_calls.append(user_id) + return ([], _resp({}), None) + + async def fake_user_groups(user_id, *_a, **_k): + groups_calls.append(user_id) + return ([_sdk_group("0gp-shared")], _resp({}), None) + + async def fake_group_roles(group_id, *_a, **_k): + group_role_calls.append(group_id) + return ([_sdk_role("HELP_DESK_ADMIN")], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fake_direct_roles + mocked.list_user_groups = fake_user_groups + mocked.list_group_assigned_roles = fake_group_roles + mocked.list_network_zones = _empty_list + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + # Owner 00uowner-1 appears twice but is resolved once. + assert sorted(direct_calls) == ["00uowner-1", "00uowner-2"] + assert sorted(groups_calls) == ["00uowner-1", "00uowner-2"] + # Shared group resolved once even though both owners belong to it. + assert group_role_calls == ["0gp-shared"] + for token in (token_a, token_b, token_c): + assert service.api_tokens[token.id].owner_roles == ["HELP_DESK_ADMIN"] + + def test_missing_groups_scope_falls_back_to_direct_only(self): + provider = set_mocked_okta_provider( + identity=OktaIdentityInfo( + org_domain="acme.okta.com", + client_id="0oa1234567890abcdef", + granted_scopes=[ + "okta.apiTokens.read", + "okta.networkZones.read", + "okta.roles.read", + ], + ) + ) + token = _sdk_token() + + async def fake_list_api_tokens(*_a, **_k): + return ([token], _resp({}), None) + + async def fake_direct_roles(*_a, **_k): + return ([_sdk_role("READ_ONLY_ADMIN")], _resp({}), None) + + async def fail_if_called(*_a, **_k): + raise AssertionError( + "list_user_groups must not be called without okta.groups.read" + ) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fake_direct_roles + mocked.list_user_groups = fail_if_called + mocked.list_group_assigned_roles = fail_if_called + mocked.list_network_zones = _empty_list + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.missing_scope["user_groups"] == "okta.groups.read" + assert service.api_tokens[token.id].owner_roles == ["READ_ONLY_ADMIN"] + + def test_wrapped_oneof_role_shape_is_unwrapped(self): + """Regression: the SDK returns each role as a oneOf wrapper with + the real StandardRole on `.actual_instance`. The previous + `_role_to_string` read `.type`/`.label` from the wrapper, got + None back, and produced an empty `owner_roles` — causing a + Super Admin token to silently PASS the check.""" + provider = set_mocked_okta_provider() + token = _sdk_token() + + async def fake_list_api_tokens(*_a, **_k): + return ([token], _resp({}), None) + + async def fake_direct_roles(*_a, **_k): + return ([_sdk_role_wrapped("SUPER_ADMIN")], _resp({}), None) + + async def fake_user_groups(*_a, **_k): + return ([_sdk_group("0gp-extra")], _resp({}), None) + + async def fake_group_roles(*_a, **_k): + return ([_sdk_role_wrapped("APP_ADMIN")], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fake_direct_roles + mocked.list_user_groups = fake_user_groups + mocked.list_group_assigned_roles = fake_group_roles + mocked.list_network_zones = _empty_list + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.api_tokens[token.id].owner_roles == [ + "SUPER_ADMIN", + "APP_ADMIN", + ] + + def test_group_role_fetch_failure_does_not_drop_other_groups(self): + provider = set_mocked_okta_provider() + token = _sdk_token() + + async def fake_list_api_tokens(*_a, **_k): + return ([token], _resp({}), None) + + async def fake_direct_roles(*_a, **_k): + return ([], _resp({}), None) + + async def fake_user_groups(*_a, **_k): + return ( + [_sdk_group("0gp-broken"), _sdk_group("0gp-good")], + _resp({}), + None, + ) + + async def fake_group_roles(group_id, *_a, **_k): + if group_id == "0gp-broken": + raise RuntimeError("upstream parse failure") + return ([_sdk_role("SUPER_ADMIN")], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_api_tokens = fake_list_api_tokens + mocked.list_assigned_roles_for_user = fake_direct_roles + mocked.list_user_groups = fake_user_groups + mocked.list_group_assigned_roles = fake_group_roles + mocked.list_network_zones = _empty_list + mocked_client_cls.return_value = mocked + service = ApiToken(provider) + + assert service.api_tokens[token.id].owner_roles == ["SUPER_ADMIN"] diff --git a/tests/providers/okta/services/api_token/apitoken_not_super_admin/apitoken_not_super_admin_test.py b/tests/providers/okta/services/api_token/apitoken_not_super_admin/apitoken_not_super_admin_test.py new file mode 100644 index 0000000000..6177d4b422 --- /dev/null +++ b/tests/providers/okta/services/api_token/apitoken_not_super_admin/apitoken_not_super_admin_test.py @@ -0,0 +1,99 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.api_token.api_token_fixtures import ( + api_token, + build_api_token_client, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.apitoken." + "apitoken_not_super_admin.apitoken_not_super_admin.api_token_client" +) + + +def _run_check(api_token_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=api_token_client), + ): + from prowler.providers.okta.services.apitoken.apitoken_not_super_admin.apitoken_not_super_admin import ( + apitoken_not_super_admin, + ) + + return apitoken_not_super_admin().execute() + + +class Test_apitoken_not_super_admin: + def test_no_tokens_returns_no_findings(self): + findings = _run_check(build_api_token_client({})) + assert findings == [] + + def test_missing_api_token_scope_is_manual(self): + findings = _run_check( + build_api_token_client( + {}, + missing_scope={"api_tokens": "okta.apiTokens.read"}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "okta.apiTokens.read" in findings[0].status_extended + assert "okta.roles.read" in findings[0].status_extended + assert "okta.groups.read" in findings[0].status_extended + + def test_missing_user_roles_scope_is_manual(self): + token = api_token(owner_roles=[]) + findings = _run_check( + build_api_token_client( + {token.id: token}, + missing_scope={"user_roles": "okta.roles.read"}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert findings[0].resource_id == token.id + assert "okta.roles.read" in findings[0].status_extended + + def test_token_owner_without_super_admin_passes(self): + token = api_token(owner_roles=["READ_ONLY_ADMIN"]) + findings = _run_check(build_api_token_client({token.id: token})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == token.id + + def test_token_owner_with_super_admin_fails(self): + token = api_token(owner_roles=["SUPER_ADMIN"]) + findings = _run_check(build_api_token_client({token.id: token})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "Super Admin" in findings[0].status_extended + + def test_missing_groups_scope_adds_best_effort_caveat_on_pass(self): + token = api_token(owner_roles=["READ_ONLY_ADMIN"]) + findings = _run_check( + build_api_token_client( + {token.id: token}, + missing_scope={"user_groups": "okta.groups.read"}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "Group-inherited roles were not checked" in findings[0].status_extended + assert "okta.groups.read" in findings[0].status_extended + + def test_missing_groups_scope_does_not_caveat_when_owner_is_super_admin(self): + token = api_token(owner_roles=["SUPER_ADMIN"]) + findings = _run_check( + build_api_token_client( + {token.id: token}, + missing_scope={"user_groups": "okta.groups.read"}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "Super Admin" in findings[0].status_extended + assert "Group-inherited" not in findings[0].status_extended diff --git a/tests/providers/okta/services/api_token/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone_test.py b/tests/providers/okta/services/api_token/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone_test.py new file mode 100644 index 0000000000..9da6f92939 --- /dev/null +++ b/tests/providers/okta/services/api_token/apitoken_restricted_to_network_zone/apitoken_restricted_to_network_zone_test.py @@ -0,0 +1,114 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.api_token.api_token_fixtures import ( + api_token, + build_api_token_client, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.apitoken." + "apitoken_restricted_to_network_zone.apitoken_restricted_to_network_zone.api_token_client" +) + + +def _run_check(api_token_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=api_token_client), + ): + from prowler.providers.okta.services.apitoken.apitoken_restricted_to_network_zone.apitoken_restricted_to_network_zone import ( + apitoken_restricted_to_network_zone, + ) + + return apitoken_restricted_to_network_zone().execute() + + +class Test_apitoken_restricted_to_network_zone: + def test_no_tokens_returns_no_findings(self): + findings = _run_check(build_api_token_client({})) + assert findings == [] + + def test_missing_api_token_scope_is_manual(self): + findings = _run_check( + build_api_token_client( + {}, + missing_scope={"api_tokens": "okta.apiTokens.read"}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "okta.apiTokens.read" in findings[0].status_extended + assert "okta.networkZones.read" in findings[0].status_extended + + def test_missing_network_zone_scope_is_manual(self): + token = api_token(network_connection="ZONE", network_includes=["nzo-corp"]) + findings = _run_check( + build_api_token_client( + {token.id: token}, + missing_scope={"network_zones": "okta.networkZones.read"}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert findings[0].resource_id == token.id + assert "okta.networkZones.read" in findings[0].status_extended + + def test_missing_network_zone_scope_still_fails_anywhere_token(self): + token = api_token(network_connection="ANYWHERE", network_includes=[]) + findings = _run_check( + build_api_token_client( + {token.id: token}, + missing_scope={"network_zones": "okta.networkZones.read"}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "from any IP" in findings[0].status_extended + + def test_token_restricted_to_known_network_zone_passes(self): + token = api_token(network_connection="ZONE", network_includes=["nzo-corp"]) + findings = _run_check( + build_api_token_client( + {token.id: token}, known_network_zone_ids={"nzo-corp"} + ) + ) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == token.id + + def test_token_with_only_excluded_network_zone_fails(self): + token = api_token( + network_connection="ZONE", + network_includes=[], + network_excludes=["nzo-blocked"], + ) + findings = _run_check( + build_api_token_client( + {token.id: token}, known_network_zone_ids={"nzo-blocked"} + ) + ) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "does not allowlist" in findings[0].status_extended + + def test_token_open_to_anywhere_fails(self): + token = api_token(network_connection="ANYWHERE", network_includes=[]) + findings = _run_check(build_api_token_client({token.id: token})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "from any IP" in findings[0].status_extended + + def test_token_restricted_to_unknown_zone_fails(self): + token = api_token(network_connection="ZONE", network_includes=["nzo-missing"]) + findings = _run_check( + build_api_token_client( + {token.id: token}, known_network_zone_ids={"nzo-corp"} + ) + ) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "unknown Network Zone" in findings[0].status_extended diff --git a/tests/providers/okta/services/authenticator/authenticator_fixtures.py b/tests/providers/okta/services/authenticator/authenticator_fixtures.py new file mode 100644 index 0000000000..f15603221b --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_fixtures.py @@ -0,0 +1,76 @@ +from unittest import mock + +from prowler.providers.okta.services.authenticator.authenticator_service import ( + OktaAuthenticator, + PasswordPolicy, +) +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def build_authenticator_client( + password_policies: dict = None, + authenticators: dict = None, + missing_scope: dict = None, +): + client = mock.MagicMock() + client.password_policies = password_policies or {} + client.authenticators = authenticators or {} + client.missing_scope = missing_scope or { + "password_policies": None, + "authenticators": None, + } + client.provider = set_mocked_okta_provider() + return client + + +def password_policy( + policy_id: str = "pol-password", + name: str = "Default Password Policy", + *, + status: str = "ACTIVE", + priority: int = 1, + max_attempts: int = 3, + min_length: int = 15, + min_upper_case: int = 1, + min_lower_case: int = 1, + min_number: int = 1, + min_symbol: int = 1, + min_age_minutes: int = 1440, + max_age_days: int = 60, + history_count: int = 5, + common_password_check: bool = True, +): + return PasswordPolicy( + id=policy_id, + name=name, + status=status, + priority=priority, + max_attempts=max_attempts, + min_length=min_length, + min_upper_case=min_upper_case, + min_lower_case=min_lower_case, + min_number=min_number, + min_symbol=min_symbol, + min_age_minutes=min_age_minutes, + max_age_days=max_age_days, + history_count=history_count, + common_password_check=common_password_check, + ) + + +def authenticator( + auth_id: str = "aut-okta-verify", + key: str = "okta_verify", + name: str = "Okta Verify", + *, + status: str = "ACTIVE", + fips: str = "REQUIRED", +): + return OktaAuthenticator( + id=auth_id, + key=key, + name=name, + status=status, + type="app", + fips=fips, + ) diff --git a/tests/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant_test.py b/tests/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant_test.py new file mode 100644 index 0000000000..467dc44611 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_okta_verify_fips_compliant/authenticator_okta_verify_fips_compliant_test.py @@ -0,0 +1,74 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + authenticator, + build_authenticator_client, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_okta_verify_fips_compliant." + "authenticator_okta_verify_fips_compliant.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_okta_verify_fips_compliant.authenticator_okta_verify_fips_compliant import ( + authenticator_okta_verify_fips_compliant, + ) + + return authenticator_okta_verify_fips_compliant().execute() + + +class Test_authenticator_okta_verify_fips_compliant: + def test_okta_verify_fips_required_passes(self): + okta_verify = authenticator(key="okta_verify", fips="REQUIRED") + findings = _run_check( + build_authenticator_client(authenticators={okta_verify.id: okta_verify}) + ) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == okta_verify.id + + def test_okta_verify_without_fips_required_fails(self): + okta_verify = authenticator(key="okta_verify", fips="OPTIONAL") + findings = _run_check( + build_authenticator_client(authenticators={okta_verify.id: okta_verify}) + ) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "FIPS" in findings[0].status_extended + + def test_missing_okta_verify_fails(self): + findings = _run_check(build_authenticator_client(authenticators={})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "Okta Verify authenticator is missing." in findings[0].status_extended + + def test_inactive_okta_verify_surfaces_current_status(self): + okta_verify = authenticator(key="okta_verify", status="INACTIVE") + findings = _run_check( + build_authenticator_client(authenticators={okta_verify.id: okta_verify}) + ) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "INACTIVE" in findings[0].status_extended + + def test_missing_authenticators_scope_is_manual(self): + findings = _run_check( + build_authenticator_client( + authenticators={}, + missing_scope={"authenticators": "okta.authenticators.read"}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "okta.authenticators.read" in findings[0].status_extended diff --git a/tests/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check_test.py b/tests/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check_test.py new file mode 100644 index 0000000000..c0e49deb8c --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_common_password_check/authenticator_password_common_password_check_test.py @@ -0,0 +1,60 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_common_password_check.authenticator_password_common_password_check.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_common_password_check.authenticator_password_common_password_check import ( + authenticator_password_common_password_check, + ) + + return authenticator_password_common_password_check().execute() + + +class Test_authenticator_password_common_password_check: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_missing_password_policies_scope_is_manual(self): + findings = _run_check( + build_authenticator_client( + {}, + missing_scope={"password_policies": "okta.policies.read"}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "okta.policies.read" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(common_password_check=True) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(common_password_check=False) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id diff --git a/tests/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase_test.py b/tests/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase_test.py new file mode 100644 index 0000000000..8483f46552 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_complexity_lowercase/authenticator_password_complexity_lowercase_test.py @@ -0,0 +1,49 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_complexity_lowercase.authenticator_password_complexity_lowercase.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_complexity_lowercase.authenticator_password_complexity_lowercase import ( + authenticator_password_complexity_lowercase, + ) + + return authenticator_password_complexity_lowercase().execute() + + +class Test_authenticator_password_complexity_lowercase: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(min_lower_case=1) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(min_lower_case=0) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id diff --git a/tests/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number_test.py b/tests/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number_test.py new file mode 100644 index 0000000000..cac8f8b444 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_complexity_number/authenticator_password_complexity_number_test.py @@ -0,0 +1,49 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_complexity_number.authenticator_password_complexity_number.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_complexity_number.authenticator_password_complexity_number import ( + authenticator_password_complexity_number, + ) + + return authenticator_password_complexity_number().execute() + + +class Test_authenticator_password_complexity_number: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(min_number=1) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(min_number=0) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id diff --git a/tests/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol_test.py b/tests/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol_test.py new file mode 100644 index 0000000000..93ed18a36d --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_complexity_symbol/authenticator_password_complexity_symbol_test.py @@ -0,0 +1,49 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_complexity_symbol.authenticator_password_complexity_symbol.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_complexity_symbol.authenticator_password_complexity_symbol import ( + authenticator_password_complexity_symbol, + ) + + return authenticator_password_complexity_symbol().execute() + + +class Test_authenticator_password_complexity_symbol: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(min_symbol=1) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(min_symbol=0) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id diff --git a/tests/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase_test.py b/tests/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase_test.py new file mode 100644 index 0000000000..74ab626e52 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_complexity_uppercase/authenticator_password_complexity_uppercase_test.py @@ -0,0 +1,49 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_complexity_uppercase.authenticator_password_complexity_uppercase.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_complexity_uppercase.authenticator_password_complexity_uppercase import ( + authenticator_password_complexity_uppercase, + ) + + return authenticator_password_complexity_uppercase().execute() + + +class Test_authenticator_password_complexity_uppercase: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(min_upper_case=1) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(min_upper_case=0) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id diff --git a/tests/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5_test.py b/tests/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5_test.py new file mode 100644 index 0000000000..e6741c53c2 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_history_5/authenticator_password_history_5_test.py @@ -0,0 +1,49 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_history_5.authenticator_password_history_5.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_history_5.authenticator_password_history_5 import ( + authenticator_password_history_5, + ) + + return authenticator_password_history_5().execute() + + +class Test_authenticator_password_history_5: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(history_count=5) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(history_count=4) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id diff --git a/tests/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3_test.py b/tests/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3_test.py new file mode 100644 index 0000000000..35c7026e11 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_lockout_threshold_3/authenticator_password_lockout_threshold_3_test.py @@ -0,0 +1,49 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_lockout_threshold_3.authenticator_password_lockout_threshold_3.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_lockout_threshold_3.authenticator_password_lockout_threshold_3 import ( + authenticator_password_lockout_threshold_3, + ) + + return authenticator_password_lockout_threshold_3().execute() + + +class Test_authenticator_password_lockout_threshold_3: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(max_attempts=3) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(max_attempts=4) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id diff --git a/tests/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d_test.py b/tests/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d_test.py new file mode 100644 index 0000000000..938b29a290 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_maximum_age_60d/authenticator_password_maximum_age_60d_test.py @@ -0,0 +1,49 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_maximum_age_60d.authenticator_password_maximum_age_60d.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_maximum_age_60d.authenticator_password_maximum_age_60d import ( + authenticator_password_maximum_age_60d, + ) + + return authenticator_password_maximum_age_60d().execute() + + +class Test_authenticator_password_maximum_age_60d: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(max_age_days=60) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(max_age_days=61) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id diff --git a/tests/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h_test.py b/tests/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h_test.py new file mode 100644 index 0000000000..0fbe562330 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_minimum_age_24h/authenticator_password_minimum_age_24h_test.py @@ -0,0 +1,49 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_minimum_age_24h.authenticator_password_minimum_age_24h.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_minimum_age_24h.authenticator_password_minimum_age_24h import ( + authenticator_password_minimum_age_24h, + ) + + return authenticator_password_minimum_age_24h().execute() + + +class Test_authenticator_password_minimum_age_24h: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(min_age_minutes=1440) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(min_age_minutes=1439) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id diff --git a/tests/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15_test.py b/tests/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15_test.py new file mode 100644 index 0000000000..2608c42998 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_password_minimum_length_15/authenticator_password_minimum_length_15_test.py @@ -0,0 +1,62 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + build_authenticator_client, + password_policy, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_password_minimum_length_15.authenticator_password_minimum_length_15.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_password_minimum_length_15.authenticator_password_minimum_length_15 import ( + authenticator_password_minimum_length_15, + ) + + return authenticator_password_minimum_length_15().execute() + + +class Test_authenticator_password_minimum_length_15: + def test_no_active_password_policies_fails(self): + findings = _run_check(build_authenticator_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Password Policies" in findings[0].status_extended + + def test_compliant_password_policy_passes(self): + policy = password_policy(min_length=15) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == policy.id + + def test_non_compliant_password_policy_fails(self): + policy = password_policy(min_length=14) + findings = _run_check(build_authenticator_client({policy.id: policy})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert findings[0].resource_id == policy.id + + def test_multiple_active_policies_emit_one_finding_each(self): + compliant = password_policy(policy_id="pol-good", name="Strict", min_length=15) + weak = password_policy( + policy_id="pol-weak", name="Weak", min_length=8, priority=2 + ) + findings = _run_check( + build_authenticator_client({compliant.id: compliant, weak.id: weak}) + ) + assert len(findings) == 2 + by_name = {finding.resource_name: finding for finding in findings} + assert by_name["Strict"].status == "PASS" + assert by_name["Weak"].status == "FAIL" diff --git a/tests/providers/okta/services/authenticator/authenticator_service_test.py b/tests/providers/okta/services/authenticator/authenticator_service_test.py new file mode 100644 index 0000000000..d66af92ca2 --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_service_test.py @@ -0,0 +1,188 @@ +from types import SimpleNamespace +from unittest import mock + +from prowler.providers.okta.models import OktaIdentityInfo +from prowler.providers.okta.services.authenticator.authenticator_service import ( + Authenticator, + OktaAuthenticator, + PasswordPolicy, +) +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def _resp(headers: dict = None): + return SimpleNamespace(headers=headers or {}) + + +def _sdk_password_policy( + policy_id: str = "pol-password", name: str = "Default", common_exclude=True +): + return SimpleNamespace( + id=policy_id, + name=name, + priority=1, + status="ACTIVE", + system=True, + settings=SimpleNamespace( + password=SimpleNamespace( + lockout=SimpleNamespace(max_attempts=3), + complexity=SimpleNamespace( + min_length=15, + min_upper_case=1, + min_lower_case=1, + min_number=1, + min_symbol=1, + dictionary=SimpleNamespace( + common=SimpleNamespace(exclude=common_exclude) + ), + ), + age=SimpleNamespace( + min_age_minutes=1440, + max_age_days=60, + history_count=5, + ), + ) + ), + ) + + +def _sdk_authenticator( + auth_id: str = "aut-okta-verify", + key: str = "okta_verify", + status: str = "ACTIVE", + fips: str = "REQUIRED", +): + return SimpleNamespace( + id=auth_id, + key=key, + name="Okta Verify" if key == "okta_verify" else "Smart Card IdP", + status=status, + type="app", + settings=SimpleNamespace(compliance=SimpleNamespace(fips=fips)), + ) + + +class Test_Authenticator_service: + def test_fetches_password_policies_and_authenticators(self): + provider = set_mocked_okta_provider() + policy = _sdk_password_policy() + okta_verify = _sdk_authenticator() + + async def fake_list_policies(*_a, **_k): + return ([policy], _resp({}), None) + + async def fake_list_authenticators(*_a, **_k): + return ([okta_verify], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = fake_list_policies + mocked.list_authenticators = fake_list_authenticators + mocked_client_cls.return_value = mocked + + service = Authenticator(provider) + + assert isinstance(service.password_policies[policy.id], PasswordPolicy) + assert service.password_policies[policy.id].min_length == 15 + assert service.password_policies[policy.id].common_password_check is True + assert isinstance(service.authenticators[okta_verify.id], OktaAuthenticator) + assert service.authenticators[okta_verify.id].fips == "REQUIRED" + + def test_common_password_exclude_false_is_not_compliant(self): + provider = set_mocked_okta_provider() + policy = _sdk_password_policy(common_exclude=False) + + async def fake_list_policies(*_a, **_k): + return ([policy], _resp({}), None) + + async def fake_list_authenticators(*_a, **_k): + return ([], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = fake_list_policies + mocked.list_authenticators = fake_list_authenticators + mocked_client_cls.return_value = mocked + service = Authenticator(provider) + + assert service.password_policies[policy.id].common_password_check is False + + def test_paginates_password_policies(self): + provider = set_mocked_okta_provider() + page_1 = _sdk_password_policy("pol-1", "First") + page_2 = _sdk_password_policy("pol-2", "Second") + quote = chr(34) + next_link = ( + "; " + f"rel={quote}next{quote}" + ) + calls = [] + + async def fake_list_policies(*_a, **kwargs): + calls.append(kwargs.get("after")) + if kwargs.get("after") is None: + return ([page_1], _resp({"link": next_link}), None) + return ([page_2], _resp({}), None) + + async def fake_list_authenticators(*_a, **_k): + return ([], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = fake_list_policies + mocked.list_authenticators = fake_list_authenticators + mocked_client_cls.return_value = mocked + service = Authenticator(provider) + + assert calls == [None, "cursor-2"] + assert set(service.password_policies.keys()) == {"pol-1", "pol-2"} + + def test_missing_scopes_skip_dependent_api_calls(self): + provider = set_mocked_okta_provider( + identity=OktaIdentityInfo( + org_domain="acme.okta.com", + client_id="0oa1234567890abcdef", + granted_scopes=["okta.apps.read"], + ) + ) + + async def fail_if_called(*_a, **_k): + raise AssertionError("Authenticator API calls should not run") + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = fail_if_called + mocked.list_authenticators = fail_if_called + mocked_client_cls.return_value = mocked + service = Authenticator(provider) + + assert service.missing_scope["password_policies"] == "okta.policies.read" + assert service.missing_scope["authenticators"] == "okta.authenticators.read" + assert service.password_policies == {} + assert service.authenticators == {} + + def test_returns_empty_collections_on_api_errors(self): + provider = set_mocked_okta_provider() + + async def failing(*_a, **_k): + return ([], _resp({}), Exception("forbidden")) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_policies = failing + mocked.list_authenticators = failing + mocked_client_cls.return_value = mocked + service = Authenticator(provider) + + assert service.password_policies == {} + assert service.authenticators == {} diff --git a/tests/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active_test.py b/tests/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active_test.py new file mode 100644 index 0000000000..c85527f0be --- /dev/null +++ b/tests/providers/okta/services/authenticator/authenticator_smart_card_active/authenticator_smart_card_active_test.py @@ -0,0 +1,74 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.authenticator.authenticator_fixtures import ( + authenticator, + build_authenticator_client, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.authenticator." + "authenticator_smart_card_active.authenticator_smart_card_active.authenticator_client" +) + + +def _run_check(authenticator_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=authenticator_client), + ): + from prowler.providers.okta.services.authenticator.authenticator_smart_card_active.authenticator_smart_card_active import ( + authenticator_smart_card_active, + ) + + return authenticator_smart_card_active().execute() + + +class Test_authenticator_smart_card_active: + def test_smart_card_active_passes(self): + smart_card = authenticator( + auth_id="aut-smart-card", + key="smart_card_idp", + name="Smart Card IdP", + status="ACTIVE", + ) + findings = _run_check( + build_authenticator_client(authenticators={smart_card.id: smart_card}) + ) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert findings[0].resource_id == smart_card.id + + def test_missing_smart_card_fails(self): + findings = _run_check(build_authenticator_client(authenticators={})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "not active" in findings[0].status_extended + + def test_missing_authenticators_scope_is_manual(self): + findings = _run_check( + build_authenticator_client( + authenticators={}, + missing_scope={"authenticators": "okta.authenticators.read"}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "okta.authenticators.read" in findings[0].status_extended + + def test_inactive_smart_card_fails(self): + smart_card = authenticator( + auth_id="aut-smart-card", + key="smart_card_idp", + name="Smart Card IdP", + status="INACTIVE", + ) + findings = _run_check( + build_authenticator_client(authenticators={smart_card.id: smart_card}) + ) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "INACTIVE" in findings[0].status_extended diff --git a/tests/providers/okta/services/idp/idp_fixtures.py b/tests/providers/okta/services/idp/idp_fixtures.py new file mode 100644 index 0000000000..8ebc449717 --- /dev/null +++ b/tests/providers/okta/services/idp/idp_fixtures.py @@ -0,0 +1,44 @@ +"""Shared helpers for `idp` service check tests.""" + +from unittest import mock + +from prowler.providers.okta.services.idp.idp_service import OktaIdentityProvider +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def build_idp_client( + identity_providers: dict = None, + missing_scope: dict = None, +): + client = mock.MagicMock() + client.identity_providers = identity_providers or {} + client.provider = set_mocked_okta_provider() + client.audit_config = {} + client.missing_scope = missing_scope or {"identity_providers": None} + return client + + +def smart_card_idp( + idp_id: str = "0oa-x509", + name: str = "CAC IdP", + status: str = "ACTIVE", + issuer: str = "CN=DOD ROOT CA 6", + kid: str = "kid-abc-123", +): + return OktaIdentityProvider( + id=idp_id, + name=name, + type="X509", + status=status, + trust_issuer=issuer, + trust_kid=kid, + ) + + +def non_smart_card_idp( + idp_id: str = "0oa-saml", + name: str = "Corporate SAML", + type: str = "SAML2", + status: str = "ACTIVE", +): + return OktaIdentityProvider(id=idp_id, name=name, type=type, status=status) diff --git a/tests/providers/okta/services/idp/idp_service_test.py b/tests/providers/okta/services/idp/idp_service_test.py new file mode 100644 index 0000000000..3c30c0d3eb --- /dev/null +++ b/tests/providers/okta/services/idp/idp_service_test.py @@ -0,0 +1,80 @@ +import json +from unittest import mock + +from okta.models.identity_provider_protocol import IdentityProviderProtocol + +from prowler.providers.okta.services.idp.idp_service import Idp, OktaIdentityProvider +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def _resp(headers: dict = None): + r = mock.MagicMock() + r.headers = headers or {} + return r + + +def _fake_idp(idp_id, name, type_, status="ACTIVE", issuer=None, kid=None): + # Build a real `IdentityProviderProtocol` when issuer/kid are provided + # so the test exercises the SDK's Pydantic v2 oneOf wrapper — credentials + # live on `actual_instance`, not directly on the wrapper. MagicMock + # auto-attribute-creation would otherwise hide a missed unwrap. + idp = mock.MagicMock() + idp.id = idp_id + idp.name = name + idp.type = type_ + idp.status = status + if issuer is None and kid is None: + idp.protocol = None + else: + idp.protocol = IdentityProviderProtocol.from_json( + json.dumps( + { + "type": "MTLS", + "credentials": {"trust": {"issuer": issuer, "kid": kid}}, + } + ) + ) + return idp + + +def _patch_sdk(**methods): + return mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=mock.MagicMock(**methods), + ) + + +class Test_Idp_service: + def test_fetches_idps_with_trust_fields(self): + provider = set_mocked_okta_provider() + x509 = _fake_idp( + "0oa1", + "CAC", + "X509", + issuer="CN=DOD ROOT CA 6", + kid="kid-1", + ) + saml = _fake_idp("0oa2", "Corp", "SAML2") + + async def fake_list(*_a, **_k): + return ([x509, saml], _resp({}), None) + + with _patch_sdk(list_identity_providers=fake_list): + service = Idp(provider) + + assert set(service.identity_providers.keys()) == {"0oa1", "0oa2"} + assert isinstance(service.identity_providers["0oa1"], OktaIdentityProvider) + assert service.identity_providers["0oa1"].trust_issuer == "CN=DOD ROOT CA 6" + assert service.identity_providers["0oa1"].trust_kid == "kid-1" + assert service.identity_providers["0oa2"].trust_issuer is None + + def test_returns_empty_on_api_error(self): + provider = set_mocked_okta_provider() + + async def failing(*_a, **_k): + return ([], _resp({}), Exception("API failure")) + + with _patch_sdk(list_identity_providers=failing): + service = Idp(provider) + + assert service.identity_providers == {} diff --git a/tests/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca_test.py b/tests/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca_test.py new file mode 100644 index 0000000000..f6517e14a9 --- /dev/null +++ b/tests/providers/okta/services/idp/idp_smart_card_dod_approved_ca/idp_smart_card_dod_approved_ca_test.py @@ -0,0 +1,125 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.idp.idp_fixtures import ( + build_idp_client, + non_smart_card_idp, + smart_card_idp, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.idp." + "idp_smart_card_dod_approved_ca.idp_smart_card_dod_approved_ca.idp_client" +) + +DOD_PKI_ISSUER = "CN=DoD ID CA-59, OU=PKI, OU=DoD, O=U.S. Government, C=US" +ECA_ISSUER = "CN=ECA Root CA 4, OU=ECA, O=U.S. Government, C=US" +NON_DOD_ISSUER = "CN=ACME Internal Root, O=Acme Corp, C=US" + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=client), + ): + from prowler.providers.okta.services.idp.idp_smart_card_dod_approved_ca.idp_smart_card_dod_approved_ca import ( + idp_smart_card_dod_approved_ca, + ) + + return idp_smart_card_dod_approved_ca().execute() + + +class Test_idp_smart_card_dod_approved_ca: + def test_pass_when_active_idp_chain_matches_dod_pki_pattern(self): + idp = smart_card_idp(name="CAC", issuer=DOD_PKI_ISSUER, kid="kid-x") + client = build_idp_client(identity_providers={idp.id: idp}) + findings = _run_check(client) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "OU=DoD" in findings[0].status_extended + assert DOD_PKI_ISSUER in findings[0].status_extended + + def test_pass_when_active_idp_chain_matches_eca_pattern(self): + idp = smart_card_idp(name="ECA Partner", issuer=ECA_ISSUER, kid="kid-e") + client = build_idp_client(identity_providers={idp.id: idp}) + findings = _run_check(client) + assert findings[0].status == "PASS" + assert "OU=ECA" in findings[0].status_extended + + def test_manual_when_active_but_issuer_does_not_match_any_pattern(self): + idp = smart_card_idp(name="Custom", issuer=NON_DOD_ISSUER, kid="kid-c") + client = build_idp_client(identity_providers={idp.id: idp}) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert NON_DOD_ISSUER in findings[0].status_extended + assert "okta_dod_approved_ca_issuer_patterns" in findings[0].status_extended + + def test_pass_when_audit_config_pattern_matches(self): + idp = smart_card_idp(name="Custom DOD", issuer=NON_DOD_ISSUER, kid="kid-c") + client = build_idp_client(identity_providers={idp.id: idp}) + client.audit_config = { + "okta_dod_approved_ca_issuer_patterns": [r"CN=ACME Internal Root"] + } + findings = _run_check(client) + assert findings[0].status == "PASS" + + def test_audit_config_overrides_bundled_defaults(self): + # When the operator supplies a list, the bundled DEFAULT patterns + # are replaced (not merged) so customers can carve out a strict set. + idp = smart_card_idp(name="DoD", issuer=DOD_PKI_ISSUER, kid="kid-x") + client = build_idp_client(identity_providers={idp.id: idp}) + client.audit_config = { + "okta_dod_approved_ca_issuer_patterns": [r"CN=YourTenantCustomDodCA"] + } + findings = _run_check(client) + assert findings[0].status == "MANUAL" + + def test_malformed_audit_config_pattern_skipped(self): + # An invalid regex from the operator must not crash the whole check. + idp = smart_card_idp(name="CAC", issuer=DOD_PKI_ISSUER, kid="kid-x") + client = build_idp_client(identity_providers={idp.id: idp}) + client.audit_config = { + "okta_dod_approved_ca_issuer_patterns": [r"[invalid(regex", r"OU=DoD"] + } + findings = _run_check(client) + assert findings[0].status == "PASS" + + def test_fail_when_x509_idp_is_inactive(self): + idp = smart_card_idp(status="INACTIVE", issuer=DOD_PKI_ISSUER) + client = build_idp_client(identity_providers={idp.id: idp}) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "INACTIVE" in findings[0].status_extended + + def test_fail_when_no_smart_card_idp_configured(self): + client = build_idp_client(identity_providers={"saml": non_smart_card_idp()}) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert ( + "No Smart Card (X509) Identity Providers are configured" + in findings[0].status_extended + ) + assert "mutelist" in findings[0].status_extended + + def test_manual_when_idps_scope_missing(self): + client = build_idp_client( + missing_scope={"identity_providers": "okta.idps.read"} + ) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert "okta.idps.read" in findings[0].status_extended + + def test_multiple_x509_idps_yield_one_finding_each(self): + idp_a = smart_card_idp(idp_id="0oa-a", name="A", issuer=DOD_PKI_ISSUER) + idp_b = smart_card_idp( + idp_id="0oa-b", name="B", status="INACTIVE", issuer=DOD_PKI_ISSUER + ) + client = build_idp_client(identity_providers={idp_a.id: idp_a, idp_b.id: idp_b}) + findings = _run_check(client) + assert len(findings) == 2 + # We don't strictly assert ordering — just that both are covered. + statuses = sorted(f.status for f in findings) + assert statuses == ["FAIL", "PASS"] diff --git a/tests/providers/okta/services/network_zone/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies_test.py b/tests/providers/okta/services/network_zone/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies_test.py new file mode 100644 index 0000000000..89cc2fd8ed --- /dev/null +++ b/tests/providers/okta/services/network_zone/network_zone_block_anonymized_proxies/network_zone_block_anonymized_proxies_test.py @@ -0,0 +1,153 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.network_zone.network_zone_fixtures import ( + build_network_zone_client, + network_zone, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.network." + "network_zone_block_anonymized_proxies." + "network_zone_block_anonymized_proxies.network_zone_client" +) + + +def _run_check(network_zone_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=network_zone_client), + ): + from prowler.providers.okta.services.network.network_zone_block_anonymized_proxies.network_zone_block_anonymized_proxies import ( + network_zone_block_anonymized_proxies, + ) + + return network_zone_block_anonymized_proxies().execute() + + +class Test_network_zone_block_anonymized_proxies: + def test_no_zones_fails(self): + findings = _run_check(build_network_zone_client({})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "No active Okta Network Zone blocklist" in findings[0].status_extended + + def test_missing_network_zone_scope_is_manual(self): + findings = _run_check( + build_network_zone_client( + {}, + missing_scope={"network_zones": "okta.networkZones.read"}, + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "okta.networkZones.read" in findings[0].status_extended + + def test_sdk_network_zone_retrieval_error_is_manual(self): + findings = _run_check( + build_network_zone_client( + {}, + retrieval_error="Error listing Network Zones: forbidden", + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "could not be retrieved" in findings[0].status_extended + assert "forbidden" in findings[0].status_extended + + def test_raw_network_zone_retrieval_error_is_manual(self): + findings = _run_check( + build_network_zone_client( + {}, + retrieval_error="Raw Network Zones fetch (execute) failed: timeout", + ) + ) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert "could not be retrieved" in findings[0].status_extended + assert "timeout" in findings[0].status_extended + + def test_active_ip_blocklist_gateway_is_manual(self): + zone = network_zone(gateways=["198.51.100.10/32"]) + findings = _run_check(build_network_zone_client({zone.id: zone})) + assert len(findings) == 1 + assert findings[0].status == "MANUAL" + assert findings[0].resource_id == zone.id + assert "manual IP blocklist" in findings[0].status_extended + assert "cannot verify full anonymizer coverage" in findings[0].status_extended + + def test_pass_with_active_enhanced_dynamic_anonymizer_blocklist(self): + zone = network_zone( + zone_id="nzo-enhanced", + name="DefaultEnhancedDynamicZone", + zone_type="DYNAMIC_V2", + system=True, + ip_service_categories=["ANONYMIZER"], + ) + findings = _run_check(build_network_zone_client({zone.id: zone})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "Enhanced Dynamic" in findings[0].status_extended + + def test_pass_with_custom_enhanced_dynamic_anonymizer_blocklist(self): + zone = network_zone( + zone_id="nzo-custom-enhanced", + name="Custom Anonymous VPN Blocklist", + zone_type="DYNAMIC_V2", + system=False, + ip_service_categories=["VPN"], + ) + findings = _run_check(build_network_zone_client({zone.id: zone})) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "Enhanced Dynamic" in findings[0].status_extended + + def test_dynamic_blocklist_without_anonymizer_category_fails(self): + zone = network_zone( + zone_id="nzo-dynamic", + name="Dynamic Blocklist Without Anonymizers", + zone_type="DYNAMIC", + ip_service_categories=["RISKY_IPS"], + ) + findings = _run_check(build_network_zone_client({zone.id: zone})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "do not actively block" in findings[0].status_extended + + def test_default_enhanced_dynamic_zone_without_categories_fails(self): + zone = network_zone( + zone_id="nzo-default-enhanced", + name="DefaultEnhancedDynamicZone", + zone_type="DYNAMIC_V2", + system=True, + ip_service_categories=[], + ) + findings = _run_check(build_network_zone_client({zone.id: zone})) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "do not actively block" in findings[0].status_extended + + def test_existing_zones_without_anonymized_proxy_blocklist_fail(self): + policy_zone = network_zone( + zone_id="nzo-policy", + name="Corporate Policy Zone", + usage="POLICY", + gateways=["10.0.0.0/8"], + ) + inactive_blocklist = network_zone( + zone_id="nzo-inactive", + name="Inactive Blocklist", + status="INACTIVE", + gateways=["203.0.113.0/24"], + ) + findings = _run_check( + build_network_zone_client( + {policy_zone.id: policy_zone, inactive_blocklist.id: inactive_blocklist} + ) + ) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "do not actively block" in findings[0].status_extended diff --git a/tests/providers/okta/services/network_zone/network_zone_fixtures.py b/tests/providers/okta/services/network_zone/network_zone_fixtures.py new file mode 100644 index 0000000000..09328a5d73 --- /dev/null +++ b/tests/providers/okta/services/network_zone/network_zone_fixtures.py @@ -0,0 +1,42 @@ +from unittest import mock + +from prowler.providers.okta.services.network.network_zone_service import OktaNetworkZone +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def build_network_zone_client( + zones: dict = None, + missing_scope: dict = None, + retrieval_error: str | None = None, +): + client = mock.MagicMock() + client.network_zones = zones or {} + client.missing_scope = missing_scope or {"network_zones": None} + client.retrieval_error = retrieval_error + client.provider = set_mocked_okta_provider() + return client + + +def network_zone( + zone_id: str = "nzo-1", + name: str = "BlockedIpZone", + *, + status: str = "ACTIVE", + zone_type: str = "IP", + usage: str = "BLOCKLIST", + system: bool = False, + gateways: list[str] = None, + proxies: list[str] = None, + ip_service_categories: list[str] = None, +): + return OktaNetworkZone( + id=zone_id, + name=name, + status=status, + type=zone_type, + usage=usage, + system=system, + gateways=gateways or [], + proxies=proxies or [], + ip_service_categories=ip_service_categories or [], + ) diff --git a/tests/providers/okta/services/network_zone/network_zone_service_test.py b/tests/providers/okta/services/network_zone/network_zone_service_test.py new file mode 100644 index 0000000000..36790c1328 --- /dev/null +++ b/tests/providers/okta/services/network_zone/network_zone_service_test.py @@ -0,0 +1,560 @@ +import json +from types import SimpleNamespace +from unittest import mock + +from pydantic import ValidationError + +from prowler.providers.okta.models import OktaIdentityInfo +from prowler.providers.okta.services.network.network_zone_service import ( + NetworkZone, + OktaNetworkZone, + _raw_zone_to_model, + _value, +) +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def _resp(headers: dict = None): + return SimpleNamespace(headers=headers or {}) + + +def _sdk_zone( + zone_id: str, + name: str, + *, + status: str = "ACTIVE", + zone_type: str = "IP", + usage: str = "BLOCKLIST", + system: bool = False, + gateways: list[str] = None, + proxies: list[str] = None, + ip_service_categories: list[str] = None, +): + return SimpleNamespace( + id=zone_id, + name=name, + status=status, + type=zone_type, + usage=usage, + system=system, + gateways=gateways or [], + proxies=proxies or [], + ip_service_categories=ip_service_categories or [], + ) + + +class _ValueObject: + def __init__(self, value: str): + self.value = value + + +class Test_value_helper: + def test_value_returns_empty_string_for_none(self): + assert _value(None) == "" + + +class Test_NetworkZone_service: + def test_fetches_ip_and_enhanced_dynamic_zones(self): + provider = set_mocked_okta_provider() + ip_zone = _sdk_zone( + "nzo-ip", + "Blocked IPs", + gateways=["203.0.113.10/32"], + ) + enhanced_zone = _sdk_zone( + "nzo-enhanced", + "DefaultEnhancedDynamicZone", + zone_type="DYNAMIC_V2", + system=True, + ip_service_categories=["ANONYMIZER"], + ) + + async def fake_list_network_zones(*_a, **_k): + return ([ip_zone, enhanced_zone], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_network_zones = fake_list_network_zones + mocked_client_cls.return_value = mocked + + service = NetworkZone(provider) + + assert set(service.network_zones.keys()) == {"nzo-ip", "nzo-enhanced"} + assert isinstance(service.network_zones["nzo-ip"], OktaNetworkZone) + assert service.network_zones["nzo-ip"].gateways == ["203.0.113.10/32"] + assert service.network_zones["nzo-enhanced"].type == "DYNAMIC_V2" + assert service.network_zones["nzo-enhanced"].ip_service_categories == [ + "ANONYMIZER" + ] + + def test_paginates_network_zones(self): + provider = set_mocked_okta_provider() + page_1 = _sdk_zone("nzo-1", "First") + page_2 = _sdk_zone("nzo-2", "Second") + next_link = '; rel="next"' + calls = [] + + async def fake_list_network_zones(*_a, **kwargs): + calls.append(kwargs.get("after")) + if kwargs.get("after") is None: + return ([page_1], _resp({"link": next_link}), None) + return ([page_2], _resp({}), None) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_network_zones = fake_list_network_zones + mocked_client_cls.return_value = mocked + service = NetworkZone(provider) + + assert calls == [None, "cursor-2"] + assert set(service.network_zones.keys()) == {"nzo-1", "nzo-2"} + + def test_preserves_sdk_error_reason_on_api_error(self): + provider = set_mocked_okta_provider() + + async def failing(*_a, **_k): + return ([], _resp({}), Exception("forbidden")) + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_network_zones = failing + mocked_client_cls.return_value = mocked + service = NetworkZone(provider) + + assert service.network_zones == {} + assert service.retrieval_error == "Error listing Network Zones: forbidden" + + def test_build_zone_extracts_sdk_network_zone_address_values(self): + from okta.models.network_zone_address import NetworkZoneAddress + + zone = _sdk_zone( + "nzo-ip", + "Blocked IPs", + gateways=[ + NetworkZoneAddress( + type="CIDR", + value="203.0.113.10/32", + ) + ], + proxies=[ + NetworkZoneAddress( + type="CIDR", + value="198.51.100.10/32", + ) + ], + ) + + built_zone = NetworkZone._build_zone(zone) + + assert built_zone.gateways == ["203.0.113.10/32"] + assert built_zone.proxies == ["198.51.100.10/32"] + + def test_build_zone_normalizes_sdk_value_objects_to_strings(self): + zone = _sdk_zone( + "nzo-sdk-values", + "SDK Values", + gateways=[_ValueObject("203.0.113.10/32")], + proxies=[_ValueObject("198.51.100.10/32")], + ) + zone.asns = SimpleNamespace(include=[_ValueObject("64512")], exclude=[]) + zone.locations = SimpleNamespace(include=[_ValueObject("US")], exclude=[]) + + built_zone = NetworkZone._build_zone(zone) + + assert built_zone.gateways == ["203.0.113.10/32"] + assert built_zone.proxies == ["198.51.100.10/32"] + assert built_zone.asns == ["64512"] + assert built_zone.locations == ["US"] + + def test_build_zone_extracts_sdk_enhanced_dynamic_category_values(self): + from okta.models.enhanced_dynamic_network_zone_all_of_ip_service_categories import ( + EnhancedDynamicNetworkZoneAllOfIpServiceCategories, + ) + + zone = _sdk_zone( + "nzo-enhanced", + "Enhanced Anonymizers", + zone_type="DYNAMIC_V2", + system=False, + ) + zone.ip_service_categories = EnhancedDynamicNetworkZoneAllOfIpServiceCategories( + include=["ALL_ANONYMIZERS"], + exclude=[], + ) + + built_zone = NetworkZone._build_zone(zone) + + assert built_zone.ip_service_categories == ["ALL_ANONYMIZERS"] + + def test_missing_network_zone_scope_skips_api_call(self): + provider = set_mocked_okta_provider( + identity=OktaIdentityInfo( + org_domain="acme.okta.com", + client_id="0oa1234567890abcdef", + granted_scopes=["okta.policies.read", "okta.brands.read"], + ) + ) + + async def fail_if_called(*_a, **_k): + raise AssertionError("list_network_zones should not be called") + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient" + ) as mocked_client_cls: + mocked = mock.MagicMock() + mocked.list_network_zones = fail_if_called + mocked_client_cls.return_value = mocked + service = NetworkZone(provider) + + assert service.missing_scope["network_zones"] == "okta.networkZones.read" + assert service.network_zones == {} + + +class Test_NetworkZone_service_sdk_validation_fallback: + """Verifies the raw-JSON fallback for the Okta SDK Enhanced Dynamic + Zone deserialization bug. + + The Okta Management API returns `asns.include` as a JSON array + (typically `[]`) but the SDK's `EnhancedDynamicNetworkZoneAllOfAsnsInclude` + is an object-shaped pydantic model — so listing zones raises + ValidationError. Without a fallback the whole fetch crashes and + every check FAILs as if no zones exist; with the fallback we parse + the raw JSON and STIG evaluation continues. + """ + + @staticmethod + def _trigger_real_validation_error() -> ValidationError: + try: + from okta.models.enhanced_dynamic_network_zone_all_of_asns_include import ( # noqa: E501 + EnhancedDynamicNetworkZoneAllOfAsnsInclude, + ) + + EnhancedDynamicNetworkZoneAllOfAsnsInclude.from_dict([]) + except ValidationError as ve: + return ve + raise AssertionError("Expected pydantic ValidationError from Okta SDK model") + + def _build_service_with_raw_payload( + self, raw_zones_payload, response=None, body_factory=None + ): + response_body = ( + body_factory(raw_zones_payload) + if body_factory + else json.dumps(raw_zones_payload) + ) + return self._build_service_with_raw_response(response_body, response=response) + + def _build_service_with_raw_response( + self, response_body, response=None, execute_error=None + ): + provider = set_mocked_okta_provider() + ve = self._trigger_real_validation_error() + + async def failing_list_network_zones(*_a, **_k): + raise ve + + async def fake_raw_create(*_a, **_k): + return ({"url": "/api/v1/zones"}, None) + + async def fake_raw_execute(_request): + return (response, response_body, execute_error) + + sdk_mock = mock.MagicMock() + sdk_mock.list_network_zones = failing_list_network_zones + sdk_mock._request_executor.create_request = fake_raw_create + sdk_mock._request_executor.execute = fake_raw_execute + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=sdk_mock, + ): + return NetworkZone(provider) + + def test_raw_fallback_projects_ip_and_enhanced_dynamic_zones(self): + zones_payload = [ + { + "id": "nzo-ip", + "name": "Blocked IPs", + "status": "ACTIVE", + "type": "IP", + "usage": "BLOCKLIST", + "system": False, + "gateways": [{"type": "CIDR", "value": "203.0.113.10/32"}], + "proxies": [], + }, + { + "id": "nzo-enhanced", + "name": "DefaultEnhancedDynamicZone", + "status": "ACTIVE", + "type": "DYNAMIC_V2", + "usage": "BLOCKLIST", + "system": True, + "asns": {"include": [], "exclude": []}, + "locations": {"include": [], "exclude": []}, + "ipServiceCategories": [{"value": "ANONYMIZER"}], + }, + ] + + service = self._build_service_with_raw_payload(zones_payload) + + assert set(service.network_zones.keys()) == {"nzo-ip", "nzo-enhanced"} + ip_zone = service.network_zones["nzo-ip"] + assert ip_zone.type == "IP" + assert ip_zone.gateways == ["203.0.113.10/32"] + enhanced = service.network_zones["nzo-enhanced"] + assert enhanced.type == "DYNAMIC_V2" + assert enhanced.system is True + assert enhanced.ip_service_categories == ["ANONYMIZER"] + assert enhanced.asns == [] + assert enhanced.locations == [] + + def test_raw_fallback_handles_empty_payload(self): + service = self._build_service_with_raw_payload([]) + assert service.network_zones == {} + + def test_raw_fallback_handles_executor_error(self): + provider = set_mocked_okta_provider() + ve = self._trigger_real_validation_error() + + async def failing_list_network_zones(*_a, **_k): + raise ve + + async def fake_raw_create(*_a, **_k): + return (None, Exception("network down")) + + sdk_mock = mock.MagicMock() + sdk_mock.list_network_zones = failing_list_network_zones + sdk_mock._request_executor.create_request = fake_raw_create + sdk_mock._request_executor.execute = mock.AsyncMock() + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=sdk_mock, + ): + service = NetworkZone(provider) + + assert service.network_zones == {} + assert service.retrieval_error == ( + "Raw Network Zones fetch failed; see logs for details." + ) + + def test_raw_fallback_handles_execute_error(self): + service = self._build_service_with_raw_response( + None, + execute_error=Exception("timeout"), + ) + + assert service.network_zones == {} + assert service.retrieval_error == ( + "Raw Network Zones fetch failed; see logs for details." + ) + + def test_raw_fallback_decodes_bytes_response_body(self): + service = self._build_service_with_raw_payload( + [ + { + "id": "nzo-bytes", + "name": "Bytes", + "status": "ACTIVE", + "type": "IP", + "usage": "BLOCKLIST", + } + ], + body_factory=lambda payload: json.dumps(payload).encode("utf-8"), + ) + + assert set(service.network_zones.keys()) == {"nzo-bytes"} + + def test_raw_fallback_handles_invalid_utf8_response_body(self): + service = self._build_service_with_raw_response(b"\xff") + + assert service.network_zones == {} + assert service.retrieval_error == ( + "Raw Network Zones fetch failed; see logs for details." + ) + + def test_raw_fallback_handles_invalid_json_response_body(self): + service = self._build_service_with_raw_response("{") + + assert service.network_zones == {} + assert service.retrieval_error == ( + "Raw Network Zones fetch failed; see logs for details." + ) + + def test_raw_fallback_handles_unexpected_payload_shape(self): + service = self._build_service_with_raw_payload({"id": "nzo-not-a-list"}) + + assert service.network_zones == {} + assert service.retrieval_error == ( + "Raw Network Zones fetch failed; see logs for details." + ) + + def test_raw_fallback_skips_non_dict_payload_items(self): + service = self._build_service_with_raw_payload( + [ + "not-a-zone", + { + "id": "nzo-valid", + "name": "Valid", + "status": "ACTIVE", + "type": "IP", + "usage": "BLOCKLIST", + }, + ] + ) + + assert set(service.network_zones.keys()) == {"nzo-valid"} + + def test_raw_fallback_paginates_via_link_header(self): + next_link = '; rel="next"' + page_1 = [ + { + "id": "nzo-1", + "name": "First", + "status": "ACTIVE", + "type": "IP", + "usage": "BLOCKLIST", + } + ] + page_2 = [ + { + "id": "nzo-2", + "name": "Second", + "status": "ACTIVE", + "type": "IP", + "usage": "BLOCKLIST", + } + ] + + provider = set_mocked_okta_provider() + ve = self._trigger_real_validation_error() + execute_calls = [] + + async def failing_list_network_zones(*_a, **_k): + raise ve + + async def fake_raw_create(*_a, **kwargs): + return ({"url": kwargs.get("url", "")}, None) + + async def fake_raw_execute(request): + execute_calls.append(request) + if len(execute_calls) == 1: + return ( + SimpleNamespace(headers={"link": next_link}), + json.dumps(page_1), + None, + ) + return (SimpleNamespace(headers={}), json.dumps(page_2), None) + + sdk_mock = mock.MagicMock() + sdk_mock.list_network_zones = failing_list_network_zones + sdk_mock._request_executor.create_request = fake_raw_create + sdk_mock._request_executor.execute = fake_raw_execute + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=sdk_mock, + ): + service = NetworkZone(provider) + + assert len(execute_calls) == 2 + assert "after=cursor-2" in execute_calls[1]["url"] + assert set(service.network_zones.keys()) == {"nzo-1", "nzo-2"} + + +class Test_raw_zone_to_model: + def test_extracts_address_values_and_categories(self): + zone = _raw_zone_to_model( + { + "id": "nzo-ip", + "name": "IPs", + "status": "ACTIVE", + "type": "IP", + "usage": "BLOCKLIST", + "system": False, + "gateways": [ + {"type": "CIDR", "value": "203.0.113.0/24"}, + {"type": "RANGE", "value": "198.51.100.5-198.51.100.10"}, + ], + "proxies": [{"type": "CIDR", "value": "192.0.2.0/24"}], + "ipServiceCategories": [ + {"value": "ANONYMIZER"}, + {"value": "TOR_ANONYMIZER"}, + ], + } + ) + assert zone.gateways == [ + "203.0.113.0/24", + "198.51.100.5-198.51.100.10", + ] + assert zone.proxies == ["192.0.2.0/24"] + assert zone.ip_service_categories == ["ANONYMIZER", "TOR_ANONYMIZER"] + + def test_collapses_non_list_asns_and_locations_to_empty(self): + zone = _raw_zone_to_model( + { + "id": "nzo-enhanced", + "name": "Enhanced", + "type": "DYNAMIC_V2", + "asns": {"include": [], "exclude": []}, + "locations": {"include": [], "exclude": []}, + } + ) + assert zone.asns == [] + assert zone.locations == [] + assert isinstance(zone, OktaNetworkZone) + + def test_extracts_ip_service_categories_from_raw_include_condition(self): + zone = _raw_zone_to_model( + { + "id": "nzo-enhanced", + "name": "Enhanced", + "type": "DYNAMIC_V2", + "ipServiceCategories": { + "include": ["ALL_ANONYMIZERS"], + "exclude": [], + }, + } + ) + assert zone.ip_service_categories == ["ALL_ANONYMIZERS"] + + def test_extracts_scalar_ip_service_category_condition(self): + zone = _raw_zone_to_model( + { + "id": "nzo-enhanced", + "name": "Enhanced", + "type": "DYNAMIC_V2", + "ipServiceCategories": { + "include": {"value": "VPN_ANONYMIZER"}, + "exclude": [], + }, + } + ) + assert zone.ip_service_categories == ["VPN_ANONYMIZER"] + + def test_ignores_none_address_entries_and_empty_condition_values(self): + zone = _raw_zone_to_model( + { + "id": "nzo-ip", + "gateways": [None], + "ipServiceCategories": { + "include": None, + "exclude": [], + }, + } + ) + assert zone.gateways == [] + assert zone.ip_service_categories == [] + + def test_falls_back_name_to_id_when_missing(self): + zone = _raw_zone_to_model({"id": "nzo-1"}) + assert zone.id == "nzo-1" + assert zone.name == "nzo-1" + assert zone.status == "" + assert zone.system is False diff --git a/tests/providers/okta/services/signon/signon_service_test.py b/tests/providers/okta/services/signon/signon_service_test.py index c408bc1221..0caeb0e680 100644 --- a/tests/providers/okta/services/signon/signon_service_test.py +++ b/tests/providers/okta/services/signon/signon_service_test.py @@ -1,12 +1,14 @@ from unittest import mock +from prowler.providers.okta.lib.service.pagination import ( + next_after_cursor as _next_after_cursor, +) from prowler.providers.okta.models import OktaIdentityInfo from prowler.providers.okta.services.signon.signon_service import ( GlobalSessionPolicy, GlobalSessionPolicyRule, SignInPage, Signon, - _next_after_cursor, ) from tests.providers.okta.okta_fixtures import ( OKTA_CLIENT_ID, diff --git a/tests/providers/okta/services/systemlog/systemlog_fixtures.py b/tests/providers/okta/services/systemlog/systemlog_fixtures.py new file mode 100644 index 0000000000..efc8289f43 --- /dev/null +++ b/tests/providers/okta/services/systemlog/systemlog_fixtures.py @@ -0,0 +1,27 @@ +"""Shared helpers for `systemlog` service check tests.""" + +from unittest import mock + +from prowler.providers.okta.services.systemlog.systemlog_service import LogStream +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def build_systemlog_client( + log_streams: dict = None, + missing_scope: dict = None, +): + client = mock.MagicMock() + client.log_streams = log_streams or {} + client.provider = set_mocked_okta_provider() + client.audit_config = {} + client.missing_scope = missing_scope or {"log_streams": None} + return client + + +def log_stream( + stream_id: str = "log-1", + name: str = "EventBridge stream", + status: str = "ACTIVE", + type: str = "AWS_EVENTBRIDGE", +): + return LogStream(id=stream_id, name=name, status=status, type=type) diff --git a/tests/providers/okta/services/systemlog/systemlog_service_test.py b/tests/providers/okta/services/systemlog/systemlog_service_test.py new file mode 100644 index 0000000000..63dee95dcc --- /dev/null +++ b/tests/providers/okta/services/systemlog/systemlog_service_test.py @@ -0,0 +1,185 @@ +import json +from unittest import mock + +from prowler.providers.okta.models import OktaIdentityInfo +from prowler.providers.okta.services.systemlog.systemlog_service import ( + LogStream, + SystemLog, +) +from tests.providers.okta.okta_fixtures import ( + OKTA_CLIENT_ID, + OKTA_ORG_DOMAIN, + set_mocked_okta_provider, +) + + +def _resp(headers: dict = None): + r = mock.MagicMock() + r.headers = headers or {} + return r + + +def _fake_stream( + stream_id: str, name: str, status: str = "ACTIVE", type_: str = "AWS_EVENTBRIDGE" +): + s = mock.MagicMock() + s.id = stream_id + s.name = name + s.status = status + s.type = type_ + return s + + +def _patch_sdk(**methods): + return mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=mock.MagicMock(**methods), + ) + + +class Test_SystemLog_service: + def test_fetches_active_streams(self): + provider = set_mocked_okta_provider() + s1 = _fake_stream("log-1", "EventBridge") + s2 = _fake_stream("log-2", "Splunk", type_="SPLUNK_CLOUD_LOGSTREAMING") + + async def fake_list(*_a, **_k): + return ([s1, s2], _resp({}), None) + + with _patch_sdk(list_log_streams=fake_list): + service = SystemLog(provider) + + assert set(service.log_streams.keys()) == {"log-1", "log-2"} + assert isinstance(service.log_streams["log-1"], LogStream) + assert service.log_streams["log-2"].type == "SPLUNK_CLOUD_LOGSTREAMING" + + def test_returns_empty_on_api_error(self): + provider = set_mocked_okta_provider() + + async def failing(*_a, **_k): + return ([], _resp({}), Exception("E0000007")) + + with _patch_sdk(list_log_streams=failing): + service = SystemLog(provider) + + assert service.log_streams == {} + + def test_skips_fetch_when_scope_missing(self): + identity = OktaIdentityInfo( + org_domain=OKTA_ORG_DOMAIN, + client_id=OKTA_CLIENT_ID, + granted_scopes=["okta.policies.read"], # no logStreams scope + ) + provider = set_mocked_okta_provider(identity=identity) + + called = False + + async def fake_list(*_a, **_k): + nonlocal called + called = True + return ([], _resp({}), None) + + with _patch_sdk(list_log_streams=fake_list): + service = SystemLog(provider) + + assert called is False + assert service.log_streams == {} + assert service.missing_scope["log_streams"] == "okta.logStreams.read" + + +class Test_SystemLog_service_sdk_validation_fallback: + """Verifies the raw-JSON fallback when the Okta SDK rejects API values. + + The SDK's `LogStreamSettingsAws.eventSourceName` validator uses the + regex `^[a-zA-Z0-9.\\-_]$` — missing the `+` quantifier, so every + multi-character name raises pydantic `ValidationError`. Without the + fallback the whole stream list is lost; with it, the raw JSON path + still surfaces each stream's id/name/status/type. + """ + + def test_raw_fallback_projects_streams_when_sdk_raises(self): + from pydantic import ValidationError + + provider = set_mocked_okta_provider() + + raw_payload = [ + { + "id": "log-1", + "name": "EventBridge prod", + "status": "ACTIVE", + "type": "AWS_EVENTBRIDGE", + }, + { + "id": "log-2", + "name": "Splunk staging", + "status": "INACTIVE", + "type": "SPLUNK_CLOUD_LOGSTREAMING", + }, + ] + + async def failing_list_log_streams(*_a, **_k): + try: + # Trigger a real pydantic ValidationError so we exercise + # the exact exception type the SDK raises in production. + from okta.models.log_stream_settings_aws import LogStreamSettingsAws + + LogStreamSettingsAws( + accountId="123456789012", + eventSourceName="MultiCharacter", + region="us-east-1", + ) + except ValidationError as ve: + raise ve + return ([], _resp({}), None) + + async def fake_raw_create(*_a, **_k): + return ({"url": "/api/v1/logStreams"}, None) + + async def fake_raw_execute(_request): + return (None, json.dumps(raw_payload), None) + + sdk = mock.MagicMock() + sdk.list_log_streams = failing_list_log_streams + sdk._request_executor.create_request = fake_raw_create + sdk._request_executor.execute = fake_raw_execute + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=sdk, + ): + service = SystemLog(provider) + + assert set(service.log_streams.keys()) == {"log-1", "log-2"} + assert service.log_streams["log-1"].status == "ACTIVE" + assert service.log_streams["log-2"].status == "INACTIVE" + assert service.log_streams["log-2"].type == "SPLUNK_CLOUD_LOGSTREAMING" + + def test_raw_fallback_handles_empty_list(self): + from pydantic import ValidationError + + provider = set_mocked_okta_provider() + + async def failing(*_a, **_k): + raise ValidationError.from_exception_data( + title="LogStreamSettingsAws", + line_errors=[], + ) + + async def fake_create(*_a, **_k): + return ({"url": "/api/v1/logStreams"}, None) + + async def fake_execute(_req): + return (None, "[]", None) + + sdk = mock.MagicMock() + sdk.list_log_streams = failing + sdk._request_executor.create_request = fake_create + sdk._request_executor.execute = fake_execute + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=sdk, + ): + service = SystemLog(provider) + + assert service.log_streams == {} diff --git a/tests/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled_test.py b/tests/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled_test.py new file mode 100644 index 0000000000..64d2bcb8fc --- /dev/null +++ b/tests/providers/okta/services/systemlog/systemlog_streaming_enabled/systemlog_streaming_enabled_test.py @@ -0,0 +1,73 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.systemlog.systemlog_fixtures import ( + build_systemlog_client, + log_stream, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.systemlog." + "systemlog_streaming_enabled.systemlog_streaming_enabled.systemlog_client" +) + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=client), + ): + from prowler.providers.okta.services.systemlog.systemlog_streaming_enabled.systemlog_streaming_enabled import ( + systemlog_streaming_enabled, + ) + + return systemlog_streaming_enabled().execute() + + +class Test_systemlog_streaming_enabled: + def test_pass_when_active_stream_exists(self): + client = build_systemlog_client( + log_streams={"log-1": log_stream(name="EventBridge prod")} + ) + findings = _run_check(client) + assert len(findings) == 1 + assert findings[0].status == "PASS" + assert "EventBridge prod" in findings[0].status_extended + + def test_pass_when_multiple_active_streams(self): + client = build_systemlog_client( + log_streams={ + "log-1": log_stream(stream_id="log-1", name="A"), + "log-2": log_stream(stream_id="log-2", name="B"), + } + ) + findings = _run_check(client) + assert len(findings) == 2 + assert all(f.status == "PASS" for f in findings) + + def test_fail_when_all_streams_inactive(self): + client = build_systemlog_client( + log_streams={"log-1": log_stream(name="A", status="INACTIVE")} + ) + findings = _run_check(client) + assert len(findings) == 1 + assert findings[0].status == "FAIL" + assert "none are ACTIVE" in findings[0].status_extended + + def test_fail_when_no_streams_configured(self): + client = build_systemlog_client(log_streams={}) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "No Okta Log Streams are configured" in findings[0].status_extended + assert "mutelist" in findings[0].status_extended + + def test_manual_when_scope_missing(self): + client = build_systemlog_client( + missing_scope={"log_streams": "okta.logStreams.read"} + ) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert "okta.logStreams.read" in findings[0].status_extended diff --git a/tests/providers/okta/services/user/user_fixtures.py b/tests/providers/okta/services/user/user_fixtures.py new file mode 100644 index 0000000000..99282c1efa --- /dev/null +++ b/tests/providers/okta/services/user/user_fixtures.py @@ -0,0 +1,55 @@ +"""Shared helpers for `user` service check tests.""" + +from unittest import mock + +from prowler.providers.okta.services.user.user_service import ( + ExternalDirectoryIdp, + UserAutomation, +) +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def build_user_client( + automations: dict = None, + external_directory_idps: dict = None, + audit_config: dict = None, + missing_scope: dict = None, +): + client = mock.MagicMock() + client.automations = automations or {} + client.external_directory_idps = external_directory_idps or {} + client.provider = set_mocked_okta_provider() + client.audit_config = audit_config or {} + client.missing_scope = missing_scope or { + "automations": None, + "identity_providers": None, + } + return client + + +def automation( + automation_id: str = "auto-1", + name: str = "User Inactivity", + status: str = "ACTIVE", + schedule_status: str = "ACTIVE", + inactivity_days: int = 35, + lifecycle_action: str = "SUSPENDED", + groups: list = None, +): + # `groups is None` keeps the "Everyone-equivalent" default; passing + # `groups=[]` lets a test exercise the empty-scope FAIL path. + return UserAutomation( + id=automation_id, + name=name, + status=status, + schedule_status=schedule_status, + inactivity_days=inactivity_days, + lifecycle_action=lifecycle_action, + applies_to_groups=["everyone"] if groups is None else groups, + ) + + +def ad_idp(idp_id: str = "0oa-ad", name: str = "Corp AD"): + return ExternalDirectoryIdp( + id=idp_id, name=name, type="ACTIVE_DIRECTORY", status="ACTIVE" + ) diff --git a/tests/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled_test.py b/tests/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled_test.py new file mode 100644 index 0000000000..99cb7db6a0 --- /dev/null +++ b/tests/providers/okta/services/user/user_inactivity_automation_35d_enabled/user_inactivity_automation_35d_enabled_test.py @@ -0,0 +1,165 @@ +from unittest import mock + +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider +from tests.providers.okta.services.user.user_fixtures import ( + ad_idp, + automation, + build_user_client, +) + +CHECK_PATH = ( + "prowler.providers.okta.services.user." + "user_inactivity_automation_35d_enabled." + "user_inactivity_automation_35d_enabled.user_client" +) + + +def _run_check(client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_okta_provider(), + ), + mock.patch(CHECK_PATH, new=client), + ): + from prowler.providers.okta.services.user.user_inactivity_automation_35d_enabled.user_inactivity_automation_35d_enabled import ( + user_inactivity_automation_35d_enabled, + ) + + return user_inactivity_automation_35d_enabled().execute() + + +class Test_user_inactivity_automation_35d_enabled: + def test_pass_when_compliant_automation_present(self): + client = build_user_client( + automations={"auto-1": automation(name="Inactivity 35d")} + ) + findings = _run_check(client) + assert findings[0].status == "PASS" + assert "Inactivity 35d" in findings[0].status_extended + assert "SUSPENDED" in findings[0].status_extended + + def test_pass_message_names_groups_and_asks_for_coverage_verification(self): + # Okta has no built-in Everyone group ID and group names vary by + # tenant (e.g. "pepito"), so we can't assert tenant-wide coverage + # automatically — surface the group IDs and let the operator verify. + client = build_user_client( + automations={"auto-1": automation(groups=["grp-A", "grp-B"])} + ) + findings = _run_check(client) + assert findings[0].status == "PASS" + assert "grp-A, grp-B" in findings[0].status_extended + assert "cover every user" in findings[0].status_extended + + def test_fail_when_applies_to_no_group(self): + # An automation with empty `people.groups.include` runs against + # nobody — Okta does not implicitly cover every user. + client = build_user_client(automations={"auto-1": automation(groups=[])}) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "no group scope" in findings[0].status_extended + + def test_pass_when_lower_threshold(self): + # Inactivity threshold lower than the default is still compliant. + client = build_user_client( + automations={"auto-1": automation(inactivity_days=14)} + ) + findings = _run_check(client) + assert findings[0].status == "PASS" + + def test_fail_when_threshold_too_high(self): + client = build_user_client( + automations={"auto-1": automation(inactivity_days=90)} + ) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "inactivity 90d (max 35d)" in findings[0].status_extended + + def test_fail_when_status_inactive(self): + client = build_user_client( + automations={"auto-1": automation(status="INACTIVE")} + ) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "status INACTIVE" in findings[0].status_extended + + def test_fail_when_schedule_inactive(self): + client = build_user_client( + automations={"auto-1": automation(schedule_status="INACTIVE")} + ) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "schedule INACTIVE" in findings[0].status_extended + + def test_fail_when_wrong_lifecycle_action(self): + client = build_user_client( + automations={"auto-1": automation(lifecycle_action="ACTIVE")} + ) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "action ACTIVE" in findings[0].status_extended + + def test_fail_when_no_automations(self): + client = build_user_client(automations={}) + findings = _run_check(client) + assert findings[0].status == "FAIL" + assert "No Okta Workflows automations" in findings[0].status_extended + + def test_fail_lists_every_missing_piece_for_unfinished_automation(self): + # Mirrors the real-world case where an admin clicks "Add Automation" + # in the UI but never configures conditions or actions. The service + # emits a placeholder UserAutomation so the check FAILs with a + # specific message instead of pretending the policy doesn't exist. + from prowler.providers.okta.services.user.user_service import UserAutomation + + shell = UserAutomation( + id="pol-1", + name="TestCheck", + status="INACTIVE", + schedule_status="INACTIVE", + inactivity_days=None, + lifecycle_action=None, + applies_to_groups=[], + policy_id="pol-1", + policy_name="TestCheck", + ) + client = build_user_client(automations={"pol-1": shell}) + findings = _run_check(client) + assert findings[0].status == "FAIL" + msg = findings[0].status_extended + assert "TestCheck" in msg + assert "status INACTIVE" in msg + assert "schedule INACTIVE" in msg + assert "no inactivity condition" in msg + assert "action unset" in msg + + def test_manual_na_when_external_directory_idp_present(self): + client = build_user_client( + automations={"auto-1": automation(inactivity_days=90)}, # non-compliant + external_directory_idps={"0oa-ad": ad_idp(name="Corp AD")}, + ) + findings = _run_check(client) + # External directory short-circuits to MANUAL N/A regardless of + # the automations state. + assert findings[0].status == "MANUAL" + assert "ACTIVE_DIRECTORY" in findings[0].status_extended + assert "Corp AD" in findings[0].status_extended + + def test_manual_when_scope_missing(self): + client = build_user_client( + missing_scope={ + "automations": "okta.policies.read", + "identity_providers": None, + } + ) + findings = _run_check(client) + assert findings[0].status == "MANUAL" + assert "okta.policies.read" in findings[0].status_extended + + def test_threshold_overridden_via_audit_config(self): + client = build_user_client( + automations={"auto-1": automation(inactivity_days=60)}, + audit_config={"okta_user_inactivity_max_days": 90}, + ) + findings = _run_check(client) + assert findings[0].status == "PASS" diff --git a/tests/providers/okta/services/user/user_service_test.py b/tests/providers/okta/services/user/user_service_test.py new file mode 100644 index 0000000000..f4e0309b7b --- /dev/null +++ b/tests/providers/okta/services/user/user_service_test.py @@ -0,0 +1,477 @@ +import json +from unittest import mock + +from prowler.providers.okta.services.user.user_service import ( + ExternalDirectoryIdp, + User, + UserAutomation, + _raw_rule_to_automation, + _rule_to_automation, +) +from tests.providers.okta.okta_fixtures import set_mocked_okta_provider + + +def _resp(headers: dict = None): + r = mock.MagicMock() + r.headers = headers or {} + return r + + +def _fake_policy( + policy_id, + name="Inactivity Policy", + status="ACTIVE", + inactivity_days=35, + inactivity_unit="DAYS", + groups=None, +): + # In the actual API response, the inactivity condition and the + # group scope live on the *policy*, not on its rules — keep the + # typed fixture aligned with that shape so it mirrors raw JSON. + p = mock.MagicMock() + p.id = policy_id + p.name = name + p.status = status + if inactivity_days is None: + p.conditions.people.users.inactivity = None + else: + p.conditions.people.users.inactivity.number = inactivity_days + p.conditions.people.users.inactivity.unit = inactivity_unit + p.conditions.people.groups.include = ["everyone"] if groups is None else groups + return p + + +def _fake_rule( + rule_id="rule-1", + name="Inactivity", + status="ACTIVE", + lifecycle_action="SUSPENDED", +): + # A USER_LIFECYCLE policy rule carries only the lifecycle action; + # its `conditions` is typically empty. + r = mock.MagicMock() + r.id = rule_id + r.name = name + r.status = status + r.actions.user_lifecycle.action = lifecycle_action + return r + + +def _fake_idp(idp_type, status="ACTIVE", idp_id="0oa-1", name="x"): + idp = mock.MagicMock() + idp.id = idp_id + idp.name = name + idp.type = idp_type + idp.status = status + return idp + + +def _patch_sdk(**methods): + return mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=mock.MagicMock(**methods), + ) + + +class Test_rule_to_automation: + def test_parses_inactivity_and_lifecycle(self): + policy = _fake_policy("pol-1", name="Inactivity Policy") + rule = _fake_rule(rule_id="rule-1", name="Inactivity") + m = _rule_to_automation(rule, policy) + assert isinstance(m, UserAutomation) + assert m.id == "rule-1" + assert m.status == "ACTIVE" + assert m.schedule_status == "ACTIVE" + assert m.inactivity_days == 35 + assert m.lifecycle_action == "SUSPENDED" + assert m.applies_to_groups == ["everyone"] + assert m.policy_id == "pol-1" + assert m.policy_name == "Inactivity Policy" + + def test_returns_none_when_id_missing(self): + policy = _fake_policy("pol") + bad = _fake_rule() + bad.id = "" + assert _rule_to_automation(bad, policy) is None + + def test_ignores_non_days_unit(self): + policy = _fake_policy("pol", inactivity_unit="WEEKS") + rule = _fake_rule() + m = _rule_to_automation(rule, policy) + assert m.inactivity_days is None + + def test_reads_inactivity_and_groups_from_policy_not_rule(self): + # The typed path used to read inactivity/groups from the rule; + # an SDK update that started populating `policy.conditions` + # exposed the mismatch. Locking the policy-shaped projection in. + policy = _fake_policy("pol", inactivity_days=21, groups=["grp-x"]) + rule = _fake_rule() + # Sanity: nothing inactivity-ish on the rule. + del rule.conditions + m = _rule_to_automation(rule, policy) + assert m.inactivity_days == 21 + assert m.applies_to_groups == ["grp-x"] + + +class Test_User_service: + def test_fetches_automations_via_policy_api(self): + provider = set_mocked_okta_provider() + policy = _fake_policy("pol-1") + rule = _fake_rule(rule_id="rule-1") + + async def fake_list_policies(*_a, **_k): + return ([policy], _resp({}), None) + + async def fake_list_rules(*_a, **_k): + return ([rule], _resp({}), None) + + async def fake_list_idps(*_a, **_k): + return ([], _resp({}), None) + + sdk = mock.MagicMock() + sdk.list_policies = fake_list_policies + sdk.list_policy_rules = fake_list_rules + sdk.list_identity_providers = fake_list_idps + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=sdk, + ): + service = User(provider) + + assert "rule-1" in service.automations + assert service.automations["rule-1"].inactivity_days == 35 + assert service.external_directory_idps == {} + + def test_returns_empty_on_policies_api_error(self): + provider = set_mocked_okta_provider() + + async def failing(*_a, **_k): + return ([], _resp({}), Exception("E0000007")) + + async def fake_list_idps(*_a, **_k): + return ([], _resp({}), None) + + sdk = mock.MagicMock() + sdk.list_policies = failing + sdk.list_identity_providers = fake_list_idps + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=sdk, + ): + service = User(provider) + + assert service.automations == {} + + def test_detects_external_directory_idp(self): + provider = set_mocked_okta_provider() + + async def empty_policies(*_a, **_k): + return ([], _resp({}), None) + + ad = _fake_idp("ACTIVE_DIRECTORY", idp_id="0oa-ad", name="Corp AD") + saml = _fake_idp("SAML2", idp_id="0oa-saml") + + async def fake_list_idps(*_a, **_k): + return ([ad, saml], _resp({}), None) + + sdk = mock.MagicMock() + sdk.list_policies = empty_policies + sdk.list_identity_providers = fake_list_idps + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=sdk, + ): + service = User(provider) + + assert "0oa-ad" in service.external_directory_idps + assert "0oa-saml" not in service.external_directory_idps + assert isinstance( + service.external_directory_idps["0oa-ad"], ExternalDirectoryIdp + ) + + +class Test_raw_rule_to_automation: + def test_projects_inactivity_and_lifecycle(self): + # Real API shape: inactivity + groups live on the POLICY, + # lifecycle action lives on the RULE under + # `actions.updateUserLifecycle.targetStatus`. + policy = { + "id": "pol-1", + "name": "TestCheck", + "status": "ACTIVE", + "conditions": { + "people": { + "users": {"inactivity": {"number": 35, "unit": "DAYS"}}, + "groups": {"include": ["everyone"]}, + } + }, + "type": "USER_LIFECYCLE", + } + rule = { + "id": "rule-1", + "name": "lifecycle-rule-1", + "status": "ACTIVE", + "conditions": {}, + "actions": { + "updateUserLifecycle": { + "targetStatus": "SUSPENDED", + "quietPeriod": {"number": 0, "unit": "DAYS"}, + } + }, + } + m = _raw_rule_to_automation(rule, policy, "pol-1", "TestCheck", "ACTIVE") + assert isinstance(m, UserAutomation) + assert m.id == "rule-1" + assert m.status == "ACTIVE" + assert m.schedule_status == "ACTIVE" + assert m.inactivity_days == 35 + assert m.lifecycle_action == "SUSPENDED" + assert m.applies_to_groups == ["everyone"] + assert m.policy_id == "pol-1" + assert m.policy_name == "TestCheck" + + def test_returns_none_when_id_missing(self): + assert _raw_rule_to_automation({"name": "x"}, {}, "pol", "P", "ACTIVE") is None + + def test_ignores_non_days_unit(self): + policy = { + "id": "pol", + "conditions": { + "people": {"users": {"inactivity": {"number": 5, "unit": "WEEKS"}}} + }, + } + rule = {"id": "rule-2", "actions": {}} + m = _raw_rule_to_automation(rule, policy, "pol", "P", "ACTIVE") + assert m.inactivity_days is None + + def test_missing_policy_dict_gives_empty_inactivity_and_groups(self): + rule = { + "id": "rule-3", + "actions": {"updateUserLifecycle": {"targetStatus": "SUSPENDED"}}, + } + m = _raw_rule_to_automation(rule, None, "pol", "P", "ACTIVE") + assert m.inactivity_days is None + assert m.applies_to_groups == [] + assert m.lifecycle_action == "SUSPENDED" + + +class Test_User_service_sdk_discriminator_fallback: + """Verifies the raw-JSON fallback when the SDK can't deserialize USER_LIFECYCLE. + + Okta SDK 3.4.2 ships a `Policy.from_dict` discriminator mapping that + omits `USER_LIFECYCLE`, so the typed call raises ValueError. Without + the fallback the whole automations list is lost; with it the raw + JSON path projects each rule onto a `UserAutomation` snapshot. + """ + + def test_raw_fallback_projects_user_lifecycle_policy_rules(self): + provider = set_mocked_okta_provider() + + # Real API shape: inactivity + groups on POLICY, lifecycle + # action on RULE under `actions.updateUserLifecycle.targetStatus`. + policy_payload = [ + { + "id": "pol-1", + "name": "TestCheck", + "status": "ACTIVE", + "type": "USER_LIFECYCLE", + "conditions": { + "people": { + "users": {"inactivity": {"number": 35, "unit": "DAYS"}}, + "groups": {"include": ["everyone"]}, + } + }, + } + ] + rules_payload = [ + { + "id": "rule-1", + "name": "lifecycle-rule-1", + "status": "ACTIVE", + "conditions": {}, + "actions": { + "updateUserLifecycle": { + "targetStatus": "SUSPENDED", + "quietPeriod": {"number": 0, "unit": "DAYS"}, + } + }, + } + ] + + async def failing_list_policies(*_a, **_k): + raise ValueError( + "Policy failed to lookup discriminator value from {...}. " + "Discriminator property name: type, mapping: {...}" + ) + + async def fake_list_idps(*_a, **_k): + return ([], _resp({}), None) + + async def fake_raw_create(*_a, **kwargs): + url = kwargs.get("url", "") or "" + return ({"url": url}, None) + + async def fake_raw_execute(request): + url = request.get("url", "") + if "/api/v1/policies/pol-1/rules" in url: + return (None, json.dumps(rules_payload), None) + if "/api/v1/policies" in url: + return (None, json.dumps(policy_payload), None) + return (None, "[]", None) + + sdk = mock.MagicMock() + sdk.list_policies = failing_list_policies + sdk.list_identity_providers = fake_list_idps + sdk._request_executor.create_request = fake_raw_create + sdk._request_executor.execute = fake_raw_execute + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=sdk, + ): + service = User(provider) + + assert "rule-1" in service.automations + a = service.automations["rule-1"] + assert a.inactivity_days == 35 + assert a.lifecycle_action == "SUSPENDED" + assert a.schedule_status == "ACTIVE" + assert a.policy_id == "pol-1" + assert a.policy_name == "TestCheck" + + def test_raw_fallback_emits_shell_for_policy_with_no_rules(self): + # Mirrors the real-world tenant state where an admin clicked + # "Add Automation" in the UI but never configured conditions or + # actions. The policy exists; it has zero rules. The raw fallback + # must surface the policy as a shell UserAutomation so the check + # FAILs with a specific message instead of dropping it. + provider = set_mocked_okta_provider() + + async def failing_list_policies(*_a, **_k): + raise ValueError("missing discriminator mapping") + + async def fake_list_idps(*_a, **_k): + return ([], _resp({}), None) + + async def fake_raw_create(*_a, **kwargs): + return ({"url": kwargs.get("url", "") or ""}, None) + + async def fake_raw_execute(request): + url = request.get("url", "") + if "/api/v1/policies/pol-empty/rules" in url: + return (None, "[]", None) + if "/api/v1/policies" in url: + return ( + None, + json.dumps( + [ + { + "id": "pol-empty", + "name": "TestCheck", + "status": "INACTIVE", + "type": "USER_LIFECYCLE", + } + ] + ), + None, + ) + return (None, "[]", None) + + sdk = mock.MagicMock() + sdk.list_policies = failing_list_policies + sdk.list_identity_providers = fake_list_idps + sdk._request_executor.create_request = fake_raw_create + sdk._request_executor.execute = fake_raw_execute + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=sdk, + ): + service = User(provider) + + assert "pol-empty" in service.automations + shell = service.automations["pol-empty"] + assert shell.name == "TestCheck" + assert shell.status == "INACTIVE" + assert shell.schedule_status == "INACTIVE" + assert shell.inactivity_days is None + assert shell.lifecycle_action is None + assert shell.applies_to_groups == [] + assert shell.policy_id == "pol-empty" + + def test_rule_typed_failure_triggers_raw_fallback_for_all_policies(self): + # When the typed `list_policies` succeeds but the typed + # `list_policy_rules` fails for a policy, the previous behavior + # was to emit a shell automation — silently misclassifying a + # valid automation as "unfinished". Now `_fetch_rules` returns + # None as a sentinel and the caller re-runs the entire + # discovery via raw JSON so no rule data is lost. + provider = set_mocked_okta_provider() + + typed_policy = _fake_policy( + "pol-1", name="TestCheck", inactivity_days=35, groups=["everyone"] + ) + + async def fake_list_policies(*_a, **_k): + return ([typed_policy], _resp({}), None) + + async def failing_list_policy_rules(*_a, **_k): + raise ValueError("KnowledgeConstraint.types expected uppercase") + + async def fake_list_idps(*_a, **_k): + return ([], _resp({}), None) + + raw_policy_payload = [ + { + "id": "pol-1", + "name": "TestCheck", + "status": "ACTIVE", + "type": "USER_LIFECYCLE", + "conditions": { + "people": { + "users": {"inactivity": {"number": 35, "unit": "DAYS"}}, + "groups": {"include": ["everyone"]}, + } + }, + } + ] + raw_rules_payload = [ + { + "id": "rule-1", + "name": "lifecycle-rule-1", + "status": "ACTIVE", + "actions": {"updateUserLifecycle": {"targetStatus": "SUSPENDED"}}, + } + ] + + async def fake_raw_create(*_a, **kwargs): + return ({"url": kwargs.get("url", "") or ""}, None) + + async def fake_raw_execute(request): + url = request.get("url", "") + if "/api/v1/policies/pol-1/rules" in url: + return (None, json.dumps(raw_rules_payload), None) + if "/api/v1/policies" in url: + return (None, json.dumps(raw_policy_payload), None) + return (None, "[]", None) + + sdk = mock.MagicMock() + sdk.list_policies = fake_list_policies + sdk.list_policy_rules = failing_list_policy_rules + sdk.list_identity_providers = fake_list_idps + sdk._request_executor.create_request = fake_raw_create + sdk._request_executor.execute = fake_raw_execute + + with mock.patch( + "prowler.providers.okta.lib.service.service.OktaSDKClient", + return_value=sdk, + ): + service = User(provider) + + # Raw-projected automation, not a shell. + assert "rule-1" in service.automations + assert service.automations["rule-1"].inactivity_days == 35 + assert service.automations["rule-1"].lifecycle_action == "SUSPENDED" diff --git a/tests/providers/openstack/openstack_provider_test.py b/tests/providers/openstack/openstack_provider_test.py index 654d109134..b36fcd97a5 100644 --- a/tests/providers/openstack/openstack_provider_test.py +++ b/tests/providers/openstack/openstack_provider_test.py @@ -631,8 +631,7 @@ class TestOpenstackProviderCloudsYaml: def test_clouds_yaml_explicit_file_path(self, tmp_path): """Test loading clouds.yaml from an explicit file path.""" clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: test-cloud: auth: @@ -644,8 +643,7 @@ clouds: project_domain_name: YamlProjectDomain region_name: RegionOne identity_api_version: 3 -""" - ) +""") mock_connection = MagicMock() mock_connection.authorize.return_value = None @@ -681,8 +679,7 @@ clouds: def test_clouds_yaml_with_explicit_cloud_name(self, tmp_path): """Test loading clouds.yaml with an explicit cloud name.""" clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: default-cloud: auth: @@ -692,8 +689,7 @@ clouds: project_id: default-project-id region_name: RegionOne identity_api_version: 3 -""" - ) +""") mock_connection = MagicMock() mock_connection.authorize.return_value = None @@ -719,8 +715,7 @@ clouds: def test_clouds_yaml_file_without_cloud_name(self, tmp_path): """Test error when clouds.yaml file is provided without cloud name.""" clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: test-cloud: auth: @@ -729,8 +724,7 @@ clouds: password: test-password project_id: test-project-id region_name: RegionOne -""" - ) +""") with pytest.raises(OpenStackInvalidConfigError) as excinfo: OpenstackProvider(clouds_yaml_file=str(clouds_yaml)) @@ -750,8 +744,7 @@ clouds: def test_clouds_yaml_cloud_not_found(self, tmp_path): """Test error when specified cloud is not in clouds.yaml.""" clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: existing-cloud: auth: @@ -760,8 +753,7 @@ clouds: password: test-password project_id: test-project-id region_name: RegionOne -""" - ) +""") with pytest.raises(OpenStackCloudNotFoundError) as excinfo: OpenstackProvider( @@ -774,8 +766,7 @@ clouds: def test_clouds_yaml_missing_required_fields(self, tmp_path): """Test error when clouds.yaml is missing required fields.""" clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: incomplete-cloud: auth: @@ -783,8 +774,7 @@ clouds: username: test-user # Missing password and other required fields region_name: RegionOne -""" - ) +""") with pytest.raises(OpenStackInvalidConfigError) as excinfo: OpenstackProvider( @@ -798,16 +788,14 @@ clouds: def test_clouds_yaml_malformed_yaml(self, tmp_path): """Test error when clouds.yaml is malformed.""" clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: malformed-cloud: auth: auth_url: https://openstack.example.com:5000/v3 username: test-user - invalid: yaml: structure -""" - ) +""") with pytest.raises(OpenStackInvalidConfigError): OpenstackProvider( @@ -818,8 +806,7 @@ clouds: def test_clouds_yaml_with_project_name(self, tmp_path): """Test clouds.yaml using project_name instead of project_id.""" clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: test-cloud: auth: @@ -831,8 +818,7 @@ clouds: project_domain_name: Default region_name: RegionOne identity_api_version: 3 -""" - ) +""") mock_connection = MagicMock() mock_connection.authorize.return_value = None @@ -863,8 +849,7 @@ clouds: monkeypatch.setenv("OS_REGION_NAME", "EnvRegion") clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: test-cloud: auth: @@ -874,8 +859,7 @@ clouds: project_id: yaml-project-id region_name: YamlRegion identity_api_version: 3 -""" - ) +""") mock_connection = MagicMock() mock_connection.authorize.return_value = None @@ -902,8 +886,7 @@ clouds: def test_test_connection_with_clouds_yaml(self, tmp_path): """Test static test_connection method with clouds.yaml.""" clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: test-cloud: auth: @@ -913,8 +896,7 @@ clouds: project_id: test-project-id region_name: RegionOne identity_api_version: 3 -""" - ) +""") mock_connection = MagicMock() mock_connection.authorize.return_value = None @@ -950,8 +932,7 @@ clouds: def test_test_connection_clouds_yaml_cloud_not_found(self, tmp_path): """Test test_connection error when cloud is not in clouds.yaml.""" clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: existing-cloud: auth: @@ -960,8 +941,7 @@ clouds: password: test-password project_id: test-project-id region_name: RegionOne -""" - ) +""") connection_result = OpenstackProvider.test_connection( clouds_yaml_file=str(clouds_yaml), @@ -1139,8 +1119,7 @@ clouds: def test_clouds_yaml_file_with_regions_list(self, tmp_path): """Test loading clouds.yaml file with regions list.""" clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: test-cloud: auth: @@ -1152,8 +1131,7 @@ clouds: - RegionOne - RegionTwo identity_api_version: 3 -""" - ) +""") mock_connection = MagicMock() mock_connection.authorize.return_value = None @@ -1177,8 +1155,7 @@ clouds: def test_clouds_yaml_file_with_both_regions_raises_error(self, tmp_path): """Test that clouds.yaml file with both region_name and regions raises error.""" clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: test-cloud: auth: @@ -1191,8 +1168,7 @@ clouds: - RegionOne - RegionTwo identity_api_version: 3 -""" - ) +""") with pytest.raises(OpenStackAmbiguousRegionError): OpenstackProvider( @@ -1203,8 +1179,7 @@ clouds: def test_clouds_yaml_file_with_no_region_raises_error(self, tmp_path): """Test that clouds.yaml file with neither region_name nor regions raises error.""" clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: test-cloud: auth: @@ -1213,8 +1188,7 @@ clouds: password: test-password project_id: test-project-id identity_api_version: 3 -""" - ) +""") with pytest.raises(OpenStackNoRegionError): OpenstackProvider( diff --git a/tests/providers/oraclecloud/services/identity/identity_storage_service_level_admins_scoped/identity_storage_service_level_admins_scoped_test.py b/tests/providers/oraclecloud/services/identity/identity_storage_service_level_admins_scoped/identity_storage_service_level_admins_scoped_test.py new file mode 100644 index 0000000000..5b974c893c --- /dev/null +++ b/tests/providers/oraclecloud/services/identity/identity_storage_service_level_admins_scoped/identity_storage_service_level_admins_scoped_test.py @@ -0,0 +1,326 @@ +from datetime import datetime +from unittest import mock + +import pytest + +from prowler.lib.check.models import Check_Report_OCI +from prowler.providers.oraclecloud.services.identity.identity_service import Policy +from tests.providers.oraclecloud.oci_fixtures import ( + OCI_COMPARTMENT_ID, + OCI_REGION, + OCI_TENANCY_ID, + set_mocked_oraclecloud_provider, +) + +CHECK_PATH = "prowler.providers.oraclecloud.services.identity.identity_storage_service_level_admins_scoped.identity_storage_service_level_admins_scoped" + + +def _policy( + name: str, statements: list[str], lifecycle_state: str = "ACTIVE" +) -> Policy: + return Policy( + id=f"ocid1.policy.oc1..{name.lower().replace(' ', '-')}", + name=name, + description="Test policy", + compartment_id=OCI_COMPARTMENT_ID, + statements=statements, + time_created=datetime.now(), + lifecycle_state=lifecycle_state, + region=OCI_REGION, + ) + + +def _identity_client(policies: list[Policy]) -> mock.MagicMock: + identity_client = mock.MagicMock() + identity_client.policies = policies + identity_client.audited_tenancy = OCI_TENANCY_ID + identity_client.audited_regions = [mock.MagicMock(key=OCI_REGION)] + return identity_client + + +def _run_check(policies: list[Policy]) -> list[Check_Report_OCI]: + identity_client = _identity_client(policies) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_oraclecloud_provider(), + ), + mock.patch(f"{CHECK_PATH}.identity_client", new=identity_client), + ): + from prowler.providers.oraclecloud.services.identity.identity_storage_service_level_admins_scoped.identity_storage_service_level_admins_scoped import ( + identity_storage_service_level_admins_scoped, + ) + + return identity_storage_service_level_admins_scoped().execute() + + +class Test_identity_storage_service_level_admins_scoped: + def test_no_policies_passes_with_tenancy_finding(self): + result = _run_check([]) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == OCI_TENANCY_ID + assert result[0].resource_name == "Tenancy" + assert ( + result[0].status_extended + == "No active storage service-level administrator policies grant manage permissions without excluding delete permissions." + ) + + def test_manage_volumes_without_delete_exclusion_fails(self): + result = _run_check( + [ + _policy( + "Volume Admins", + ["Allow group VolumeUsers to manage volumes in tenancy"], + ) + ] + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_name == "Volume Admins" + assert "VOLUME_DELETE" in result[0].status_extended + assert ( + "Allow group VolumeUsers to manage volumes in tenancy" + in result[0].status_extended + ) + + def test_manage_volumes_with_delete_exclusion_passes(self): + result = _run_check( + [ + _policy( + "Volume Admins", + [ + "Allow group VolumeUsers to manage volumes in tenancy where request.permission!='VOLUME_DELETE'" + ], + ) + ] + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Policy 'Volume Admins' excludes required storage delete permissions from storage manage statements." + ) + + def test_delete_exclusion_parser_is_case_and_whitespace_insensitive(self): + result = _run_check( + [ + _policy( + "Volume Admins", + [ + " allow group VolumeUsers TO manage volumes in tenancy WHERE request.permission != 'volume_delete' " + ], + ) + ] + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_generic_where_clause_does_not_pass(self): + result = _run_check( + [ + _policy( + "Bucket Admins", + [ + "Allow group BucketUsers to manage buckets in tenancy where request.region='iad'" + ], + ) + ] + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "BUCKET_DELETE" in result[0].status_extended + assert "request.region='iad'" in result[0].status_extended + + @pytest.mark.parametrize( + "statement", + [ + "Allow group BucketUsers to manage buckets in tenancy where ANY {request.permission!='BUCKET_DELETE', request.region='iad'}", + "Allow group BucketUsers to manage buckets in tenancy where request.permission!='BUCKET_DELETE' OR request.region='iad'", + ], + ) + def test_disjunctive_delete_exclusion_does_not_pass(self, statement): + result = _run_check([_policy("Bucket Admins", [statement])]) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "BUCKET_DELETE" in result[0].status_extended + + def test_quoted_literals_do_not_make_delete_exclusion_disjunctive(self): + result = _run_check( + [ + _policy( + "Bucket Admins", + [ + "Allow group BucketUsers to manage buckets in tenancy where request.permission!='BUCKET_DELETE' and target.tag.namespace='any-tag'" + ], + ) + ] + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + + @pytest.mark.parametrize( + "resource,permission", + [ + ("file-systems", "FILE_SYSTEM_DELETE"), + ("mount-targets", "MOUNT_TARGET_DELETE"), + ("export-sets", "EXPORT_SET_DELETE"), + ("volumes", "VOLUME_DELETE"), + ("volume-backups", "VOLUME_BACKUP_DELETE"), + ("objects", "OBJECT_DELETE"), + ("buckets", "BUCKET_DELETE"), + ], + ) + def test_storage_resources_require_matching_delete_exclusion( + self, resource, permission + ): + fail_result = _run_check( + [ + _policy( + "Storage Admins", + [f"Allow group StorageUsers to manage {resource} in tenancy"], + ) + ] + ) + pass_result = _run_check( + [ + _policy( + "Storage Admins", + [ + f"Allow group StorageUsers to manage {resource} in tenancy where request.permission != '{permission}'" + ], + ) + ] + ) + + assert len(fail_result) == 1 + assert fail_result[0].status == "FAIL" + assert permission in fail_result[0].status_extended + assert len(pass_result) == 1 + assert pass_result[0].status == "PASS" + + def test_file_family_fails_until_all_delete_permissions_are_excluded(self): + partial_result = _run_check( + [ + _policy( + "File Admins", + [ + "Allow group FileUsers to manage file-family in tenancy where ALL {request.permission!='FILE_SYSTEM_DELETE', request.permission!='MOUNT_TARGET_DELETE'}" + ], + ) + ] + ) + complete_result = _run_check( + [ + _policy( + "File Admins", + [ + "Allow group FileUsers to manage file-family in tenancy where ALL {request.permission!='FILE_SYSTEM_DELETE', request.permission!='MOUNT_TARGET_DELETE', request.permission!='EXPORT_SET_DELETE'}" + ], + ) + ] + ) + + assert len(partial_result) == 1 + assert partial_result[0].status == "FAIL" + assert "EXPORT_SET_DELETE" in partial_result[0].status_extended + assert len(complete_result) == 1 + assert complete_result[0].status == "PASS" + + @pytest.mark.parametrize( + "family,missing_permission,statement", + [ + ( + "volume-family", + "VOLUME_BACKUP_DELETE", + "Allow group VolumeUsers to manage volume-family in tenancy where request.permission!='VOLUME_DELETE'", + ), + ( + "object-family", + "BUCKET_DELETE", + "Allow group BucketUsers to manage object-family in tenancy where request.permission!='OBJECT_DELETE'", + ), + ( + "all-resources", + "BUCKET_DELETE", + "Allow group StorageUsers to manage all-resources in tenancy where ALL {request.permission!='VOLUME_DELETE', request.permission!='VOLUME_BACKUP_DELETE', request.permission!='FILE_SYSTEM_DELETE', request.permission!='MOUNT_TARGET_DELETE', request.permission!='EXPORT_SET_DELETE', request.permission!='OBJECT_DELETE'}", + ), + ], + ) + def test_families_and_all_resources_fail_unless_all_delete_permissions_are_excluded( + self, family, missing_permission, statement + ): + result = _run_check([_policy("Storage Admins", [statement])]) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert family in result[0].status_extended + assert missing_permission in result[0].status_extended + + def test_all_resources_passes_when_all_storage_delete_permissions_are_excluded( + self, + ): + result = _run_check( + [ + _policy( + "Storage Admins", + [ + "Allow group StorageUsers to manage all-resources in tenancy where ALL {request.permission!='VOLUME_DELETE', request.permission!='VOLUME_BACKUP_DELETE', request.permission!='FILE_SYSTEM_DELETE', request.permission!='MOUNT_TARGET_DELETE', request.permission!='EXPORT_SET_DELETE', request.permission!='OBJECT_DELETE', request.permission!='BUCKET_DELETE'}" + ], + ) + ] + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_inactive_policies_are_ignored(self): + result = _run_check( + [ + _policy( + "Inactive Volume Admins", + ["Allow group VolumeUsers to manage volumes in tenancy"], + lifecycle_state="INACTIVE", + ) + ] + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_name == "Tenancy" + + def test_tenant_admin_policy_is_ignored(self): + result = _run_check( + [ + _policy( + "Tenant Admin Policy", + ["Allow group Administrators to manage all-resources in tenancy"], + ) + ] + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_name == "Tenancy" + + def test_policies_without_storage_manage_statements_are_ignored(self): + result = _run_check( + [ + _policy( + "Network Admins", + ["Allow group NetworkUsers to manage vcns in tenancy"], + ) + ] + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_name == "Tenancy" diff --git a/tests/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration_test.py b/tests/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration_test.py new file mode 100644 index 0000000000..2d1e6b7875 --- /dev/null +++ b/tests/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration_test.py @@ -0,0 +1,150 @@ +from unittest import mock + +from prowler.providers.stackit.services.objectstorage.objectstorage_service import ( + AccessKey, +) +from tests.providers.stackit.stackit_fixtures import ( + STACKIT_PROJECT_ID, + set_mocked_stackit_provider, +) + + +class Test_objectstorage_access_key_expiration: + def test_no_access_keys(self): + objectstorage_client = mock.MagicMock + objectstorage_client.access_keys = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_access_key_expiration.objectstorage_access_key_expiration import ( + objectstorage_access_key_expiration, + ) + + check = objectstorage_access_key_expiration() + result = check.execute() + assert len(result) == 0 + + def test_access_key_with_expiration(self): + objectstorage_client = mock.MagicMock + objectstorage_client.access_keys = [ + AccessKey( + key_id="key-123", + display_name="my-key", + expires="2027-01-01T00:00:00+00:00", + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_access_key_expiration.objectstorage_access_key_expiration import ( + objectstorage_access_key_expiration, + ) + + check = objectstorage_access_key_expiration() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "has an expiration date set" in result[0].status_extended + assert result[0].resource_id == "key-123" + assert result[0].resource_name == "my-key" + assert result[0].location == "eu01" + + def test_access_key_no_expiration_none(self): + objectstorage_client = mock.MagicMock + objectstorage_client.access_keys = [ + AccessKey( + key_id="key-456", + display_name="never-expiring-key", + expires=None, + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_access_key_expiration.objectstorage_access_key_expiration import ( + objectstorage_access_key_expiration, + ) + + check = objectstorage_access_key_expiration() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "no expiration date" in result[0].status_extended + assert result[0].resource_id == "key-456" + + def test_access_key_no_expiration_sentinel(self): + """Year-0001 date is the SDK sentinel for 'never expires'.""" + objectstorage_client = mock.MagicMock + objectstorage_client.access_keys = [ + AccessKey( + key_id="key-789", + display_name="sentinel-key", + expires="0001-01-01T00:00:00+00:00", + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_access_key_expiration.objectstorage_access_key_expiration import ( + objectstorage_access_key_expiration, + ) + + check = objectstorage_access_key_expiration() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "no expiration date" in result[0].status_extended diff --git a/tests/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled_test.py b/tests/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled_test.py new file mode 100644 index 0000000000..a802dac78f --- /dev/null +++ b/tests/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled_test.py @@ -0,0 +1,111 @@ +from unittest import mock + +from prowler.providers.stackit.services.objectstorage.objectstorage_service import ( + Bucket, +) +from tests.providers.stackit.stackit_fixtures import ( + STACKIT_PROJECT_ID, + set_mocked_stackit_provider, +) + + +class Test_objectstorage_bucket_object_lock_enabled: + def test_no_buckets(self): + objectstorage_client = mock.MagicMock + objectstorage_client.buckets = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_bucket_object_lock_enabled.objectstorage_bucket_object_lock_enabled import ( + objectstorage_bucket_object_lock_enabled, + ) + + check = objectstorage_bucket_object_lock_enabled() + result = check.execute() + assert len(result) == 0 + + def test_bucket_object_lock_enabled(self): + objectstorage_client = mock.MagicMock + objectstorage_client.buckets = [ + Bucket( + name="my-bucket", + region="eu01", + project_id=STACKIT_PROJECT_ID, + object_lock_enabled=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_bucket_object_lock_enabled.objectstorage_bucket_object_lock_enabled import ( + objectstorage_bucket_object_lock_enabled, + ) + + check = objectstorage_bucket_object_lock_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "has S3 Object Lock enabled" in result[0].status_extended + assert result[0].resource_id == "my-bucket" + assert result[0].resource_name == "my-bucket" + assert result[0].location == "eu01" + + def test_bucket_object_lock_disabled(self): + objectstorage_client = mock.MagicMock + objectstorage_client.buckets = [ + Bucket( + name="my-bucket", + region="eu01", + project_id=STACKIT_PROJECT_ID, + object_lock_enabled=False, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_bucket_object_lock_enabled.objectstorage_bucket_object_lock_enabled import ( + objectstorage_bucket_object_lock_enabled, + ) + + check = objectstorage_bucket_object_lock_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "does not have S3 Object Lock enabled" in result[0].status_extended + assert result[0].resource_id == "my-bucket" diff --git a/tests/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy_test.py b/tests/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy_test.py new file mode 100644 index 0000000000..59fdf73370 --- /dev/null +++ b/tests/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy_test.py @@ -0,0 +1,153 @@ +from unittest import mock + +from prowler.providers.stackit.services.objectstorage.objectstorage_service import ( + Bucket, +) +from tests.providers.stackit.stackit_fixtures import ( + STACKIT_PROJECT_ID, + set_mocked_stackit_provider, +) + + +class Test_objectstorage_bucket_retention_policy: + def test_no_buckets(self): + objectstorage_client = mock.MagicMock + objectstorage_client.buckets = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_bucket_retention_policy.objectstorage_bucket_retention_policy import ( + objectstorage_bucket_retention_policy, + ) + + check = objectstorage_bucket_retention_policy() + result = check.execute() + assert len(result) == 0 + + def test_bucket_with_retention_policy(self): + objectstorage_client = mock.MagicMock + objectstorage_client.buckets = [ + Bucket( + name="my-bucket", + region="eu01", + project_id=STACKIT_PROJECT_ID, + object_lock_enabled=True, + retention_days=30, + retention_mode="COMPLIANCE", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_bucket_retention_policy.objectstorage_bucket_retention_policy import ( + objectstorage_bucket_retention_policy, + ) + + check = objectstorage_bucket_retention_policy() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "30 day(s)" in result[0].status_extended + assert "COMPLIANCE" in result[0].status_extended + assert result[0].resource_id == "my-bucket" + assert result[0].location == "eu01" + + def test_bucket_without_retention_policy(self): + objectstorage_client = mock.MagicMock + objectstorage_client.buckets = [ + Bucket( + name="my-bucket", + region="eu01", + project_id=STACKIT_PROJECT_ID, + object_lock_enabled=False, + retention_days=None, + retention_mode=None, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_bucket_retention_policy.objectstorage_bucket_retention_policy import ( + objectstorage_bucket_retention_policy, + ) + + check = objectstorage_bucket_retention_policy() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "does not have a default retention policy" in result[0].status_extended + ) + assert result[0].resource_id == "my-bucket" + + def test_bucket_retention_zero_days(self): + objectstorage_client = mock.MagicMock + objectstorage_client.buckets = [ + Bucket( + name="my-bucket", + region="eu01", + project_id=STACKIT_PROJECT_ID, + object_lock_enabled=True, + retention_days=0, + retention_mode="GOVERNANCE", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_bucket_retention_policy.objectstorage_bucket_retention_policy import ( + objectstorage_bucket_retention_policy, + ) + + check = objectstorage_bucket_retention_policy() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/stackit/services/objectstorage/stackit_objectstorage_service_test.py b/tests/providers/stackit/services/objectstorage/stackit_objectstorage_service_test.py new file mode 100644 index 0000000000..59f6559a54 --- /dev/null +++ b/tests/providers/stackit/services/objectstorage/stackit_objectstorage_service_test.py @@ -0,0 +1,645 @@ +from types import SimpleNamespace +from unittest import mock + +import pytest + +from prowler.providers.stackit.services.objectstorage.objectstorage_service import ( + AccessKey, + ObjectStorageService, +) +from tests.providers.stackit.stackit_fixtures import STACKIT_PROJECT_ID + + +class TestObjectStorageService: + def test_list_buckets_keeps_bucket_when_retention_not_configured(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + + not_found_error = Exception("not found") + not_found_error.status = 404 + + client = mock.MagicMock() + client.list_buckets.return_value = SimpleNamespace( + buckets=[ + SimpleNamespace( + name="my-bucket", + object_lock_enabled=True, + ) + ] + ) + client.get_default_retention.side_effect = not_found_error + + service._list_buckets(client, "eu01") + + assert len(service.buckets) == 1 + assert service.buckets[0].name == "my-bucket" + assert service.buckets[0].object_lock_enabled is True + assert service.buckets[0].retention_days is None + assert service.buckets[0].retention_mode is None + + def test_list_buckets_propagates_unexpected_retention_api_errors(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + + api_error = Exception("service unavailable") + api_error.status = 503 + + client = mock.MagicMock() + client.list_buckets.return_value = SimpleNamespace( + buckets=[ + SimpleNamespace( + name="my-bucket", + object_lock_enabled=True, + ) + ] + ) + client.get_default_retention.side_effect = api_error + + with pytest.raises(Exception, match="service unavailable"): + service._list_buckets(client, "eu01") + + assert service.buckets == [] + service.provider.handle_api_error.assert_called_once_with(api_error) + + def test_init_creates_service_with_no_regions(self): + provider = mock.MagicMock() + provider.identity.project_id = STACKIT_PROJECT_ID + provider.generate_regional_clients.return_value = {} + + service = ObjectStorageService(provider) + + assert service.project_id == STACKIT_PROJECT_ID + assert service.buckets == [] + assert service.access_keys == [] + provider.generate_regional_clients.assert_called_once_with("objectstorage") + + def test_fetch_all_regions_skips_404_region(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + service.access_keys = [] + + not_found = Exception("not found") + not_found.status = 404 + service.regional_clients = {"eu01": mock.MagicMock()} + + with mock.patch.object(service, "_list_buckets", side_effect=not_found): + service._fetch_all_regions() + + assert service.buckets == [] + + def test_fetch_all_regions_reraises_non_404_error(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + service.access_keys = [] + + server_error = Exception("internal server error") + server_error.status = 500 + service.regional_clients = {"eu01": mock.MagicMock()} + + with mock.patch.object(service, "_list_buckets", side_effect=server_error): + with pytest.raises(Exception, match="internal server error"): + service._fetch_all_regions() + + def test_list_buckets_with_dict_api_response(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + + not_found = Exception("not found") + not_found.status = 404 + + client = mock.MagicMock() + client.list_buckets.return_value = { + "buckets": [ + SimpleNamespace(name="dict-response-bucket", object_lock_enabled=True) + ] + } + client.get_default_retention.side_effect = not_found + + service._list_buckets(client, "eu01") + + assert len(service.buckets) == 1 + assert service.buckets[0].name == "dict-response-bucket" + + def test_list_buckets_with_dict_bucket_data(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + + not_found = Exception("not found") + not_found.status = 404 + + client = mock.MagicMock() + client.list_buckets.return_value = SimpleNamespace( + buckets=[{"name": "dict-bucket", "objectLockEnabled": True}] + ) + client.get_default_retention.side_effect = not_found + + service._list_buckets(client, "eu01") + + assert len(service.buckets) == 1 + assert service.buckets[0].name == "dict-bucket" + assert service.buckets[0].object_lock_enabled is True + + def test_list_buckets_skips_unknown_bucket_type(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + + client = mock.MagicMock() + client.list_buckets.return_value = SimpleNamespace(buckets=[42]) + + service._list_buckets(client, "eu01") + + assert len(service.buckets) == 0 + + def test_get_default_retention_with_dict_response(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + + client = mock.MagicMock() + client.get_default_retention.return_value = {"days": 14, "mode": "GOVERNANCE"} + + days, mode = service._get_default_retention(client, "eu01", "my-bucket") + + assert days == 14 + assert mode == "GOVERNANCE" + + def test_list_access_keys_with_object_data(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[SimpleNamespace(id="cg-001", display_name="main-group")] + ) + client.list_access_keys.return_value = SimpleNamespace( + access_keys=[ + SimpleNamespace( + key_id="key-001", + display_name="my-key", + expires="2027-01-01T00:00:00+00:00", + ) + ] + ) + + service._list_access_keys(client, "eu01") + + client.list_credentials_groups.assert_called_once_with( + project_id=STACKIT_PROJECT_ID, region="eu01" + ) + client.list_access_keys.assert_called_once_with( + project_id=STACKIT_PROJECT_ID, region="eu01", credentials_group="cg-001" + ) + assert len(service.access_keys) == 1 + assert service.access_keys[0].key_id == "key-001" + assert service.access_keys[0].display_name == "my-key" + assert service.access_keys[0].region == "eu01" + assert service.access_keys[0].expires == "2027-01-01T00:00:00+00:00" + assert service.access_keys[0].credentials_group_id == "cg-001" + assert service.access_keys[0].credentials_group_name == "main-group" + + def test_list_access_keys_with_credentials_group_id_object_data(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[ + SimpleNamespace( + credentials_group_id="cg-sdk", + display_name="sdk-group", + ) + ] + ) + client.list_access_keys.return_value = SimpleNamespace(access_keys=[]) + + service._list_access_keys(client, "eu01") + + client.list_access_keys.assert_called_once_with( + project_id=STACKIT_PROJECT_ID, region="eu01", credentials_group="cg-sdk" + ) + + def test_list_access_keys_collects_keys_from_multiple_credentials_groups(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[ + SimpleNamespace(id="cg-001", display_name="group-one"), + SimpleNamespace(id="cg-002", display_name="group-two"), + ] + ) + client.list_access_keys.side_effect = [ + SimpleNamespace( + access_keys=[ + SimpleNamespace( + key_id="key-001", + display_name="key-one", + expires="2027-01-01T00:00:00+00:00", + ) + ] + ), + SimpleNamespace( + access_keys=[ + SimpleNamespace( + key_id="key-002", + display_name="key-two", + expires=None, + ) + ] + ), + ] + + service._list_access_keys(client, "eu01") + + assert client.list_access_keys.call_args_list == [ + mock.call( + project_id=STACKIT_PROJECT_ID, + region="eu01", + credentials_group="cg-001", + ), + mock.call( + project_id=STACKIT_PROJECT_ID, + region="eu01", + credentials_group="cg-002", + ), + ] + assert [key.key_id for key in service.access_keys] == ["key-001", "key-002"] + assert service.access_keys[1].expires is None + assert service.access_keys[1].has_expiration() is False + assert [key.credentials_group_id for key in service.access_keys] == [ + "cg-001", + "cg-002", + ] + + def test_list_access_keys_with_dict_api_response(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = { + "credentialsGroups": [{"id": "cg-dict", "displayName": "dict-group"}] + } + client.list_access_keys.return_value = { + "accessKeys": [ + {"keyId": "key-dict", "displayName": "dict-key", "expires": None} + ] + } + + service._list_access_keys(client, "eu01") + + assert len(service.access_keys) == 1 + assert service.access_keys[0].key_id == "key-dict" + assert service.access_keys[0].display_name == "dict-key" + assert service.access_keys[0].expires is None + assert service.access_keys[0].has_expiration() is False + assert service.access_keys[0].credentials_group_id == "cg-dict" + assert service.access_keys[0].credentials_group_name == "dict-group" + + def test_list_access_keys_with_raw_json_response_and_null_expires(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + class RawResponse: + status = 200 + + def json(self): + return { + "accessKeys": [ + { + "keyId": "key-raw", + "displayName": "raw-key", + "expires": None, + } + ] + } + + class FakeClient: + def __init__(self): + self.list_credentials_groups = mock.MagicMock( + return_value=SimpleNamespace( + credentials_groups=[SimpleNamespace(id="cg-raw")] + ) + ) + self.list_access_keys = mock.MagicMock() + self.raw_call = None + + def list_access_keys_without_preload_content(self, **kwargs): + self.raw_call = kwargs + return RawResponse() + + client = FakeClient() + + service._list_access_keys(client, "eu01") + + assert client.raw_call == { + "project_id": STACKIT_PROJECT_ID, + "region": "eu01", + "credentials_group": "cg-raw", + } + client.list_access_keys.assert_not_called() + assert len(service.access_keys) == 1 + assert service.access_keys[0].key_id == "key-raw" + assert service.access_keys[0].expires is None + assert service.access_keys[0].has_expiration() is False + + def test_list_access_keys_with_raw_data_response(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + class RawResponse: + status = 200 + data = b'{"accessKeys":[{"keyId":"key-data","displayName":"data-key"}]}' + + class FakeClient: + def __init__(self): + self.list_credentials_groups = mock.MagicMock( + return_value=SimpleNamespace( + credentials_groups=[SimpleNamespace(id="cg-data")] + ) + ) + + def list_access_keys_without_preload_content(self, **kwargs): + return RawResponse() + + service._list_access_keys(FakeClient(), "eu01") + + assert len(service.access_keys) == 1 + assert service.access_keys[0].key_id == "key-data" + assert service.access_keys[0].display_name == "data-key" + + def test_list_access_keys_raw_response_propagates_non_success_status(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + class RawResponse: + status = 503 + + class FakeClient: + def __init__(self): + self.list_credentials_groups = mock.MagicMock( + return_value=SimpleNamespace( + credentials_groups=[SimpleNamespace(id="cg-error")] + ) + ) + + def list_access_keys_without_preload_content(self, **kwargs): + return RawResponse() + + with pytest.raises(Exception, match="status 503") as error: + service._list_access_keys(FakeClient(), "eu01") + + assert error.value.status == 503 + service.provider.handle_api_error.assert_called_once_with(error.value) + + def test_list_access_keys_with_dict_key_data(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[{"id": "cg-456", "displayName": "group-456"}] + ) + client.list_access_keys.return_value = SimpleNamespace( + access_keys=[ + { + "keyId": "key-456", + "displayName": "my-dict-key", + "expires": "2028-06-01T00:00:00+00:00", + } + ] + ) + + service._list_access_keys(client, "eu01") + + assert len(service.access_keys) == 1 + assert service.access_keys[0].key_id == "key-456" + assert service.access_keys[0].display_name == "my-dict-key" + assert service.access_keys[0].credentials_group_id == "cg-456" + + def test_list_access_keys_skips_unknown_type(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[SimpleNamespace(id="cg-001")] + ) + client.list_access_keys.return_value = SimpleNamespace(access_keys=[42]) + + service._list_access_keys(client, "eu01") + + assert len(service.access_keys) == 0 + + def test_list_access_keys_no_keys(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[SimpleNamespace(id="cg-empty")] + ) + client.list_access_keys.return_value = SimpleNamespace(access_keys=[]) + + service._list_access_keys(client, "eu01") + + assert len(service.access_keys) == 0 + + def test_list_access_keys_no_credentials_groups(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[] + ) + + service._list_access_keys(client, "eu01") + + assert len(service.access_keys) == 0 + client.list_access_keys.assert_not_called() + + def test_list_access_keys_skips_malformed_credentials_groups(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[ + 42, + {}, + SimpleNamespace(id="cg-valid", display_name="valid-group"), + ] + ) + client.list_access_keys.return_value = SimpleNamespace( + access_keys=[SimpleNamespace(key_id="key-valid")] + ) + + service._list_access_keys(client, "eu01") + + client.list_access_keys.assert_called_once_with( + project_id=STACKIT_PROJECT_ID, region="eu01", credentials_group="cg-valid" + ) + assert len(service.access_keys) == 1 + assert service.access_keys[0].key_id == "key-valid" + + def test_fetch_all_regions_calls_both_list_methods(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + service.access_keys = [] + + service.regional_clients = {"eu01": mock.MagicMock()} + + with ( + mock.patch.object(service, "_list_buckets") as mock_buckets, + mock.patch.object(service, "_list_access_keys") as mock_keys, + ): + service._fetch_all_regions() + + mock_buckets.assert_called_once() + mock_keys.assert_called_once() + + def test_list_buckets_handles_bucket_processing_error(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + + class BrokenBucket: + @property + def name(self): + raise RuntimeError("broken bucket attribute") + + client = mock.MagicMock() + client.list_buckets.return_value = SimpleNamespace(buckets=[BrokenBucket()]) + + service._list_buckets(client, "eu01") + + assert len(service.buckets) == 0 + + def test_list_access_keys_handles_key_processing_error(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + class BrokenKey: + @property + def key_id(self): + raise RuntimeError("broken key attribute") + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[SimpleNamespace(id="cg-001")] + ) + client.list_access_keys.return_value = SimpleNamespace( + access_keys=[BrokenKey()] + ) + + service._list_access_keys(client, "eu01") + + assert len(service.access_keys) == 0 + + +class TestAccessKeyModel: + def test_has_expiration_with_invalid_date_string(self): + key = AccessKey( + key_id="k", + display_name="k", + expires="not-a-valid-date", + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + assert key.has_expiration() is False + + def test_expires_within_days_when_no_expiration(self): + key = AccessKey( + key_id="k", + display_name="k", + expires=None, + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + assert key.expires is None + assert key.has_expiration() is False + assert key.expires_within_days(90) is False + + def test_expires_within_days_when_expiring_soon(self): + key = AccessKey( + key_id="k", + display_name="k", + expires="2026-06-15T00:00:00+00:00", + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + assert key.expires_within_days(90) is True + + def test_expires_within_days_when_not_expiring_soon(self): + key = AccessKey( + key_id="k", + display_name="k", + expires="2030-01-01T00:00:00+00:00", + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + assert key.expires_within_days(30) is False + + def test_expires_within_days_with_naive_datetime(self): + key = AccessKey( + key_id="k", + display_name="k", + expires="2026-06-10T00:00:00", + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + assert key.expires_within_days(90) is True + + def test_expires_within_days_with_sentinel_key(self): + key = AccessKey( + key_id="k", + display_name="k", + expires="0001-01-01T00:00:00+00:00", + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + assert key.expires_within_days(90) is False diff --git a/tests/providers/stackit/stackit_provider_test.py b/tests/providers/stackit/stackit_provider_test.py index 8bcbe17f84..13bc7f4698 100644 --- a/tests/providers/stackit/stackit_provider_test.py +++ b/tests/providers/stackit/stackit_provider_test.py @@ -411,3 +411,68 @@ class Test_StackitProvider_Handle_API_Error: with pytest.raises(RuntimeError) as excinfo: StackitProvider.handle_api_error(original) assert excinfo.value is original + + +class TestGenerateRegionalClients: + """Tests for StackitProvider.generate_regional_clients.""" + + def _make_provider(self): + provider = object.__new__(StackitProvider) + provider._service_account_key_path = "/tmp/sa-key.json" + provider._service_account_key = None + provider._audited_regions = None + return provider + + def _fake_classes(self): + class FakeConfig: + pass + + class FakeIaasClient: + def __init__(self, config): + pass + + class FakeObjStorageClient: + def __init__(self, config): + pass + + return FakeConfig, FakeIaasClient, FakeObjStorageClient + + def test_objectstorage_service_uses_objectstorage_api_class(self, monkeypatch): + FakeConfig, FakeIaasClient, FakeObjStorageClient = self._fake_classes() + + monkeypatch.setattr( + StackitProvider, + "_SERVICE_API_CLASS", + {"iaas": FakeIaasClient, "objectstorage": FakeObjStorageClient}, + ) + provider = self._make_provider() + monkeypatch.setattr( + provider, "get_available_service_regions", lambda _s, _r: ["eu01"] + ) + with patch.object( + StackitProvider, "_build_sdk_configuration", return_value=FakeConfig() + ): + clients = provider.generate_regional_clients("objectstorage") + + assert "eu01" in clients + assert isinstance(clients["eu01"], FakeObjStorageClient) + + def test_iaas_service_uses_iaas_api_class(self, monkeypatch): + FakeConfig, FakeIaasClient, FakeObjStorageClient = self._fake_classes() + + monkeypatch.setattr( + StackitProvider, + "_SERVICE_API_CLASS", + {"iaas": FakeIaasClient, "objectstorage": FakeObjStorageClient}, + ) + provider = self._make_provider() + monkeypatch.setattr( + provider, "get_available_service_regions", lambda _s, _r: ["eu01"] + ) + with patch.object( + StackitProvider, "_build_sdk_configuration", return_value=FakeConfig() + ): + clients = provider.generate_regional_clients("iaas") + + assert "eu01" in clients + assert isinstance(clients["eu01"], FakeIaasClient) diff --git a/ui/.pre-commit-config.yaml b/ui/.pre-commit-config.yaml index eab333ceea..3bc0c3b5e6 100644 --- a/ui/.pre-commit-config.yaml +++ b/ui/.pre-commit-config.yaml @@ -1,5 +1,10 @@ orphan: true +# Hooks run on commit only by default; +# NOTE: default_stages does NOT override a hook's manifest stages, so fixers shipping pre-push in their +# manifest need an explicit stages: ["pre-commit"] below to stay off push. +default_stages: [pre-commit] + repos: - repo: local hooks: diff --git a/ui/.prettierignore b/ui/.prettierignore index 5377d96f2d..8b679ff005 100644 --- a/ui/.prettierignore +++ b/ui/.prettierignore @@ -8,6 +8,7 @@ build/ coverage/ dist/ esm/ +CHANGELOG.md # Generated files next-env.d.ts diff --git a/ui/AGENTS.md b/ui/AGENTS.md index 38af45860f..7c06cc56b1 100644 --- a/ui/AGENTS.md +++ b/ui/AGENTS.md @@ -14,40 +14,46 @@ > - [`playwright`](../skills/playwright/SKILL.md) - Page Object Model, selectors > - [`vitest`](../skills/vitest/SKILL.md) - Unit testing with React Testing Library > - [`tdd`](../skills/tdd/SKILL.md) - TDD workflow (MANDATORY for UI tasks) +> - [`prowler-tour`](../skills/prowler-tour/SKILL.md) - Keep product-tour definitions aligned with the UI ## Auto-invoke Skills When performing these actions, ALWAYS invoke the corresponding skill FIRST: -| Action | Skill | -| -------------------------------------------------------------- | ------------------- | -| Add changelog entry for a PR or feature | `prowler-changelog` | -| App Router / Server Actions | `nextjs-16` | -| Building AI chat features | `ai-sdk-5` | -| Committing changes | `prowler-commit` | -| Create PR that requires changelog entry | `prowler-changelog` | -| Creating Zod schemas | `zod-4` | -| Creating a git commit | `prowler-commit` | -| Creating/modifying Prowler UI components | `prowler-ui` | -| Fixing bug | `tdd` | -| Implementing feature | `tdd` | -| Modifying component | `tdd` | -| Refactoring code | `tdd` | -| Review changelog format and conventions | `prowler-changelog` | -| Testing hooks or utilities | `vitest` | -| Update CHANGELOG.md in any component | `prowler-changelog` | -| Using Zustand stores | `zustand-5` | -| Working on Prowler UI structure (actions/adapters/types/hooks) | `prowler-ui` | -| Working on task | `tdd` | -| Working with Prowler UI test helpers/pages | `prowler-test-ui` | -| Working with Tailwind classes | `tailwind-4` | -| Writing Playwright E2E tests | `playwright` | -| Writing Prowler UI E2E tests | `prowler-test-ui` | -| Writing React component tests | `vitest` | -| Writing React components | `react-19` | -| Writing TypeScript types/interfaces | `typescript` | -| Writing Vitest tests | `vitest` | -| Writing unit tests for UI | `vitest` | +| Action | Skill | +| ----------------------------------------------------------------- | ------------------- | +| Add changelog entry for a PR or feature | `prowler-changelog` | +| Adding, updating, or removing a tour definition (\*.tour.ts) | `prowler-tour` | +| App Router / Server Actions | `nextjs-16` | +| Building AI chat features | `ai-sdk-5` | +| Changing button labels or section headings on a tour-covered page | `prowler-tour` | +| Committing changes | `prowler-commit` | +| Create PR that requires changelog entry | `prowler-changelog` | +| Creating Zod schemas | `zod-4` | +| Creating a git commit | `prowler-commit` | +| Creating/modifying Prowler UI components | `prowler-ui` | +| Editing a UI file containing data-tour-id attributes | `prowler-tour` | +| Fixing bug | `tdd` | +| Implementing feature | `tdd` | +| Modifying component | `tdd` | +| Refactoring code | `tdd` | +| Renaming or removing a data-tour-id attribute value | `prowler-tour` | +| Restructuring routes or layouts covered by a tour | `prowler-tour` | +| Review changelog format and conventions | `prowler-changelog` | +| Testing hooks or utilities | `vitest` | +| Update CHANGELOG.md in any component | `prowler-changelog` | +| Using Zustand stores | `zustand-5` | +| Working on Prowler UI structure (actions/adapters/types/hooks) | `prowler-ui` | +| Working on task | `tdd` | +| Working with Prowler UI test helpers/pages | `prowler-test-ui` | +| Working with Tailwind classes | `tailwind-4` | +| Writing Playwright E2E tests | `playwright` | +| Writing Prowler UI E2E tests | `prowler-test-ui` | +| Writing React component tests | `vitest` | +| Writing React components | `react-19` | +| Writing TypeScript types/interfaces | `typescript` | +| Writing Vitest tests | `vitest` | +| Writing unit tests for UI | `vitest` | --- diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 6a10c36dbb..dfa5565e93 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,12 +2,87 @@ All notable changes to the **Prowler UI** are documented in this file. -## [1.30.0] (Prowler UNRELEASED) +## [1.31.0] (Prowler UNRELEASED) + +### 🚀 Added + +- Controlled `402` and `403` Server Action error messages for alert seed and mutation flows [(#11629)](https://github.com/prowler-cloud/prowler/pull/11629) ### 🔄 Changed - ESLint: typed flat config with `typescript-eslint` (type-aware via `projectService`) and `eslint-plugin-import-x`, replacing `eslint-plugin-prettier`, `eslint-plugin-simple-import-sort`, and `eslint-plugin-unused-imports` [(#11352)](https://github.com/prowler-cloud/prowler/pull/11352) +### 🐞 Fixed + +- Attack Paths now shows distinct messages while a scan is queued, running, or building its graph — plus a separate "couldn't load scans" error — instead of always showing "No scans available" [(#11512)](https://github.com/prowler-cloud/prowler/pull/11512) +- Radio button no longer shifts vertically when selected [(#11608)](https://github.com/prowler-cloud/prowler/pull/11608) +- Handle rename DORA to DORA_2022_2554 to follow the naming _ in compliance frameworks [(#11551)](https://github.com/prowler-cloud/prowler/pull/11551) + +### 🔐 Security + +- Bump vulnerable `Next.js`, React, AI SDK, `postcss`, `hono`, `qs`, `esbuild`, and Alpine OpenSSL packages (`libcrypto3` and `libssl3`) [(#11581)](https://github.com/prowler-cloud/prowler/pull/11581) +- Bump transitive `dompurify` from 3.4.2 to 3.4.10, patching XSS sanitization bypass advisories [(#11636)](https://github.com/prowler-cloud/prowler/pull/11636) + +--- + +## [1.30.1] (Prowler v5.30.1) + +### 🐞 Fixed + +- Threat Map no longer shows an empty map for accounts that only have Okta or Google Workspace scans [(#11542)](https://github.com/prowler-cloud/prowler/pull/11542) +- Compliance attributes requests now pass the selected scan, so multi-provider universal frameworks (e.g. CSA CCM) load the check IDs of the scan's provider and Azure/GCP requirement details show their findings instead of appearing empty [(#11546)](https://github.com/prowler-cloud/prowler/pull/11546) + +### 🔄 Changed + +- Public SaaS config (Sentry, Google Tag Manager, API base/docs URL) now resolves at container runtime instead of build time; self-hosted deployments set the UI config through the new `UI_`-prefixed env vars (`UI_API_BASE_URL`, `UI_API_DOCS_URL`, `UI_GOOGLE_TAG_MANAGER_ID`, `UI_SENTRY_DSN`, `UI_SENTRY_ENVIRONMENT`), with the previous `NEXT_PUBLIC_*` names still honored as a deprecated fallback [(#11500)](https://github.com/prowler-cloud/prowler/pull/11500) + +### 🐞 Fixed + +- `ui/.env` template now lists only the canonical `UI_SENTRY_DSN` and `UI_SENTRY_ENVIRONMENT` names; the deprecated `NEXT_PUBLIC_SENTRY_DSN` and `NEXT_PUBLIC_SENTRY_ENVIRONMENT` entries have been removed [(#11500)](https://github.com/prowler-cloud/prowler/pull/11500) + +--- + +## [1.30.0] (Prowler v5.30.0) + +### 🚀 Added + +- DISA Okta IDaaS STIG V1R2 compliance framework support with its dedicated mapper, details panel, and icon [(#11428)](https://github.com/prowler-cloud/prowler/pull/11428) +- DORA compliance framework support [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131) + +### 🔄 Changed + +- Renamed "Customer Support" to "Support Desk" in the side menu, showing it only in Prowler Cloud/Enterprise, while "Community Support" now shows only in Prowler OSS [(#11508)](https://github.com/prowler-cloud/prowler/pull/11508) +- Compliance detail page now shows a "still loading" retry state while the API warms its compliance catalog, instead of rendering an empty page [(#11530)](https://github.com/prowler-cloud/prowler/pull/11530) + +### 🐞 Fixed + +- Risk Pipeline Sankey chart now adapts height and node spacing for dense provider datasets, keeping provider and severity labels readable [(#11527)](https://github.com/prowler-cloud/prowler/pull/11527) + +--- + +## [1.29.3] (Prowler v5.29.3) + +### 🐞 Fixed + +- Finding drawer tabs now keep the active tab text and underline styling when tooltip state changes [(#11493)](https://github.com/prowler-cloud/prowler/pull/11493) + +--- + +## [1.29.2] (Prowler v5.29.2) + +### 🔄 Changed + +- Account and provider-type selector triggers now show the provider icon, with a non-deduped icon stack [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424) + +### 🐞 Fixed + +- Add Provider modal now closes without reloading the providers page [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424) +- Users page now shows the "Delete User" action only on the current user's row, matching the backend rule that a user can only delete their own account [(#11447)](https://github.com/prowler-cloud/prowler/pull/11447) + +### 🔐 Security + +- Vitest toolchain upgraded `4.0.18` → `4.1.8` to clear two critical `pnpm audit` advisories [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424) + --- ## [1.29.0] (Prowler v5.29.0) @@ -25,6 +100,7 @@ All notable changes to the **Prowler UI** are documented in this file. - Compliance page now loads the most recent scan when opened from the sidebar instead of showing the "no compliance data available" alert [(#11374)](https://github.com/prowler-cloud/prowler/pull/11374) - Invitation links now show specific expired, no-longer-valid, and invalid-token messages based on API error responses [(#11376)](https://github.com/prowler-cloud/prowler/pull/11376) +- Jira dispatch and provider connection-test polling no longer show a false timeout for longer-running tasks; both poll windows now extend to 60 seconds [(#11519)](https://github.com/prowler-cloud/prowler/pull/11519) ### 🔐 Security diff --git a/ui/Dockerfile b/ui/Dockerfile index 047df1171b..86673ba046 100644 --- a/ui/Dockerfile +++ b/ui/Dockerfile @@ -3,8 +3,8 @@ FROM node:24.13.0-alpine@sha256:cd6fb7efa6490f039f3471a189214d5f548c11df1ff9e5b1 LABEL maintainer="https://github.com/prowler-cloud" -# Enable corepack for pnpm -RUN corepack enable +# Patch Alpine OpenSSL runtime packages before all stages inherit the base image. +RUN apk upgrade --no-cache libcrypto3 libssl3 && corepack enable # Install dependencies only when needed FROM base AS deps @@ -16,6 +16,7 @@ WORKDIR /app # Install dependencies based on the preferred package manager COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY scripts ./scripts +ENV NODE_OPTIONS=--max-old-space-size=4096 RUN corepack install && pnpm install --frozen-lockfile @@ -35,12 +36,8 @@ RUN corepack install ENV NEXT_TELEMETRY_DISABLED=1 ARG NEXT_PUBLIC_PROWLER_RELEASE_VERSION ENV NEXT_PUBLIC_PROWLER_RELEASE_VERSION=${NEXT_PUBLIC_PROWLER_RELEASE_VERSION} -ARG NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID -ENV NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID=${NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID} -ARG NEXT_PUBLIC_API_BASE_URL -ENV NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL} -ARG NEXT_PUBLIC_API_DOCS_URL -ENV NEXT_PUBLIC_API_DOCS_URL=${NEXT_PUBLIC_API_DOCS_URL} + +# GTM / API base+docs URLs are runtime container env (prod stage), not build ARGs. RUN pnpm run build @@ -77,6 +74,12 @@ EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" +# Runtime configuration is read by `node server.js` at container start and is +# NOT baked into the image. Supply it via your orchestrator (docker-compose, +# Helm/K8s): +# - required: UI_API_BASE_URL, AUTH_URL, AUTH_SECRET (missing ⇒ fail fast at boot) +# - optional: UI_API_DOCS_URL, UI_GOOGLE_TAG_MANAGER_ID, UI_SENTRY_DSN, UI_SENTRY_ENVIRONMENT +# - reserved: POSTHOG_KEY, POSTHOG_HOST, REO_DEV_CLIENT_ID (no consumer yet) # server.js is created by next build from the standalone output # https://nextjs.org/docs/pages/api-reference/next-config-js/output CMD ["node", "server.js"] diff --git a/ui/__tests__/msw/handlers/attack-paths.ts b/ui/__tests__/msw/handlers/attack-paths.ts index b17c6c0596..7c76fa574b 100644 --- a/ui/__tests__/msw/handlers/attack-paths.ts +++ b/ui/__tests__/msw/handlers/attack-paths.ts @@ -10,7 +10,7 @@ import type { QueryResultAttributes, } from "@/types/attack-paths"; -const API = process.env.NEXT_PUBLIC_API_BASE_URL!; +const API = process.env.UI_API_BASE_URL; type JsonApiErrorBody = { errors: Array<{ detail: string; status: string }>; diff --git a/ui/actions/compliances/compliances.ts b/ui/actions/compliances/compliances.ts index d5f4fd4954..e06b06d29d 100644 --- a/ui/actions/compliances/compliances.ts +++ b/ui/actions/compliances/compliances.ts @@ -73,17 +73,33 @@ export const getComplianceOverviewMetadataInfo = async ({ } }; -export const getComplianceAttributes = async (complianceId: string) => { +export const getComplianceAttributes = async ( + complianceId: string, + scanId?: string, +) => { const headers = await getAuthHeaders({ contentType: false }); try { const url = new URL(`${apiBaseUrl}/compliance-overviews/attributes`); url.searchParams.append("filter[compliance_id]", complianceId); + // Pass the scan so multi-provider universal frameworks (e.g. CSA CCM) + // resolve the check IDs for the scan's provider instead of defaulting to + // the first provider that declares the framework. + if (scanId) { + url.searchParams.append("filter[scan_id]", scanId); + } const response = await fetch(url.toString(), { headers, }); + // The compliance catalog is still warming after a deploy/restart. Signal + // the page to render the "still loading" state instead of letting this + // become a thrown 5xx (which would be captured as a server error). + if (response.status === 503) { + return { warming: true as const, status: 503 }; + } + return handleApiResponse(response); } catch (error) { console.error("Error fetching compliance attributes:", error); diff --git a/ui/actions/integrations/integrations.ts b/ui/actions/integrations/integrations.ts index 40084a0da1..8945842fe4 100644 --- a/ui/actions/integrations/integrations.ts +++ b/ui/actions/integrations/integrations.ts @@ -286,7 +286,7 @@ const pollTaskUntilComplete = async ( taskId: string, ): Promise => { const settled = await pollTaskUntilSettled(taskId, { - maxAttempts: 10, + maxAttempts: 20, delayMs: 3000, }); diff --git a/ui/actions/integrations/jira-dispatch.ts b/ui/actions/integrations/jira-dispatch.ts index 72bfc1176d..9e3b25679f 100644 --- a/ui/actions/integrations/jira-dispatch.ts +++ b/ui/actions/integrations/jira-dispatch.ts @@ -148,7 +148,7 @@ export const pollJiraDispatchTask = async ( { success: true; message: string } | { success: false; error: string } > => { const res = await pollTaskUntilSettled(taskId, { - maxAttempts: 10, + maxAttempts: 30, delayMs: 2000, }); if (!res.ok) { diff --git a/ui/actions/overview/regions/threat-map.adapter.test.ts b/ui/actions/overview/regions/threat-map.adapter.test.ts new file mode 100644 index 0000000000..515825bb0a --- /dev/null +++ b/ui/actions/overview/regions/threat-map.adapter.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; + +import { adaptRegionsOverviewToThreatMap } from "./threat-map.adapter"; +import type { RegionsOverviewResponse } from "./types"; + +function buildRegionsResponse( + rows: Array<{ providerType: string; region: string }>, +): RegionsOverviewResponse { + return { + data: rows.map(({ providerType, region }, index) => ({ + type: "regions-overview", + id: `region-${index}`, + attributes: { + provider_type: providerType, + region, + total: 10, + fail: 4, + muted: 0, + pass: 6, + }, + })), + meta: { version: "v1" }, + }; +} + +describe("adaptRegionsOverviewToThreatMap", () => { + it("maps okta regions to a global location", () => { + const response = buildRegionsResponse([ + { providerType: "okta", region: "global" }, + ]); + + const result = adaptRegionsOverviewToThreatMap(response); + + expect(result.locations).toHaveLength(1); + expect(result.locations[0]).toMatchObject({ + providerType: "okta", + region: "global", + name: "Okta - Global", + totalFindings: 10, + failFindings: 4, + }); + expect(result.regions).toEqual(["global"]); + }); + + it("maps googleworkspace regions to a global location", () => { + const response = buildRegionsResponse([ + { providerType: "googleworkspace", region: "global" }, + ]); + + const result = adaptRegionsOverviewToThreatMap(response); + + expect(result.locations).toHaveLength(1); + expect(result.locations[0]).toMatchObject({ + providerType: "googleworkspace", + region: "global", + name: "Google Workspace - Global", + totalFindings: 10, + failFindings: 4, + }); + expect(result.regions).toEqual(["global"]); + }); +}); diff --git a/ui/actions/overview/regions/threat-map.adapter.ts b/ui/actions/overview/regions/threat-map.adapter.ts index 0b7852b56a..6ee0958aad 100644 --- a/ui/actions/overview/regions/threat-map.adapter.ts +++ b/ui/actions/overview/regions/threat-map.adapter.ts @@ -261,6 +261,19 @@ const ALIBABACLOUD_COORDINATES: Record = { global: { lat: 30.3, lng: 120.2 }, // Global fallback (Hangzhou HQ) }; +// Okta is a SaaS identity platform without user-facing regions +const OKTA_COORDINATES: Record = { + global: { lat: 37.8, lng: -122.4 }, // Global fallback (San Francisco HQ) +}; + +// Google Workspace is a SaaS suite without user-facing regions +const GOOGLEWORKSPACE_COORDINATES: Record< + string, + { lat: number; lng: number } +> = { + global: { lat: 37.4, lng: -122.1 }, // Global fallback (Mountain View HQ) +}; + const PROVIDER_COORDINATES: Record< string, Record @@ -277,6 +290,8 @@ const PROVIDER_COORDINATES: Record< oraclecloud: ORACLECLOUD_COORDINATES, mongodbatlas: MONGODBATLAS_COORDINATES, alibabacloud: ALIBABACLOUD_COORDINATES, + okta: OKTA_COORDINATES, + googleworkspace: GOOGLEWORKSPACE_COORDINATES, }; // Returns [lng, lat] format for D3/GeoJSON compatibility diff --git a/ui/actions/resources/resources.test.ts b/ui/actions/resources/resources.test.ts index cbdb0b1296..d8c5d75456 100644 --- a/ui/actions/resources/resources.test.ts +++ b/ui/actions/resources/resources.test.ts @@ -28,6 +28,10 @@ import { vi.mock("@/lib", () => ({ apiBaseUrl: "https://api.example.com/api/v1", getAuthHeaders: getAuthHeadersMock, + GENERIC_SERVER_ERROR_MESSAGE: + "Server is temporarily unavailable. Please try again in a few minutes.", + sanitizeErrorMessage: (message: string, fallback: string) => + / { }); }); + it("returns a generic error when a gateway returns HTML", async () => { + // Given + const mockResponse = new Response( + "502 Bad Gateway

502 Bad Gateway

", + { + status: 502, + statusText: "Bad Gateway", + headers: { "content-type": "text/html" }, + }, + ); + fetchMock.mockResolvedValue(mockResponse); + + // When + const result = await getResourceEvents("resource-123"); + + // Then + expect(result).toEqual({ + error: + "Server is temporarily unavailable. Please try again in a few minutes.", + status: 502, + }); + }); + it("returns generic error when fetch throws", async () => { // Given fetchMock.mockRejectedValue(new Error("Network failure")); diff --git a/ui/actions/resources/resources.ts b/ui/actions/resources/resources.ts index 8af2576852..3ab84efa4f 100644 --- a/ui/actions/resources/resources.ts +++ b/ui/actions/resources/resources.ts @@ -4,7 +4,13 @@ import { redirect } from "next/navigation"; import { getLatestFindings } from "@/actions/findings"; import { listOrganizationsSafe } from "@/actions/organizations/organizations"; -import { apiBaseUrl, FINDINGS_FILTERED_SORT, getAuthHeaders } from "@/lib"; +import { + apiBaseUrl, + FINDINGS_FILTERED_SORT, + GENERIC_SERVER_ERROR_MESSAGE, + getAuthHeaders, + sanitizeErrorMessage, +} from "@/lib"; import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters"; import { handleApiResponse } from "@/lib/server-actions-helper"; import { OrganizationResource } from "@/types/organizations"; @@ -193,22 +199,27 @@ export const getResourceEvents = async ( if (!response.ok) { const rawText = await response.text().catch(() => ""); + const contentType = + response.headers.get("content-type")?.toLowerCase() || ""; const defaultError = "An error occurred while fetching events."; + const fallbackError = contentType.includes("text/html") + ? GENERIC_SERVER_ERROR_MESSAGE + : response.statusText || defaultError; try { const errorData = rawText ? JSON.parse(rawText) : null; + const errorMessage = + errorData?.errors?.[0]?.detail || + errorData?.error || + errorData?.message || + rawText || + fallbackError; return { - error: - errorData?.errors?.[0]?.detail || - errorData?.error || - errorData?.message || - rawText || - response.statusText || - defaultError, + error: sanitizeErrorMessage(String(errorMessage), fallbackError), status: response.status, }; } catch { return { - error: rawText || response.statusText || defaultError, + error: sanitizeErrorMessage(rawText || fallbackError, fallbackError), status: response.status, }; } diff --git a/ui/actions/scans/scans.test.ts b/ui/actions/scans/scans.test.ts index ae82ba860d..a7f7fd0c7e 100644 --- a/ui/actions/scans/scans.test.ts +++ b/ui/actions/scans/scans.test.ts @@ -14,6 +14,8 @@ const { vi.mock("@/lib", () => ({ apiBaseUrl: "https://api.example.com/api/v1", + GENERIC_SERVER_ERROR_MESSAGE: + "Server is temporarily unavailable. Please try again in a few minutes.", getAuthHeaders: getAuthHeadersMock, getErrorMessage: (error: unknown) => error instanceof Error ? error.message : String(error), @@ -28,7 +30,7 @@ vi.mock("@/lib/sentry-breadcrumbs", () => ({ addScanOperation: vi.fn(), })); -import { launchOrganizationScans } from "./scans"; +import { getExportsZip, launchOrganizationScans } from "./scans"; describe("launchOrganizationScans", () => { beforeEach(() => { @@ -69,3 +71,34 @@ describe("launchOrganizationScans", () => { expect(result.failureCount).toBe(0); }); }); + +describe("getExportsZip", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + }); + + it("returns a generic server error when the report endpoint returns HTML", async () => { + // Given + fetchMock.mockResolvedValue( + new Response( + "502 Bad Gateway

502 Bad Gateway

", + { + status: 502, + statusText: "Bad Gateway", + headers: { "content-type": "text/html" }, + }, + ), + ); + + // When + const result = await getExportsZip("scan-123"); + + // Then + expect(result).toEqual({ + error: + "Server is temporarily unavailable. Please try again in a few minutes.", + }); + }); +}); diff --git a/ui/actions/scans/scans.ts b/ui/actions/scans/scans.ts index a121da3e50..5a6ddb7a29 100644 --- a/ui/actions/scans/scans.ts +++ b/ui/actions/scans/scans.ts @@ -3,7 +3,12 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; -import { apiBaseUrl, getAuthHeaders, getErrorMessage } from "@/lib"; +import { + apiBaseUrl, + GENERIC_SERVER_ERROR_MESSAGE, + getAuthHeaders, + getErrorMessage, +} from "@/lib"; import { COMPLIANCE_REPORT_DISPLAY_NAMES, type ComplianceReportType, @@ -15,6 +20,7 @@ import { } from "@/lib/provider-filters"; import { addScanOperation } from "@/lib/sentry-breadcrumbs"; import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper"; +import { SCAN_STATES } from "@/types/attack-paths"; const ORGANIZATION_SCAN_CONCURRENCY_LIMIT = 5; export const getScans = async ({ @@ -64,6 +70,10 @@ export const getScansByState = async () => { "filter[provider_type__in]", sanitizeProviderTypesCsv(), ); + // Only need to know whether at least one completed scan exists; filter server-side + // and cap to a single row so the answer is correct regardless of total scan count. + url.searchParams.append("filter[state]", SCAN_STATES.COMPLETED); + url.searchParams.append("page[size]", "1"); try { const response = await fetch(url.toString(), { @@ -157,18 +167,20 @@ export const scheduleDaily = async (formData: FormData) => { const url = new URL(`${apiBaseUrl}/schedules/daily`); + const body = { + data: { + type: "daily-schedules", + attributes: { + provider_id: providerId, + }, + }, + }; + try { const response = await fetch(url.toString(), { method: "POST", headers, - body: JSON.stringify({ - data: { - type: "daily-schedules", - attributes: { - provider_id: providerId, - }, - }, - }), + body: JSON.stringify(body), }); return handleApiResponse(response, "/scans"); @@ -244,6 +256,27 @@ export const launchOrganizationScans = async ( return summary; }; +async function getScanReportErrorMessage( + response: Response, + fallbackMessage: string, +): Promise { + const contentType = response.headers.get("content-type")?.toLowerCase() || ""; + + if (contentType.includes("text/html")) { + return GENERIC_SERVER_ERROR_MESSAGE; + } + + const errorData = await response.json().catch(() => null); + + return ( + errorData?.errors?.[0]?.detail || + errorData?.errors?.detail || + errorData?.error || + errorData?.message || + (response.status >= 500 ? GENERIC_SERVER_ERROR_MESSAGE : fallbackMessage) + ); +} + export const updateScan = async (formData: FormData) => { const headers = await getAuthHeaders({ contentType: true }); @@ -295,11 +328,11 @@ export const getExportsZip = async (scanId: string) => { } if (!response.ok) { - const errorData = await response.json(); - throw new Error( - errorData?.errors?.detail || + await getScanReportErrorMessage( + response, "Unable to fetch scan report. Contact support if the issue continues.", + ), ); } @@ -370,10 +403,11 @@ const _fetchScanBinary = async ( } if (!response.ok) { - const errorData = await response.json().catch(() => ({})); throw new Error( - errorData?.errors?.detail || + await getScanReportErrorMessage( + response, `Unable to retrieve ${errorLabel}. Contact support if the issue continues.`, + ), ); } @@ -394,6 +428,27 @@ export const getComplianceCsv = async (scanId: string, complianceId: string) => "compliance report", ); +/** + * Get the OCSF JSON export for a universal compliance framework. + * + * Only universal frameworks that declare an ``outputs`` block (today: DORA, + * CSA CCM 4.0) produce a per-framework OCSF artifact. For any other framework + * the backend returns 404; callers should gate this download via + * ``isOcsfSupported(framework)``. + * + * NOTE: this is a dedicated path (``compliance/{id}/ocsf``), not a query + * param. The API's JSON:API ``QueryParameterValidationFilter`` rejects any + * non-JSON:API query param with 400, so ``?type=`` / ``?format=`` is not an + * option — the format must be encoded in the route. + */ +export const getComplianceOcsf = async (scanId: string, complianceId: string) => + _fetchScanBinary( + scanId, + `compliance/${complianceId}/ocsf`, + `scan-${scanId}-compliance-${complianceId}.ocsf.json`, + "compliance OCSF report", + ); + /** * Get a compliance PDF report for any supported framework. * diff --git a/ui/actions/schedules/index.ts b/ui/actions/schedules/index.ts new file mode 100644 index 0000000000..7e6e7b459a --- /dev/null +++ b/ui/actions/schedules/index.ts @@ -0,0 +1 @@ +export * from "./schedules"; diff --git a/ui/actions/schedules/schedules.test.ts b/ui/actions/schedules/schedules.test.ts new file mode 100644 index 0000000000..9e05799116 --- /dev/null +++ b/ui/actions/schedules/schedules.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { SCHEDULE_FREQUENCY } from "@/types/schedules"; + +const { + fetchMock, + getAuthHeadersMock, + handleApiErrorMock, + handleApiResponseMock, + revalidatePathMock, +} = vi.hoisted(() => ({ + fetchMock: vi.fn(), + getAuthHeadersMock: vi.fn(), + handleApiErrorMock: vi.fn(), + handleApiResponseMock: vi.fn(), + revalidatePathMock: vi.fn(), +})); + +vi.mock("next/cache", () => ({ + revalidatePath: revalidatePathMock, +})); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", + getAuthHeaders: getAuthHeadersMock, +})); + +vi.mock("@/lib/server-actions-helper", () => ({ + handleApiError: handleApiErrorMock, + handleApiResponse: handleApiResponseMock, +})); + +import { getSchedule, removeSchedule, updateSchedule } from "./schedules"; + +const PROVIDER_ID = "1795f636-37e6-42f6-b158-d4faaa64e0fc"; + +const payload = { + scan_enabled: true, + scan_frequency: SCHEDULE_FREQUENCY.DAILY, + scan_hour: 12, + scan_timezone: "Europe/Madrid", + scan_interval_hours: null, + scan_day_of_week: null, + scan_day_of_month: null, +}; + +describe("schedule write actions revalidate only on success", () => { + beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + fetchMock.mockResolvedValue(new Response(null, { status: 204 })); + handleApiErrorMock.mockReturnValue({ error: "Failed" }); + }); + + it("revalidates /scans and /providers after a successful update", async () => { + handleApiResponseMock.mockResolvedValue({ success: true }); + + await updateSchedule(PROVIDER_ID, payload); + + expect(revalidatePathMock).toHaveBeenCalledWith("/scans"); + expect(revalidatePathMock).toHaveBeenCalledWith("/providers"); + }); + + it("does not revalidate when the update returns an error result", async () => { + handleApiResponseMock.mockResolvedValue({ error: "Schedule rejected" }); + + await updateSchedule(PROVIDER_ID, payload); + + expect(revalidatePathMock).not.toHaveBeenCalled(); + }); + + it("revalidates /scans and /providers after a successful delete", async () => { + handleApiResponseMock.mockResolvedValue({ success: true }); + + await removeSchedule(PROVIDER_ID); + + expect(revalidatePathMock).toHaveBeenCalledWith("/scans"); + expect(revalidatePathMock).toHaveBeenCalledWith("/providers"); + }); + + it("does not revalidate when the delete returns an error result", async () => { + handleApiResponseMock.mockResolvedValue({ error: "Not allowed" }); + + await removeSchedule(PROVIDER_ID); + + expect(revalidatePathMock).not.toHaveBeenCalled(); + }); + + it("rejects non-UUID provider ids without issuing a request", async () => { + const malicious = "../users/me"; + + expect(await getSchedule(malicious)).toEqual({ + error: "Invalid provider id.", + }); + expect(await updateSchedule(malicious, payload)).toEqual({ + error: "Invalid provider id.", + }); + expect(await removeSchedule(malicious)).toEqual({ + error: "Invalid provider id.", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/actions/schedules/schedules.ts b/ui/actions/schedules/schedules.ts new file mode 100644 index 0000000000..c8e0d9993f --- /dev/null +++ b/ui/actions/schedules/schedules.ts @@ -0,0 +1,128 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; + +import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper"; +import type { ScheduleProps, ScheduleUpdatePayload } from "@/types/schedules"; + +// SSRF guard: the id is interpolated into the request URL, so only UUIDs pass. +const providerIdSchema = z.uuid(); + +function parseProviderId(providerId: string): string | null { + const parsed = providerIdSchema.safeParse(providerId); + return parsed.success ? parsed.data : null; +} + +function revalidateScheduleViews() { + revalidatePath("/scans"); + revalidatePath("/providers"); +} + +export const getSchedule = async (providerId: string) => { + const id = parseProviderId(providerId); + if (!id) return { error: "Invalid provider id." }; + + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}/schedules/${id}`); + url.searchParams.set("include", "provider"); + + try { + const response = await fetch(url.toString(), { headers }); + + return handleApiResponse(response); + } catch (error) { + return handleApiError(error); + } +}; + +/** + * Lists every schedule (one per provider), following pagination — the backend + * has no multi-provider filter. + */ +export const getSchedules = async () => { + const headers = await getAuthHeaders({ contentType: false }); + const schedules: ScheduleProps[] = []; + const MAX_PAGES = 20; + + try { + for (let page = 1; page <= MAX_PAGES; page++) { + const url = new URL(`${apiBaseUrl}/schedules`); + url.searchParams.set("page[number]", String(page)); + url.searchParams.set("page[size]", "100"); + + const response = await fetch(url.toString(), { headers }); + + const result = await handleApiResponse(response); + if (result?.error) return result; + + schedules.push(...(result?.data ?? [])); + + const totalPages = result?.meta?.pagination?.pages ?? 1; + if (page >= totalPages) break; + } + + return { data: schedules }; + } catch (error) { + return handleApiError(error); + } +}; + +export const updateSchedule = async ( + providerId: string, + payload: ScheduleUpdatePayload, +) => { + const id = parseProviderId(providerId); + if (!id) return { error: "Invalid provider id." }; + + const headers = await getAuthHeaders({ contentType: true }); + const url = new URL(`${apiBaseUrl}/schedules/${id}`); + + const body = { + data: { + type: "schedules", + id, + attributes: payload, + }, + }; + + try { + const response = await fetch(url.toString(), { + method: "PATCH", + headers, + body: JSON.stringify(body), + }); + + const result = await handleApiResponse(response); + if (!result?.error) { + revalidateScheduleViews(); + } + return result; + } catch (error) { + return handleApiError(error); + } +}; + +export const removeSchedule = async (providerId: string) => { + const id = parseProviderId(providerId); + if (!id) return { error: "Invalid provider id." }; + + const headers = await getAuthHeaders({ contentType: true }); + const url = new URL(`${apiBaseUrl}/schedules/${id}`); + + try { + const response = await fetch(url.toString(), { + method: "DELETE", + headers, + }); + + const result = await handleApiResponse(response); + if (!result?.error) { + revalidateScheduleViews(); + } + return result; + } catch (error) { + return handleApiError(error); + } +}; diff --git a/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.test.ts b/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.test.ts index d95966e680..41118bd6e2 100644 --- a/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.test.ts +++ b/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.test.ts @@ -14,7 +14,7 @@ const lastFetchCall = (): { url: string; init: RequestInit } => { describe("confirmAlertRecipient", () => { beforeEach(() => { vi.stubGlobal("fetch", fetchMock); - vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://api.example.com/api/v1"); + vi.stubEnv("UI_API_BASE_URL", "https://api.example.com/api/v1"); fetchMock.mockResolvedValue( new Response( JSON.stringify({ @@ -104,7 +104,8 @@ describe("confirmAlertRecipient", () => { }); it("returns the fallback message when the API base URL is missing", async () => { - // Given + // Given - neither the new name nor its legacy fallback is set + vi.stubEnv("UI_API_BASE_URL", ""); vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", ""); // When @@ -120,6 +121,21 @@ describe("confirmAlertRecipient", () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it("falls back to the deprecated NEXT_PUBLIC_API_BASE_URL when UI_API_BASE_URL is unset", async () => { + // Given - only the legacy name is configured + vi.stubEnv("UI_API_BASE_URL", undefined); + vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://legacy.example.com/api/v1"); + + // When + const result = await confirmAlertRecipient("token-1"); + + // Then + expect(result.ok).toBe(true); + expect(lastFetchCall().url).toBe( + "https://legacy.example.com/api/v1/alerts/recipients/confirm?token=token-1", + ); + }); + it("returns the fallback message when the request fails", async () => { // Given fetchMock.mockRejectedValueOnce(new Error("network down")); diff --git a/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.ts b/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.ts index b567bb4124..19e9216339 100644 --- a/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.ts +++ b/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.ts @@ -1,3 +1,5 @@ +import { readEnv } from "@/lib/runtime-env"; + interface AlertConfirmApiResponse { state?: string; message?: string; @@ -41,7 +43,7 @@ const toState = (payload: unknown): string => { export const confirmAlertRecipient = async ( token?: string, ): Promise => { - const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; + const apiBaseUrl = readEnv("UI_API_BASE_URL", "NEXT_PUBLIC_API_BASE_URL"); if (!apiBaseUrl) { return { ok: false, diff --git a/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.test.ts b/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.test.ts index 8a595fa63e..508625b4be 100644 --- a/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.test.ts +++ b/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.test.ts @@ -14,7 +14,7 @@ const lastFetchCall = (): { url: string; init: RequestInit } => { describe("unsubscribeAlertRecipient", () => { beforeEach(() => { vi.stubGlobal("fetch", fetchMock); - vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://api.example.com/api/v1"); + vi.stubEnv("UI_API_BASE_URL", "https://api.example.com/api/v1"); fetchMock.mockResolvedValue( new Response( JSON.stringify({ @@ -102,4 +102,37 @@ describe("unsubscribeAlertRecipient", () => { "https://api.example.com/api/v1/alerts/recipients/unsubscribe", ); }); + + it("returns the fallback message when the API base URL is missing", async () => { + // Given - neither the new name nor its legacy fallback is set + vi.stubEnv("UI_API_BASE_URL", ""); + vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", ""); + + // When + const result = await unsubscribeAlertRecipient("token-1"); + + // Then + expect(result).toEqual({ + ok: false, + state: "missing_api_base_url", + message: + "We could not process this unsubscribe link. Please try again later.", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("falls back to the deprecated NEXT_PUBLIC_API_BASE_URL when UI_API_BASE_URL is unset", async () => { + // Given - only the legacy name is configured + vi.stubEnv("UI_API_BASE_URL", undefined); + vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://legacy.example.com/api/v1"); + + // When + const result = await unsubscribeAlertRecipient("token-1"); + + // Then + expect(result.ok).toBe(true); + expect(lastFetchCall().url).toBe( + "https://legacy.example.com/api/v1/alerts/recipients/unsubscribe?token=token-1", + ); + }); }); diff --git a/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.ts b/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.ts index af64165a1e..8c3a4e2a79 100644 --- a/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.ts +++ b/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.ts @@ -1,3 +1,5 @@ +import { readEnv } from "@/lib/runtime-env"; + interface AlertUnsubscribeApiResponse { state?: string; message?: string; @@ -41,7 +43,7 @@ const toState = (payload: unknown): string => { export const unsubscribeAlertRecipient = async ( token?: string, ): Promise => { - const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; + const apiBaseUrl = readEnv("UI_API_BASE_URL", "NEXT_PUBLIC_API_BASE_URL"); if (!apiBaseUrl) { return { ok: false, diff --git a/ui/app/(auth)/layout.tsx b/ui/app/(auth)/layout.tsx index 07fe3a60c3..79a4ce4e89 100644 --- a/ui/app/(auth)/layout.tsx +++ b/ui/app/(auth)/layout.tsx @@ -2,12 +2,15 @@ import "@/styles/globals.css"; import { GoogleTagManager } from "@next/third-parties/google"; import { Metadata, Viewport } from "next"; +import { connection } from "next/server"; import { ReactNode, Suspense } from "react"; +import { RuntimePublicConfig } from "@/components/runtime-config/runtime-public-config"; import { NavigationProgress, Toaster } from "@/components/ui"; import { fontSans } from "@/config/fonts"; import { siteConfig } from "@/config/site"; import { cn } from "@/lib"; +import { readEnv } from "@/lib/runtime-env"; import { Providers } from "../providers"; @@ -29,10 +32,27 @@ export const viewport: Viewport = { ], }; -export default function AuthLayout({ children }: { children: ReactNode }) { +export default async function AuthLayout({ + children, +}: { + children: ReactNode; +}) { + // Force dynamic rendering so the read below resolves from the container env + // at request time rather than being snapshotted at build (independent of the + // island's own connection() call). + await connection(); + + // Server-side runtime read. Empty/unset id ⇒ GoogleTagManager is not mounted + const gtmId = readEnv( + "UI_GOOGLE_TAG_MANAGER_ID", + "NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID", + ); + return ( - + + + {children} - + {gtmId && } diff --git a/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx index 626e0985da..19c63cf3d2 100644 --- a/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; @@ -57,7 +57,7 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({ ); }, MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => ( -
{children}
+
{children}
), MultiSelectValue: ({ placeholder }: { placeholder: string }) => ( {placeholder} @@ -220,4 +220,45 @@ describe("AccountsSelector", () => { expect(multiSelectSpy).toHaveBeenLastCalledWith({ open: false }); }); + + it("shows the provider icon next to the name in the trigger for a single selection", async () => { + render( + , + ); + + const trigger = screen.getByTestId("trigger"); + expect(await within(trigger).findByText("AWS")).toBeInTheDocument(); + expect(within(trigger).getByText("Production AWS")).toBeInTheDocument(); + }); + + it("renders one icon per selected account without deduping by provider type", async () => { + const secondAws = { + ...providers[0], + id: "provider-2", + attributes: { + ...providers[0].attributes, + uid: "999999999999", + alias: "Staging AWS", + }, + }; + + render( + , + ); + + const trigger = screen.getByTestId("trigger"); + // Two AWS accounts -> two AWS icons in the trigger (no dedupe). + expect(await within(trigger).findAllByText("AWS")).toHaveLength(2); + expect( + within(trigger).getByText("2 Providers selected"), + ).toBeInTheDocument(); + }); }); diff --git a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx index a37807e793..0c389febb7 100644 --- a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx @@ -1,26 +1,12 @@ "use client"; import { useSearchParams } from "next/navigation"; -import { ReactNode, useState } from "react"; +import { useState } from "react"; import { - AlibabaCloudProviderBadge, - AWSProviderBadge, - AzureProviderBadge, - CloudflareProviderBadge, - GCPProviderBadge, - GitHubProviderBadge, - GoogleWorkspaceProviderBadge, - IacProviderBadge, - ImageProviderBadge, - KS8ProviderBadge, - M365ProviderBadge, - MongoDBAtlasProviderBadge, - OktaProviderBadge, - OpenStackProviderBadge, - OracleCloudProviderBadge, - VercelProviderBadge, -} from "@/components/icons/providers-badge"; + ProviderTypeIcon, + ProviderTypeIconStack, +} from "@/components/icons/providers-badge/provider-type-icon"; import { Badge } from "@/components/shadcn"; import { MultiSelect, @@ -45,25 +31,6 @@ const ACCOUNT_SELECTOR_FILTER = { type AccountSelectorFilter = (typeof ACCOUNT_SELECTOR_FILTER)[keyof typeof ACCOUNT_SELECTOR_FILTER]; -const PROVIDER_ICON: Record = { - aws: , - azure: , - gcp: , - kubernetes: , - m365: , - github: , - googleworkspace: , - iac: , - image: , - oraclecloud: , - mongodbatlas: , - alibabacloud: , - cloudflare: , - openstack: , - vercel: , - okta: , -}; - /** Common props shared by both batch and instant modes. */ interface AccountsSelectorBaseProps { providers: ProviderProps[]; @@ -158,10 +125,36 @@ export function AccountsSelector({ if (selectedIds.length === 1) { const p = providers.find((pr) => getProviderValue(pr) === selectedIds[0]); const name = p ? p.attributes.alias || p.attributes.uid : selectedIds[0]; - return {name}; + return ( + + {p && ( + + )} + {name} + + ); } + // One icon per selected account (no dedupe): two accounts of the same + // provider show two icons, disambiguated by the UID tooltip on hover. + const items = selectedIds + .map((selectedId) => + providers.find((pr) => getProviderValue(pr) === selectedId), + ) + .filter((p): p is ProviderProps => Boolean(p)) + .map((p) => ({ + key: p.id, + type: p.attributes.provider as ProviderType, + tooltip: p.attributes.uid, + })); return ( - {selectedIds.length} Providers selected + + + + {selectedIds.length} Providers selected + + ); }; @@ -208,7 +201,6 @@ export function AccountsSelector({ const isDisabled = disabledValuesSet.has(value); const displayName = p.attributes.alias || p.attributes.uid; const providerType = p.attributes.provider as ProviderType; - const icon = PROVIDER_ICON[providerType]; const searchKeywords = [ displayName, p.attributes.alias, @@ -228,7 +220,9 @@ export function AccountsSelector({ if (closeOnSelect) setSelectorOpen(false); }} > - + {displayName} {isDisabled && Disconnected} diff --git a/ui/app/(prowler)/_overview/_components/provider-type-selector.test.tsx b/ui/app/(prowler)/_overview/_components/provider-type-selector.test.tsx index 5cd94a3087..b2c05b336d 100644 --- a/ui/app/(prowler)/_overview/_components/provider-type-selector.test.tsx +++ b/ui/app/(prowler)/_overview/_components/provider-type-selector.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { ProviderTypeSelector } from "./provider-type-selector"; @@ -39,7 +39,7 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({
{children}
), MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => ( -
{children}
+
{children}
), MultiSelectValue: ({ placeholder }: { placeholder: string }) => ( {placeholder} @@ -145,4 +145,26 @@ describe("ProviderTypeSelector", () => { ).toHaveAttribute("aria-disabled", "true"); expect(screen.getByText("All selected")).toBeInTheDocument(); }); + + it("shows one icon per selected type and a count in the trigger", async () => { + const azure = { + ...providers[0], + id: "provider-2", + attributes: { ...providers[0].attributes, provider: "azure" as const }, + }; + + render( + , + ); + + const trigger = screen.getByTestId("trigger"); + expect(await within(trigger).findByText("AWS")).toBeInTheDocument(); + expect( + within(trigger).getByText("2 Provider Types selected"), + ).toBeInTheDocument(); + }); }); diff --git a/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx b/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx index 15d069b49d..e78412eeeb 100644 --- a/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx @@ -1,8 +1,12 @@ "use client"; import { useSearchParams } from "next/navigation"; -import { type ComponentType, lazy, Suspense } from "react"; +import { + PROVIDER_TYPE_DATA, + ProviderTypeIcon, + ProviderTypeIconStack, +} from "@/components/icons/providers-badge/provider-type-icon"; import { MultiSelect, MultiSelectContent, @@ -14,163 +18,6 @@ import { import { useUrlFilters } from "@/hooks/use-url-filters"; import { type ProviderProps, ProviderType } from "@/types/providers"; -const AWSProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.AWSProviderBadge, - })), -); -const AzureProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.AzureProviderBadge, - })), -); -const GCPProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.GCPProviderBadge, - })), -); -const KS8ProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.KS8ProviderBadge, - })), -); -const M365ProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.M365ProviderBadge, - })), -); -const GitHubProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.GitHubProviderBadge, - })), -); -const IacProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.IacProviderBadge, - })), -); -const ImageProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.ImageProviderBadge, - })), -); -const OracleCloudProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.OracleCloudProviderBadge, - })), -); -const MongoDBAtlasProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.MongoDBAtlasProviderBadge, - })), -); -const AlibabaCloudProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.AlibabaCloudProviderBadge, - })), -); -const CloudflareProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.CloudflareProviderBadge, - })), -); -const OpenStackProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.OpenStackProviderBadge, - })), -); -const GoogleWorkspaceProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.GoogleWorkspaceProviderBadge, - })), -); -const VercelProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.VercelProviderBadge, - })), -); -const OktaProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.OktaProviderBadge, - })), -); - -type IconProps = { width: number; height: number }; - -const IconPlaceholder = ({ width, height }: IconProps) => ( -
-); - -const PROVIDER_DATA: Record< - ProviderType, - { label: string; icon: ComponentType } -> = { - aws: { - label: "Amazon Web Services", - icon: AWSProviderBadge, - }, - azure: { - label: "Microsoft Azure", - icon: AzureProviderBadge, - }, - gcp: { - label: "Google Cloud Platform", - icon: GCPProviderBadge, - }, - kubernetes: { - label: "Kubernetes", - icon: KS8ProviderBadge, - }, - m365: { - label: "Microsoft 365", - icon: M365ProviderBadge, - }, - github: { - label: "GitHub", - icon: GitHubProviderBadge, - }, - googleworkspace: { - label: "Google Workspace", - icon: GoogleWorkspaceProviderBadge, - }, - iac: { - label: "Infrastructure as Code", - icon: IacProviderBadge, - }, - image: { - label: "Container Registry", - icon: ImageProviderBadge, - }, - oraclecloud: { - label: "Oracle Cloud Infrastructure", - icon: OracleCloudProviderBadge, - }, - mongodbatlas: { - label: "MongoDB Atlas", - icon: MongoDBAtlasProviderBadge, - }, - alibabacloud: { - label: "Alibaba Cloud", - icon: AlibabaCloudProviderBadge, - }, - cloudflare: { - label: "Cloudflare", - icon: CloudflareProviderBadge, - }, - openstack: { - label: "OpenStack", - icon: OpenStackProviderBadge, - }, - vercel: { - label: "Vercel", - icon: VercelProviderBadge, - }, - okta: { - label: "Okta", - icon: OktaProviderBadge, - }, -}; - /** Common props shared by both batch and instant modes. */ interface ProviderTypeSelectorBaseProps { providers: ProviderProps[]; @@ -247,34 +94,38 @@ export const ProviderTypeSelector = ({ .map((p) => p.attributes.provider), ), ) - .filter((type): type is ProviderType => type in PROVIDER_DATA) + .filter((type): type is ProviderType => type in PROVIDER_TYPE_DATA) .sort((a, b) => - PROVIDER_DATA[a].label.localeCompare(PROVIDER_DATA[b].label), + PROVIDER_TYPE_DATA[a].label.localeCompare(PROVIDER_TYPE_DATA[b].label), ); - const renderIcon = (providerType: ProviderType) => { - const IconComponent = PROVIDER_DATA[providerType].icon; - return ( - }> - - - ); - }; - const selectedLabel = () => { if (selectedTypes.length === 0) return null; if (selectedTypes.length === 1) { const providerType = selectedTypes[0] as ProviderType; return ( - {renderIcon(providerType)} - {PROVIDER_DATA[providerType].label} + + + {PROVIDER_TYPE_DATA[providerType].label} + ); } return ( - - {selectedTypes.length} Provider Types selected + + ({ + key: type, + type, + tooltip: PROVIDER_TYPE_DATA[type].label, + }))} + /> + + {selectedTypes.length} Provider Types selected + ); }; @@ -329,12 +180,17 @@ export const ProviderTypeSelector = ({ - - {PROVIDER_DATA[providerType].label} + + {PROVIDER_TYPE_DATA[providerType].label} ))} diff --git a/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx b/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx index be336be2a7..026ba439f1 100644 --- a/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx +++ b/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx @@ -14,6 +14,7 @@ import { } from "@/app/(prowler)/alerts/_types"; import type { ProviderProps } from "@/types/providers"; +import { ALERTS_PERMISSION_ERROR } from "../../_lib/alert-errors"; import { AlertFormModal } from "../alert-form-modal"; const recipientsActionMocks = vi.hoisted(() => ({ @@ -701,6 +702,53 @@ describe("AlertFormModal", () => { expect(errorMessage).toHaveClass("text-text-error-primary"); }); + it("should clear the preview error when save shows the form error", async () => { + // Given + const user = userEvent.setup(); + alertsActionMocks.seedAlertRule.mockResolvedValue({ + error: "No alert-compatible filters", + }); + mockRecipientsList(); + renderCreateModal({ editingAlert: createEditingAlert() }); + + // When + await user.click(screen.getByRole("button", { name: /^test$/i })); + expect(await screen.findByText("Test result")).toBeVisible(); + await user.click(screen.getByRole("button", { name: /^save$/i })); + + // Then + await waitFor(() => + expect(screen.queryByText("Test result")).not.toBeInTheDocument(), + ); + expect( + screen.getAllByText( + "Apply at least one alert-compatible Findings filter.", + ), + ).toHaveLength(1); + }); + + it("should show the manage alerts permission error when save seed is forbidden", async () => { + // Given + const user = userEvent.setup(); + alertsActionMocks.seedAlertRule.mockResolvedValue({ + error: "You do not have permission to perform this action.", + status: 403, + }); + mockRecipientsList(); + renderCreateModal({ editingAlert: createEditingAlert() }); + + // When + await user.click(screen.getByRole("button", { name: /^save$/i })); + + // Then + expect(await screen.findByText(ALERTS_PERMISSION_ERROR)).toBeVisible(); + expect( + screen.queryByText( + "Apply at least one alert-compatible Findings filter.", + ), + ).not.toBeInTheDocument(); + }); + it("should hydrate advanced edit mode filters and normalize them on save", async () => { // Given const user = userEvent.setup(); diff --git a/ui/app/(prowler)/alerts/_components/__tests__/alerts-manager.test.tsx b/ui/app/(prowler)/alerts/_components/__tests__/alerts-manager.test.tsx index 1ee2c67302..c8a6c9fb61 100644 --- a/ui/app/(prowler)/alerts/_components/__tests__/alerts-manager.test.tsx +++ b/ui/app/(prowler)/alerts/_components/__tests__/alerts-manager.test.tsx @@ -1,6 +1,6 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { isValidElement, type ReactNode } from "react"; +import { isValidElement, type ReactNode, useState } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -8,7 +8,12 @@ import { ALERT_TRIGGER_KINDS, type AlertRule, } from "@/app/(prowler)/alerts/_types"; +import type { + AlertFormSubmitResult, + AlertFormValues, +} from "@/app/(prowler)/alerts/_types/alert-form"; +import { ALERTS_PERMISSION_ERROR } from "../../_lib/alert-errors"; import { AlertsManager } from "../alerts-manager"; const actionMocks = vi.hoisted(() => ({ @@ -96,12 +101,32 @@ vi.mock("../alert-form-modal", () => ({ open, editingAlert, onOpenChange, + onSubmit, }: { open: boolean; editingAlert?: AlertRule | null; onOpenChange: (open: boolean) => void; - }) => - open ? ( + onSubmit: (values: AlertFormValues) => Promise; + }) => { + const [error, setError] = useState(null); + + const submit = async () => { + const result = await onSubmit({ + name: "Updated alert", + description: "", + method: "email", + frequency: ALERT_TRIGGER_KINDS.AFTER_SCAN, + condition: { + op: ALERT_AGGREGATE_OPS.ANY, + filter: { severity: ["critical"] }, + }, + recipientEmails: [], + enabled: true, + }); + setError(result.ok ? null : (result.error ?? null)); + }; + + return open ? (
({ + {editingAlert?.attributes.name} + {error &&

{error}

}
- ) : null, + ) : null; + }, })); vi.mock("../alerts-empty-state", () => ({ @@ -259,6 +289,38 @@ describe("AlertsManager", () => { }); }); + it("shows a manage alerts permission message for edit 403 errors", async () => { + // Given + const user = userEvent.setup(); + const alert = makeAlert(true); + actionMocks.updateAlert.mockResolvedValue({ + error: "You do not have permission to perform this action.", + status: 403, + }); + render( + , + ); + + // When + await user.click(screen.getByRole("button", { name: /submit alert/i })); + + // Then + expect(await screen.findByText(ALERTS_PERMISSION_ERROR)).toBeVisible(); + expect(toastMock).not.toHaveBeenCalled(); + }); + it("shows a success toast after disabling an alert", async () => { // Given const user = userEvent.setup(); @@ -281,6 +343,32 @@ describe("AlertsManager", () => { ); }); + it("shows a manage alerts permission toast for disable 403 errors", async () => { + // Given + const user = userEvent.setup(); + const alert = makeAlert(true); + actionMocks.disableAlert.mockResolvedValue({ + error: "You do not have permission to perform this action.", + status: 403, + }); + renderManager([alert]); + + // When + await user.click( + screen.getByRole("button", { name: /actions for enabled alert/i }), + ); + await user.click(screen.getByRole("menuitem", { name: /disable/i })); + + // Then + await waitFor(() => + expect(toastMock).toHaveBeenCalledWith({ + variant: "destructive", + title: "Alert update failed", + description: ALERTS_PERMISSION_ERROR, + }), + ); + }); + it("shows a success toast after enabling an alert", async () => { // Given const user = userEvent.setup(); diff --git a/ui/app/(prowler)/alerts/_components/alert-form-modal.tsx b/ui/app/(prowler)/alerts/_components/alert-form-modal.tsx index e3529d4056..8a9effd890 100644 --- a/ui/app/(prowler)/alerts/_components/alert-form-modal.tsx +++ b/ui/app/(prowler)/alerts/_components/alert-form-modal.tsx @@ -54,6 +54,7 @@ import { getEmptyAlertFormDefaults, getFindingsFiltersFromAlertCondition, } from "../_lib/alert-adapter"; +import { getAlertMutationError } from "../_lib/alert-errors"; import { alertFormSchema } from "../_lib/alert-form-schema"; import type { AlertFormSubmitResult, @@ -408,7 +409,7 @@ const AlertFormModalContent = ({ if (seedResult?.error) { setPreview({ status: "error", - error: ALERT_SEED_ERROR, + error: getAlertMutationError(seedResult, ALERT_SEED_ERROR), }); return; } @@ -451,7 +452,10 @@ const AlertFormModalContent = ({ ? await seedAlertRule(pendingFilters) : null; if (seedResult?.error) { - setErrors({ root: ALERT_SEED_ERROR }); + setPreview(null); + setErrors({ + root: getAlertMutationError(seedResult, ALERT_SEED_ERROR), + }); return; } diff --git a/ui/app/(prowler)/alerts/_components/alerts-manager.tsx b/ui/app/(prowler)/alerts/_components/alerts-manager.tsx index 2b995a179d..15d98299c5 100644 --- a/ui/app/(prowler)/alerts/_components/alerts-manager.tsx +++ b/ui/app/(prowler)/alerts/_components/alerts-manager.tsx @@ -23,6 +23,7 @@ import type { MetaDataProps, ScanEntity } from "@/types"; import type { ProviderProps } from "@/types/providers"; import { toAlertPayload } from "../_lib/alert-adapter"; +import { getAlertMutationError } from "../_lib/alert-errors"; import type { AlertFormSubmitResult, AlertFormValues, @@ -108,7 +109,12 @@ export const AlertsManager = ({ } const payload = toAlertPayload(values); const result = await updateAlert(editingAlert.id, payload); - if (result?.error) return { ok: false, error: result.error }; + if (result?.error) { + return { + ok: false, + error: getAlertMutationError(result), + }; + } toast({ title: "Alert updated", description: result.data.attributes.name, @@ -127,7 +133,7 @@ export const AlertsManager = ({ toast({ variant: "destructive", title: "Alert update failed", - description: result.error, + description: getAlertMutationError(result), }); return; } @@ -147,7 +153,7 @@ export const AlertsManager = ({ toast({ variant: "destructive", title: "Alert delete failed", - description: result.error, + description: getAlertMutationError(result), }); return; } diff --git a/ui/app/(prowler)/alerts/_lib/alert-errors.ts b/ui/app/(prowler)/alerts/_lib/alert-errors.ts new file mode 100644 index 0000000000..a921338066 --- /dev/null +++ b/ui/app/(prowler)/alerts/_lib/alert-errors.ts @@ -0,0 +1,25 @@ +import { + ACTION_ERROR_STATUS, + getActionErrorMessage, +} from "@/lib/action-errors"; + +export const ALERTS_PERMISSION_ERROR = + "You don't have permission to manage alerts. Ask an administrator to update your role."; + +interface AlertActionErrorResult { + error: string; + status?: number; +} + +export const getAlertMutationError = ( + result: AlertActionErrorResult, + fallback = result.error, +): string => + getActionErrorMessage( + { ...result, error: fallback }, + { + messages: { + [ACTION_ERROR_STATUS.FORBIDDEN]: ALERTS_PERMISSION_ERROR, + }, + }, + ); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.test.tsx new file mode 100644 index 0000000000..f95aa99870 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.test.tsx @@ -0,0 +1,65 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { ATTACK_PATHS_VIEW_STATES } from "../_lib/get-attack-paths-view-state"; + +import { AttackPathsStatusPanel } from "./attack-paths-status-panel"; + +describe("AttackPathsStatusPanel", () => { + it("renders the no-scans message with a link to Scan Jobs", () => { + render( + , + ); + expect(screen.getByText(/no scans available/i)).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: /go to scan jobs/i }), + ).toHaveAttribute("href", "/scans"); + }); + + it("renders the scan-pending message", () => { + render( + , + ); + expect(screen.getByText(/scan in progress/i)).toBeInTheDocument(); + }); + + it("renders the graph-building message with progress", () => { + render( + , + ); + expect( + screen.getByText(/preparing attack paths data/i), + ).toBeInTheDocument(); + expect(screen.getByText(/45%/)).toBeInTheDocument(); + }); + + it("renders the no-graph-data message", () => { + render( + , + ); + expect(screen.getByText(/no attack paths data/i)).toBeInTheDocument(); + }); + + it("renders the error message and calls onRetry when Retry is clicked", () => { + const onRetry = vi.fn(); + render( + , + ); + expect(screen.getByText(/couldn.t load scans/i)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /retry/i })); + expect(onRetry).toHaveBeenCalledOnce(); + }); + + it("renders nothing for the ready state", () => { + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.tsx new file mode 100644 index 0000000000..89db0dbfd2 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/attack-paths-status-panel.tsx @@ -0,0 +1,87 @@ +import Link from "next/link"; + +import { Button } from "@/components/shadcn"; +import { StatusAlert } from "@/components/shared/status-alert"; + +import { + ATTACK_PATHS_VIEW_STATES, + type AttackPathsViewState, +} from "../_lib/get-attack-paths-view-state"; + +interface AttackPathsStatusPanelProps { + state: AttackPathsViewState; + progress?: number; + onRetry?: () => void; +} + +/** + * Full-page status message shown whenever the Attack Paths graph is not yet + * queryable. The page renders the normal workflow instead once `state` is + * `READY` (this component renders nothing for `READY`/`LOADING`). + */ +export const AttackPathsStatusPanel = ({ + state, + progress = 0, + onRetry, +}: AttackPathsStatusPanelProps) => { + if (state === ATTACK_PATHS_VIEW_STATES.ERROR) { + return ( + + Something went wrong loading your scans. + {onRetry ? ( + + ) : null} + + ); + } + + if (state === ATTACK_PATHS_VIEW_STATES.NO_SCANS) { + return ( + + + You need to run a scan before you can analyze attack paths.{" "} + + Go to Scan Jobs + + + + ); + } + + if (state === ATTACK_PATHS_VIEW_STATES.SCAN_PENDING) { + return ( + + + Your scan is queued. Attack Paths will be available once it completes. + + + ); + } + + if (state === ATTACK_PATHS_VIEW_STATES.GRAPH_BUILDING) { + return ( + + + We're building the graph from your latest scan ({progress}%). + This will be ready shortly. + + + ); + } + + if (state === ATTACK_PATHS_VIEW_STATES.NO_GRAPH_DATA) { + return ( + + This scan didn't produce Attack Paths data. + + ); + } + + return null; +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-execution-error.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-execution-error.tsx index 084bcf6e16..b451cf58f6 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-execution-error.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-execution-error.tsx @@ -1,6 +1,4 @@ -import { CircleAlert } from "lucide-react"; - -import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn"; +import { StatusAlert } from "@/components/shared/status-alert"; interface QueryExecutionErrorProps { error: string; @@ -14,17 +12,17 @@ export const QueryExecutionError = ({ description, }: QueryExecutionErrorProps) => { return ( - - - {title} - - {description ?

{description}

: null} -
-
-            {error}
-          
-
-
-
+ + {description ?

{description}

: null} +
+
+          {error}
+        
+
+
); }; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.test.tsx index 4aec4b97d3..5ec29558ff 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.test.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.test.tsx @@ -58,6 +58,7 @@ vi.mock("@/components/ui/table", () => ({ data, metadata, controlledPage, + getRowAttributes, }: { columns: Array<{ id?: string; @@ -74,6 +75,10 @@ vi.mock("@/components/ui/table", () => ({ }; }; controlledPage: number; + getRowAttributes?: (row: { + index: number; + original: AttackPathScan; + }) => Record; }) => (
{metadata.pagination.count} Total Entries @@ -95,8 +100,8 @@ vi.mock("@/components/ui/table", () => ({ - {data.map((row) => ( - + {data.map((row, index) => ( + {columns.map((column, index) => ( {column.cell @@ -176,6 +181,20 @@ describe("ScanListTable", () => { ); }); + it("anchors the attack paths scan tour to the first visible scan row", () => { + render( + , + ); + + const firstRow = screen + .getAllByRole("radio", { + name: "Select scan", + })[0] + .closest("tr"); + + expect(firstRow).toHaveAttribute("data-tour-id", "attack-paths-scan-list"); + }); + it("enables the radio button for a failed scan when graph data is ready", async () => { const user = userEvent.setup(); const failedScan: AttackPathScan = { diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.tsx index 00a330810f..dd532f9ed6 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.tsx @@ -295,6 +295,9 @@ export const ScanListTable = ({ scans }: ScanListTableProps) => { handleSelectScan(row.original.id); } }} + getRowAttributes={(row) => + row.index === 0 ? { "data-tour-id": "attack-paths-scan-list" } : {} + } enableRowSelection rowSelection={getSelectedRowSelection(paginatedScans, selectedScanId)} /> diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/index.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/index.ts index eec8e5f81a..c30edb519c 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/index.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/index.ts @@ -1,3 +1,4 @@ +export { useAttackPathScans } from "./use-attack-path-scans"; export { useGraphState } from "./use-graph-state"; export { useQueryBuilder } from "./use-query-builder"; export { useWizardState } from "./use-wizard-state"; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-attack-path-scans.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-attack-path-scans.ts new file mode 100644 index 0000000000..5957e175d7 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-attack-path-scans.ts @@ -0,0 +1,102 @@ +"use client"; + +import { useRef, useState } from "react"; + +import { getAttackPathScans } from "@/actions/attack-paths"; +import { useMountEffect } from "@/hooks/use-mount-effect"; +import type { AttackPathScan } from "@/types/attack-paths"; + +export interface UseAttackPathScansOptions { + /** + * Invoked once the initial load resolves with no scan whose graph data is + * ready (including empty results or a fetch failure). The page passes a + * redirect only during onboarding replay; an established user gets `undefined` + * and stays on the page. + */ + onNoReadyScan?: () => void; +} + +export interface UseAttackPathScansResult { + scans: AttackPathScan[]; + scansLoading: boolean; + loadError: boolean; + refreshScans: () => Promise; + retryLoadScans: () => Promise; +} + +/** + * `useData`-style hook owning the Attack Paths scan list. The direct + * `useEffect` (via `useMountEffect`) lives here, not in the component: the + * project forbids `useEffect` in components, but a reusable data hook is the + * sanctioned place for a mount-time fetch when no fetching library is wired up. + */ +export function useAttackPathScans( + options: UseAttackPathScansOptions = {}, +): UseAttackPathScansResult { + const { onNoReadyScan } = options; + + const [scans, setScans] = useState([]); + const [scansLoading, setScansLoading] = useState(true); + const [loadError, setLoadError] = useState(false); + const mountedRef = useRef(true); + + // Silent background refresh for auto-refresh: never flips loading/error, so it + // can't disrupt the visible view if it fails. + const refreshScans = async () => { + try { + const scansData = await getAttackPathScans(); + if (scansData?.data) { + setScans(scansData.data); + } + } catch (error) { + console.error("Failed to refresh scans:", error); + } + }; + + // Full (re)load: drives loading + error state. Runs on mount and is reused by + // the error view's Retry action. A successful empty result (`{ data: [] }`) is + // not an error; only a missing payload or a thrown request is. + const loadScans = async () => { + setScansLoading(true); + setLoadError(false); + try { + const scansData = await getAttackPathScans(); + if (!mountedRef.current) return; + if (scansData?.data) { + setScans(scansData.data); + if (!scansData.data.some((scan) => scan.attributes.graph_data_ready)) { + onNoReadyScan?.(); + } + } else { + setScans([]); + setLoadError(true); + onNoReadyScan?.(); + } + } catch (error) { + if (!mountedRef.current) return; + console.error("Failed to load scans:", error); + setScans([]); + setLoadError(true); + onNoReadyScan?.(); + } finally { + if (mountedRef.current) setScansLoading(false); + } + }; + + useMountEffect(() => { + mountedRef.current = true; + void loadScans(); + + return () => { + mountedRef.current = false; + }; + }); + + return { + scans, + scansLoading, + loadError, + refreshScans, + retryLoadScans: loadScans, + }; +} diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/get-attack-paths-view-state.test.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/get-attack-paths-view-state.test.ts new file mode 100644 index 0000000000..c6722021ed --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/get-attack-paths-view-state.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest"; + +import type { AttackPathScan, ScanState } from "@/types/attack-paths"; + +import { + ATTACK_PATHS_VIEW_STATES, + getAttackPathsViewState, + getGraphBuildingProgress, + isScanInFlight, +} from "./get-attack-paths-view-state"; + +const scan = ( + state: ScanState, + graph_data_ready: boolean, + progress = 0, +): AttackPathScan => ({ + type: "attack-paths-scans", + id: `${state}-${String(graph_data_ready)}-${progress}`, + attributes: { + state, + progress, + graph_data_ready, + provider_alias: "Provider", + provider_type: "aws", + provider_uid: "123456789012", + inserted_at: "2026-04-21T10:00:00Z", + started_at: "2026-04-21T10:00:00Z", + completed_at: null, + duration: null, + }, + relationships: { + provider: { data: { type: "providers", id: "p" } }, + scan: { data: { type: "scans", id: "s" } }, + task: { data: { type: "tasks", id: "t" } }, + }, +}); + +describe("getAttackPathsViewState", () => { + it("returns loading while scans are loading, regardless of other inputs", () => { + expect( + getAttackPathsViewState({ + scansLoading: true, + loadError: true, + scans: [], + }), + ).toBe(ATTACK_PATHS_VIEW_STATES.LOADING); + }); + + it("returns error on load failure (error wins over empty scans)", () => { + expect( + getAttackPathsViewState({ + scansLoading: false, + loadError: true, + scans: [], + }), + ).toBe(ATTACK_PATHS_VIEW_STATES.ERROR); + }); + + it("returns no-scans for an empty list", () => { + expect( + getAttackPathsViewState({ + scansLoading: false, + loadError: false, + scans: [], + }), + ).toBe(ATTACK_PATHS_VIEW_STATES.NO_SCANS); + }); + + it("returns ready when any provider has a queryable graph", () => { + expect( + getAttackPathsViewState({ + scansLoading: false, + loadError: false, + scans: [scan("executing", false, 50), scan("completed", true, 100)], + }), + ).toBe(ATTACK_PATHS_VIEW_STATES.READY); + }); + + it("returns graph-building when none ready and some scan is executing (wins over scheduled)", () => { + expect( + getAttackPathsViewState({ + scansLoading: false, + loadError: false, + scans: [scan("scheduled", false), scan("executing", false, 30)], + }), + ).toBe(ATTACK_PATHS_VIEW_STATES.GRAPH_BUILDING); + }); + + it("returns scan-pending when none ready and some scan is scheduled/available", () => { + expect( + getAttackPathsViewState({ + scansLoading: false, + loadError: false, + scans: [scan("scheduled", false)], + }), + ).toBe(ATTACK_PATHS_VIEW_STATES.SCAN_PENDING); + expect( + getAttackPathsViewState({ + scansLoading: false, + loadError: false, + scans: [scan("available", false)], + }), + ).toBe(ATTACK_PATHS_VIEW_STATES.SCAN_PENDING); + }); + + it("returns no-graph-data when none ready and all scans are terminal", () => { + expect( + getAttackPathsViewState({ + scansLoading: false, + loadError: false, + scans: [scan("completed", false), scan("failed", false)], + }), + ).toBe(ATTACK_PATHS_VIEW_STATES.NO_GRAPH_DATA); + }); +}); + +describe("isScanInFlight", () => { + it("is true for an available scan (created, not yet scheduled)", () => { + expect(isScanInFlight([scan("available", false)])).toBe(true); + }); + + it("is true for scheduled and executing scans", () => { + expect(isScanInFlight([scan("scheduled", false)])).toBe(true); + expect(isScanInFlight([scan("executing", false, 40)])).toBe(true); + }); + + it("is false when every scan is in a terminal state", () => { + expect( + isScanInFlight([scan("completed", true), scan("failed", false)]), + ).toBe(false); + }); + + it("is false for an empty list", () => { + expect(isScanInFlight([])).toBe(false); + }); +}); + +describe("getGraphBuildingProgress", () => { + it("returns the max progress among executing scans", () => { + expect( + getGraphBuildingProgress([ + scan("executing", false, 30), + scan("executing", false, 70), + scan("scheduled", false, 99), + ]), + ).toBe(70); + }); + + it("returns 0 when no scan is executing", () => { + expect(getGraphBuildingProgress([scan("scheduled", false, 50)])).toBe(0); + }); +}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/get-attack-paths-view-state.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/get-attack-paths-view-state.ts new file mode 100644 index 0000000000..0e734304f8 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/get-attack-paths-view-state.ts @@ -0,0 +1,65 @@ +import type { AttackPathScan, ScanState } from "@/types/attack-paths"; +import { SCAN_STATES } from "@/types/attack-paths"; + +// In-flight = scan still progressing toward a graph. AVAILABLE is the default +// state of a new scan. Shared by the deriver and polling so they can't diverge. +const IN_FLIGHT_SCAN_STATES: ScanState[] = [ + SCAN_STATES.AVAILABLE, + SCAN_STATES.SCHEDULED, + SCAN_STATES.EXECUTING, +]; + +export const isScanInFlight = (scans: AttackPathScan[]): boolean => + scans.some((s) => IN_FLIGHT_SCAN_STATES.includes(s.attributes.state)); + +export const ATTACK_PATHS_VIEW_STATES = { + LOADING: "loading", + ERROR: "error", + NO_SCANS: "no-scans", + SCAN_PENDING: "scan-pending", + GRAPH_BUILDING: "graph-building", + NO_GRAPH_DATA: "no-graph-data", + READY: "ready", +} as const; + +export type AttackPathsViewState = + (typeof ATTACK_PATHS_VIEW_STATES)[keyof typeof ATTACK_PATHS_VIEW_STATES]; + +interface GetAttackPathsViewStateInput { + scansLoading: boolean; + loadError: boolean; + scans: AttackPathScan[]; +} + +/** + * Single source of truth for what the Attack Paths page shows. The full-page + * message owns every "not queryable yet" state; the workflow renders only once + * at least one provider's graph is ready. + */ +export const getAttackPathsViewState = ({ + scansLoading, + loadError, + scans, +}: GetAttackPathsViewStateInput): AttackPathsViewState => { + if (scansLoading) return ATTACK_PATHS_VIEW_STATES.LOADING; + if (loadError) return ATTACK_PATHS_VIEW_STATES.ERROR; + if (scans.length === 0) return ATTACK_PATHS_VIEW_STATES.NO_SCANS; + + if (scans.some((s) => s.attributes.graph_data_ready)) { + return ATTACK_PATHS_VIEW_STATES.READY; + } + if (scans.some((s) => s.attributes.state === SCAN_STATES.EXECUTING)) { + return ATTACK_PATHS_VIEW_STATES.GRAPH_BUILDING; + } + // EXECUTING returned above; an in-flight scan here is AVAILABLE/SCHEDULED. + if (isScanInFlight(scans)) { + return ATTACK_PATHS_VIEW_STATES.SCAN_PENDING; + } + return ATTACK_PATHS_VIEW_STATES.NO_GRAPH_DATA; +}; + +/** Highest progress among scans whose graph is actively building. */ +export const getGraphBuildingProgress = (scans: AttackPathScan[]): number => + scans + .filter((s) => s.attributes.state === SCAN_STATES.EXECUTING) + .reduce((max, s) => Math.max(max, s.attributes.progress), 0); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx index 9e455a4b6d..bc2cf36331 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx @@ -75,6 +75,31 @@ describe("loading the page", () => { }); }); +describe("waiting states", () => { + test("a pending scan shows the scan-in-progress message", async ({ + mountWith, + }) => { + const graph = await mountWith(fixtures.scanPending()); + expect(await graph.emptyStateMessage()).toMatch(/scan in progress/i); + }); + + test("a building graph shows the preparing message with progress", async ({ + mountWith, + }) => { + const graph = await mountWith(fixtures.graphBuilding()); + const message = await graph.emptyStateMessage(); + expect(message).toMatch(/preparing attack paths data/i); + expect(message).toMatch(/45%/); + }); + + test("a completed scan with no graph shows the no-data message", async ({ + mountWith, + }) => { + const graph = await mountWith(fixtures.noGraphData()); + expect(await graph.emptyStateMessage()).toMatch(/no attack paths data/i); + }); +}); + describe("running a query", () => { test("the graph renders with a background, a minimap, and a viewport", async ({ mountWith, diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.fixtures.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.fixtures.ts index 37e2170355..41089af527 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.fixtures.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.fixtures.ts @@ -143,6 +143,52 @@ export const emptyScans = (): PageFixture => ({ queryResult: null, }); +export const scanPending = (): PageFixture => ({ + scans: [ + buildScan(TYPICAL_SCAN_ID, { + state: "scheduled", + progress: 0, + graph_data_ready: false, + completed_at: null, + duration: null, + }), + ], + scanId: TYPICAL_SCAN_ID, + queries: [], + queryId: DEFAULT_QUERY_ID, + queryResult: null, +}); + +export const graphBuilding = (): PageFixture => ({ + scans: [ + buildScan(TYPICAL_SCAN_ID, { + state: "executing", + progress: 45, + graph_data_ready: false, + completed_at: null, + duration: null, + }), + ], + scanId: TYPICAL_SCAN_ID, + queries: [], + queryId: DEFAULT_QUERY_ID, + queryResult: null, +}); + +export const noGraphData = (): PageFixture => ({ + scans: [ + buildScan(TYPICAL_SCAN_ID, { + state: "completed", + progress: 100, + graph_data_ready: false, + }), + ], + scanId: TYPICAL_SCAN_ID, + queries: [], + queryId: DEFAULT_QUERY_ID, + queryResult: null, +}); + export const emptyGraph = (): PageFixture => ({ scans: [buildScan(TYPICAL_SCAN_ID)], scanId: TYPICAL_SCAN_ID, @@ -269,6 +315,9 @@ export const edgeCases = (): PageFixture => { export const fixtures = { typical, emptyScans, + scanPending, + graphBuilding, + noGraphData, emptyGraph, singleNode, findingsOnly, diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.test.tsx deleted file mode 100644 index 8b91c3fb6e..0000000000 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.test.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { readFileSync } from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { describe, expect, it } from "vitest"; - -describe("AttackPathsPage", () => { - const currentDir = path.dirname(fileURLToPath(import.meta.url)); - const filePath = path.join(currentDir, "attack-paths-page.tsx"); - const source = readFileSync(filePath, "utf8"); - - it("keeps the page description without rendering a duplicate Attack Paths heading", () => { - // Then - expect(source).not.toContain(">\n Attack Paths\n "); - expect(source).toContain( - "Select a scan, build a query, and visualize Attack Paths in your", - ); - }); -}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx index 992fdee5ef..a103a326be 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx @@ -1,8 +1,7 @@ "use client"; -import { ArrowLeft, Info, Maximize2 } from "lucide-react"; -import Link from "next/link"; -import { useSearchParams } from "next/navigation"; +import { ArrowLeft, Maximize2 } from "lucide-react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { Suspense, useEffect, useRef, useState } from "react"; import { FormProvider } from "react-hook-form"; @@ -10,19 +9,14 @@ import { buildAttackPathQueries, executeCustomQuery, executeQuery, - getAttackPathScans, getAvailableQueries, } from "@/actions/attack-paths"; import { adaptQueryResultToGraphData } from "@/actions/attack-paths/query-result.adapter"; import { FindingDetailDrawer } from "@/components/findings/table"; +import { PageReady } from "@/components/onboarding"; import { useFindingDetails } from "@/components/resources/table/use-finding-details"; import { AutoRefresh } from "@/components/scans"; -import { - Alert, - AlertDescription, - AlertTitle, - Button, -} from "@/components/shadcn"; +import { Button } from "@/components/shadcn"; import { Dialog, DialogContent, @@ -31,11 +25,21 @@ import { DialogTitle, DialogTrigger, } from "@/components/shadcn/dialog"; +import { StatusAlert } from "@/components/shared/status-alert"; import { useToast } from "@/components/ui"; +import { useMountEffect } from "@/hooks/use-mount-effect"; +import { isCloud } from "@/lib/shared/env"; +import { attackPathsEmptyTour } from "@/lib/tours/attack-paths-empty.tour"; +import { + attackPathsTour, + type AttackPathsTourTarget, + pickDemoQuery, + pickDemoScan, +} from "@/lib/tours/attack-paths.tour"; +import { advanceActiveTour, useDriverTour } from "@/lib/tours/use-driver-tour"; import type { AttackPathQuery, AttackPathQueryError, - AttackPathScan, GraphNode, } from "@/types/attack-paths"; import { ATTACK_PATH_QUERY_IDS, SCAN_STATES } from "@/types/attack-paths"; @@ -52,24 +56,42 @@ import { QuerySelector, ScanListTable, } from "./_components"; +import { AttackPathsStatusPanel } from "./_components/attack-paths-status-panel"; import type { GraphHandle } from "./_components/graph/attack-path-graph"; +import { useAttackPathScans } from "./_hooks/use-attack-path-scans"; import { useGraphState } from "./_hooks/use-graph-state"; import { useQueryBuilder } from "./_hooks/use-query-builder"; import { exportGraphAsPNG } from "./_lib"; +import { + ATTACK_PATHS_VIEW_STATES, + getAttackPathsViewState, + getGraphBuildingProgress, + isScanInFlight, +} from "./_lib/get-attack-paths-view-state"; + +const SCROLL_CONTAINER_CLASS = + "minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary relative z-0 flex w-full flex-col gap-4 overflow-auto border p-4"; -/** - * Attack Paths - * Allows users to select a scan, build a query, and visualize the attack path graph - */ export default function AttackPathsPage() { const searchParams = useSearchParams(); + const pathname = usePathname(); + const router = useRouter(); const scanId = searchParams.get("scanId"); + // Onboarding tours are Cloud-only. + const onboardingEnabled = isCloud(); + const isAttackPathsReplay = + onboardingEnabled && searchParams.get("onboarding") === "attack-paths"; const graphState = useGraphState(); const finding = useFindingDetails(); const { toast } = useToast(); - const [scansLoading, setScansLoading] = useState(true); - const [scans, setScans] = useState([]); + const { scans, scansLoading, loadError, refreshScans, retryLoadScans } = + useAttackPathScans({ + onNoReadyScan: isAttackPathsReplay + ? () => router.push("/scans?onboarding=view-first-scan") + : undefined, + }); + const [queriesLoading, setQueriesLoading] = useState(true); const [queriesError, setQueriesError] = useState(null); const [isFullscreenOpen, setIsFullscreenOpen] = useState(false); @@ -81,10 +103,64 @@ export default function AttackPathsPage() { const [queries, setQueries] = useState([]); - // Use custom hook for query builder form state and validation const queryBuilder = useQueryBuilder(queries); - // Reset graph state when component mounts + const hasReadyScan = scans.some((scan) => scan.attributes.graph_data_ready); + const hasNoScans = scans.length === 0; + + useDriverTour(attackPathsEmptyTour, { + // Gate on !loadError: the empty-scans CTA anchor only renders in the + // NO_SCANS view-state, not in the ERROR state (which also has scans === []). + enabled: onboardingEnabled && !scansLoading && !loadError && hasNoScans, + }); + + const { start: startAttackPathsTour } = useDriverTour( + attackPathsTour, + { + enabled: onboardingEnabled && !scansLoading && hasReadyScan, + autoOpen: !isAttackPathsReplay, + // Page owns tour auto-open; OnboardingSequenceBanner is the sole Continue/Skip control. + // pickDemoScan/pickDemoQuery policy lives in attack-paths.tour.ts. + stepHandlers: { + "scan-list": { + onNext: async ({ waitForStep }) => { + const selected = pickDemoScan(scans); + if (!selected) return; + const params = new URLSearchParams(searchParams.toString()); + params.set("scanId", selected.id); + router.push(`${pathname}?${params.toString()}`); + await waitForStep("query-selector"); + }, + }, + "query-selector": { + onNext: async ({ waitForStep }) => { + const selected = pickDemoQuery(queries); + if (!selected) return; + queryBuilder.handleQueryChange(selected.id); + await waitForStep("execute-button"); + }, + }, + }, + }, + ); + + // Onboarding replay entry: start the tour once and strip the `onboarding` + // param. Invoked from , which mounts only when the + // replay conditions hold — so `useMountEffect` fires it exactly once and the + // old `replayStartedRef` run-once guard is gone. + const startAttackPathsReplay = () => { + startAttackPathsTour(); + + const params = new URLSearchParams(searchParams.toString()); + params.delete("onboarding"); + const query = params.toString(); + window.history.replaceState( + null, + "", + query ? `${pathname}?${query}` : pathname, + ); + }; + useEffect(() => { if (!hasResetRef.current) { hasResetRef.current = true; @@ -92,39 +168,14 @@ export default function AttackPathsPage() { } }, [graphState]); - // Reset graph state when scan changes useEffect(() => { graphState.resetGraph(); }, [scanId]); // eslint-disable-line react-hooks/exhaustive-deps -- reset on scanId change only - // Load available scans on mount - useEffect(() => { - const loadScans = async () => { - setScansLoading(true); - try { - const scansData = await getAttackPathScans(); - if (scansData?.data) { - setScans(scansData.data); - } else { - setScans([]); - } - } catch (error) { - console.error("Failed to load scans:", error); - setScans([]); - } finally { - setScansLoading(false); - } - }; + // Poll while a scan is in flight so the page auto-advances when the graph is ready. + const hasScanInFlight = isScanInFlight(scans); - loadScans(); - }, []); - - // Check if there's an executing scan for auto-refresh - const hasExecutingScan = scans.some( - (scan) => - scan.attributes.state === SCAN_STATES.EXECUTING || - scan.attributes.state === SCAN_STATES.SCHEDULED, - ); + const viewState = getAttackPathsViewState({ scansLoading, loadError, scans }); // Detect if the selected scan is showing data from a previous cycle const selectedScan = scans.find((scan) => scan.id === scanId); @@ -133,19 +184,6 @@ export default function AttackPathsPage() { selectedScan.attributes.graph_data_ready && selectedScan.attributes.state !== SCAN_STATES.COMPLETED; - // Callback to refresh scans (used by AutoRefresh component) - const refreshScans = async () => { - try { - const scansData = await getAttackPathScans(); - if (scansData?.data) { - setScans(scansData.data); - } - } catch (error) { - console.error("Failed to refresh scans:", error); - } - }; - - // Load available queries on mount useEffect(() => { const loadQueries = async () => { if (!scanId) { @@ -205,7 +243,6 @@ export default function AttackPathsPage() { return; } - // Validate form before executing query const isValid = await queryBuilder.form.trigger(); if (!isValid) { showErrorToast( @@ -215,6 +252,9 @@ export default function AttackPathsPage() { return; } + // The tour's execute step is autoAdvance: the real Execute click moves it forward. + advanceActiveTour(); + graphState.startLoading(); graphState.setError(null); @@ -257,7 +297,6 @@ export default function AttackPathsPage() { variant: "default", }); - // Scroll to graph after successful query execution setTimeout(() => { graphContainerRef.current?.scrollIntoView({ behavior: "smooth", @@ -297,13 +336,9 @@ export default function AttackPathsPage() { } findingNavigationInFlightRef.current = true; - // Findings skip the intermediate node-details modal. The finding drawer - // is the useful destination, so open it directly from the graph click. + // Open finding drawer directly, bypassing the node-details modal. graphState.enterFilteredView(node.id); - // enterFilteredView stores the filtered node as selected so the graph can - // highlight it. Clear the selection right after for findings so the node - // details modal does not open before the finding drawer. - graphState.selectNode(null); + graphState.selectNode(null); // clear so node-details modal doesn't open first void handleViewFinding(String(node.properties?.id || node.id)); return; } @@ -368,14 +403,19 @@ export default function AttackPathsPage() { return (
- {/* Auto-refresh scans when there's an executing scan */} - {/* Page introduction */} -
+ {isAttackPathsReplay && !scansLoading && hasReadyScan && ( + + )} + + {/* Enables the navbar replay icon once the initial scan load resolves. */} + {!scansLoading && } + +

Select a scan, build a query, and visualize Attack Paths in your infrastructure. @@ -386,48 +426,44 @@ export default function AttackPathsPage() {

- {scansLoading ? ( -
+ {viewState === ATTACK_PATHS_VIEW_STATES.LOADING ? ( +

Loading scans...

- ) : scans.length === 0 ? ( - - - No scans available - - - You need to run a scan before you can analyze attack paths.{" "} - - Go to Scan Jobs - - - - + ) : viewState === ATTACK_PATHS_VIEW_STATES.NO_SCANS ? ( + // Keep the empty-scans tour anchor: attackPathsEmptyTour targets + // data-tour-id="attack-paths-empty-scans-cta". The panel's NO_SCANS + // render is the same "No scans available" + Go to Scan Jobs CTA. +
+ +
+ ) : viewState !== ATTACK_PATHS_VIEW_STATES.READY ? ( + ) : ( <> - {/* Scans Table */} Loading scans...
}> - {/* Banner: viewing data from a previous scan cycle */} {isViewingPreviousCycleData && ( - - - Viewing data from a previous scan - - This scan is currently{" "} - {selectedScan.attributes.state === SCAN_STATES.EXECUTING - ? `running (${selectedScan.attributes.progress}%)` - : selectedScan.attributes.state} - . The graph data shown is from the last completed cycle. - - + + This scan is currently{" "} + {selectedScan.attributes.state === SCAN_STATES.EXECUTING + ? `running (${selectedScan.attributes.progress}%)` + : selectedScan.attributes.state} + . The graph data shown is from the last completed cycle. + )} - {/* Query Builder Section - shown only after selecting a scan */} {scanId && ( -
+
{queriesLoading ? (

Loading queries...

) : queriesError ? ( @@ -438,11 +474,13 @@ export default function AttackPathsPage() { ) : ( <> - +
+ +
{queryBuilder.selectedQueryData && ( -
+
)} - {/* Graph Visualization (Full Width) */} {(graphState.loading || (graphState.data && graphState.data.nodes && graphState.data.nodes.length > 0)) && ( -
+
{graphState.loading ? ( ) : graphState.data && graphState.data.nodes && graphState.data.nodes.length > 0 ? ( <> - {/* Info message and controls */}
{graphState.isFilteredView ? (
@@ -537,7 +576,6 @@ export default function AttackPathsPage() {
)} - {/* Graph controls and fullscreen button together */}
graphRef.current?.zoomIn()} @@ -546,7 +584,6 @@ export default function AttackPathsPage() { onExport={() => handleGraphExport("main")} /> - {/* Fullscreen button */}
- {/* Graph in the middle */}
- {/* Legend below */}
); } + +interface AttackPathsReplayTriggerProps { + onReplay: () => void; +} + +// Conditional-mount trigger: the parent renders this only when the replay +// should start. The microtask keeps driver.js/flushSync outside React's +// mount lifecycle while still running before the next browser task. +function AttackPathsReplayTrigger({ onReplay }: AttackPathsReplayTriggerProps) { + useMountEffect(() => { + let cancelled = false; + + queueMicrotask(() => { + if (!cancelled) onReplay(); + }); + + return () => { + cancelled = true; + }; + }); + + return null; +} diff --git a/ui/app/(prowler)/attack-paths/layout.tsx b/ui/app/(prowler)/attack-paths/layout.tsx index 5ff93faab2..3a9a90b6d1 100644 --- a/ui/app/(prowler)/attack-paths/layout.tsx +++ b/ui/app/(prowler)/attack-paths/layout.tsx @@ -6,7 +6,11 @@ export default function AttackPathsLayout({ children: React.ReactNode; }) { return ( - + {children} ); diff --git a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx index 71e7870f8d..f5d42f8bc8 100644 --- a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx +++ b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx @@ -13,6 +13,7 @@ import { ClientAccordionWrapper, ComplianceDownloadContainer, ComplianceHeader, + ComplianceWarming, RequirementsStatusCard, RequirementsStatusCardSkeleton, // SectionsFailureRateCard, @@ -86,12 +87,22 @@ export default async function ComplianceDetail({ "filter[scan_id]": selectedScanId ?? undefined, }, }), - getComplianceAttributes(complianceId), + getComplianceAttributes(complianceId, selectedScanId ?? undefined), selectedScanId ? getScan(selectedScanId, { include: "provider" }) : Promise.resolve(null), ]); + // The compliance catalog is still warming after a deploy/restart. Show the + // "still loading" state with a Try Again instead of rendering an empty page. + if (attributesData?.warming) { + return ( + + + + ); + } + if (selectedScanResponse?.data) { const scan = selectedScanResponse.data; const providerId = scan.relationships?.provider?.data?.id; diff --git a/ui/app/(prowler)/compliance/page.tsx b/ui/app/(prowler)/compliance/page.tsx index a67509bed1..d4ee609a0c 100644 --- a/ui/app/(prowler)/compliance/page.tsx +++ b/ui/app/(prowler)/compliance/page.tsx @@ -46,16 +46,26 @@ export default async function Compliance({ }); if (!scansData?.data) { - return ; + return ( + + + + ); } - // Process scans with provider information from included data const expandedScansData: ExpandedScanData[] = scansData.data .filter((scan: ScanProps) => scan.relationships?.provider?.data?.id) .map((scan: ScanProps) => { const providerId = scan.relationships!.provider!.data!.id; - // Find the provider data in the included array const providerData = scansData.included?.find( (item: { type: string; id: string }) => item.type === "providers" && item.id === providerId, @@ -76,15 +86,20 @@ export default async function Compliance({ }) .filter(Boolean) as ExpandedScanData[]; - // Use scanId from URL, or select the first scan if not provided const scanIdParam = resolvedSearchParams.scanId; const scanIdFromUrl = Array.isArray(scanIdParam) ? scanIdParam[0] : scanIdParam; const selectedScanId: string | null = scanIdFromUrl || expandedScansData[0]?.id || null; + const onboardingAction = selectedScanId + ? { flowId: "view-compliance" } + : { + flowId: "view-compliance", + fallbackFlowId: "view-first-scan", + useFallback: true, + }; - // Find the selected scan const selectedScan = expandedScansData.find( (scan) => scan.id === selectedScanId, ); @@ -100,7 +115,6 @@ export default async function Compliance({ } : undefined; - // Fetch metadata if we have a selected scan const metadataInfoData = selectedScanId ? await getComplianceOverviewMetadataInfo({ filters: { @@ -111,7 +125,6 @@ export default async function Compliance({ const uniqueRegions = metadataInfoData?.data?.attributes?.regions || []; - // Fetch ThreatScore data from API if we have a selected scan let threatScoreData = null; if (selectedScanId && typeof selectedScanId === "string") { const threatScoreResponse = await getThreatScore({ @@ -128,10 +141,13 @@ export default async function Compliance({ } return ( - + {selectedScanId ? ( <> - {/* Row 1: Filters */}
- {/* Row 2: ThreatScore card — full width, horizontal */} {threatScoreData && typeof selectedScanId === "string" && selectedScan && ( @@ -155,7 +170,6 @@ export default async function Compliance({
)} - {/* Row 3: Compliance grid with client-side search */} { const regionFilter = searchParams["filter[region__in]"]?.toString() || ""; - // Only fetch compliance data if we have a valid scanId const compliancesData = scanId && scanId.trim() !== "" ? await getCompliancesOverview({ @@ -207,7 +220,6 @@ const SSRComplianceGrid = async ({ a.attributes.framework.localeCompare(b.attributes.framework), ); - // Check if the response contains no data if ( !compliancesData || !compliancesData.data || @@ -225,7 +237,6 @@ const SSRComplianceGrid = async ({ ); } - // Handle errors returned by the API if (compliancesData?.errors?.length > 0) { return ( @@ -235,10 +246,7 @@ const SSRComplianceGrid = async ({ ); } - // Compute the set of latest CIS variants per provider once, so each card - // can gate its PDF button without re-parsing on every render. The backend - // only generates a CIS PDF for the latest version per provider, so any - // other CIS card must not expose the PDF download button. + // Backend only generates CIS PDFs for the latest version per provider. const latestCisIds = pickLatestCisPerProvider( compliancesData.data.map( (compliance: ComplianceOverviewData) => compliance.id, diff --git a/ui/app/(prowler)/findings/page.tsx b/ui/app/(prowler)/findings/page.tsx index 7ff9780292..65e8eca9cd 100644 --- a/ui/app/(prowler)/findings/page.tsx +++ b/ui/app/(prowler)/findings/page.tsx @@ -59,7 +59,6 @@ export default async function Findings({ filters: resolvedFilters, }); - // Extract unique regions, services, categories, groups from the new endpoint const uniqueRegions = metadataInfoData?.data?.attributes?.regions || []; const uniqueServices = metadataInfoData?.data?.attributes?.services || []; const uniqueResourceTypes = @@ -67,7 +66,6 @@ export default async function Findings({ const uniqueCategories = metadataInfoData?.data?.attributes?.categories || []; const uniqueGroups = metadataInfoData?.data?.attributes?.groups || []; - // Extract scan UUIDs with "completed" state and more than one resource const completedScans = scansData?.data?.filter( (scan: ScanProps) => scan.attributes.state === "completed" && @@ -76,6 +74,14 @@ export default async function Findings({ const completedScanIds = completedScans?.map((scan: ScanProps) => scan.id) || []; + const onboardingAction = + completedScanIds.length > 0 + ? { flowId: "explore-findings" } + : { + flowId: "explore-findings", + fallbackFlowId: "view-first-scan", + useFallback: true, + }; const scanDetails = createScanDetailsMapping( completedScans || [], @@ -84,7 +90,11 @@ export default async function Findings({ const alertsEnabled = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"; return ( - +
g.id).join(","); return ( diff --git a/ui/app/(prowler)/layout.tsx b/ui/app/(prowler)/layout.tsx index 958dfc80d6..2554ab02b9 100644 --- a/ui/app/(prowler)/layout.tsx +++ b/ui/app/(prowler)/layout.tsx @@ -2,16 +2,25 @@ import "@/styles/globals.css"; import * as Sentry from "@sentry/nextjs"; import { Metadata, Viewport } from "next"; -import { ReactNode } from "react"; +import { ReactNode, Suspense } from "react"; import { getProviders } from "@/actions/providers"; +import { getScansByState } from "@/actions/scans/scans"; +import { + OnboardingCheckpointWatcher, + OnboardingGate, + OnboardingSequenceBanner, +} from "@/components/onboarding"; +import { RuntimePublicConfig } from "@/components/runtime-config/runtime-public-config"; import MainLayout from "@/components/ui/main-layout/main-layout"; import { NavigationProgress } from "@/components/ui/navigation-progress"; import { Toaster } from "@/components/ui/toast"; import { fontSans } from "@/config/fonts"; import { siteConfig } from "@/config/site"; +import { isCloud } from "@/lib/shared/env"; import { cn } from "@/lib/utils"; import { StoreInitializer } from "@/store/ui/store-initializer"; +import { SCAN_STATES } from "@/types/attack-paths"; import { Providers } from "../providers"; @@ -41,12 +50,36 @@ export default async function RootLayout({ }: { children: ReactNode; }) { - const providersData = await getProviders({ page: 1, pageSize: 1 }); - const hasProviders = !!(providersData?.data && providersData.data.length > 0); + // Onboarding is Cloud-only; skip its fetches and orchestrators in OSS. + const onboardingEnabled = isCloud(); + + // Fail-open: unknown scan state is treated as "has data" so the banner never blocks + // progression on a fetch error. + let hasCompletedScan = true; + // Tri-state: true = has providers, false = zero providers, undefined = fetch failed (gate fails open). + let hasProviders: boolean | undefined = false; + + if (onboardingEnabled) { + const [providersData, scansByState] = await Promise.all([ + getProviders({ page: 1, pageSize: 1 }), + getScansByState(), + ]); + hasCompletedScan = Array.isArray(scansByState?.data) + ? scansByState.data.some( + (scan: { attributes?: { state?: string } }) => + scan.attributes?.state === SCAN_STATES.COMPLETED, + ) + : true; + hasProviders = Array.isArray(providersData?.data) + ? providersData.data.length > 0 + : undefined; + } return ( - + + + - - + {/* Suspense contains the useSearchParams() CSR bailout so statically + prerendered pages don't fail the build (matches the auth layout). */} + + + + {/* Store uses boolean; gate receives tri-state to fail open on fetch errors. */} + + {onboardingEnabled && ( + <> + + {/* Single mount point so the watcher survives post-connect navigation. */} + + {/* Persistent banner shown only while a guided sequence is active. */} + + + )} {children} diff --git a/ui/app/(prowler)/providers/page.tsx b/ui/app/(prowler)/providers/page.tsx index 0ec08e5e11..e6186df10a 100644 --- a/ui/app/(prowler)/providers/page.tsx +++ b/ui/app/(prowler)/providers/page.tsx @@ -22,12 +22,22 @@ export default async function Providers({ const activeTab = getProviderTab(resolvedSearchParams.tab); const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"; - // Exclude `tab` from the Suspense key so switching tabs doesn't re-suspend - const { tab: _, ...paramsWithoutTab } = resolvedSearchParams || {}; - const searchParamsKey = JSON.stringify(paramsWithoutTab); + // Exclude `tab` and `onboarding` from the key: tab switches must not re-suspend, + // and `onboarding` is ephemeral (stripped via history.replaceState) — keeping it + // would remount ProvidersAccountsView and reset the wizard mid-flow. + const { + tab: _tab, + onboarding: _onboarding, + ...stableParams + } = resolvedSearchParams || {}; + const searchParamsKey = JSON.stringify(stableParams); return ( - + {isCloudEnvironment && } { return (
- {/* ProviderTypeSelector */} - {/* Organizations filter */} - {/* Provider Groups filter */} - {/* Status filter */} - {/* Action buttons */}
diff --git a/ui/app/(prowler)/providers/providers-page.utils.test.ts b/ui/app/(prowler)/providers/providers-page.utils.test.ts index 335b672af2..b5dcd6d844 100644 --- a/ui/app/(prowler)/providers/providers-page.utils.test.ts +++ b/ui/app/(prowler)/providers/providers-page.utils.test.ts @@ -14,16 +14,26 @@ const scansActionsMock = vi.hoisted(() => ({ getScans: vi.fn(), })); +const schedulesActionsMock = vi.hoisted(() => ({ + getSchedules: vi.fn(), +})); + vi.mock("@/actions/providers", () => providersActionsMock); vi.mock( "@/actions/organizations/organizations", () => organizationsActionsMock, ); vi.mock("@/actions/scans", () => scansActionsMock); +vi.mock("@/actions/schedules", () => schedulesActionsMock); import { SearchParamsProps } from "@/types"; import { ProvidersApiResponse } from "@/types/providers"; import { ProvidersProviderRow } from "@/types/providers-table"; +import { + SCHEDULE_FREQUENCY, + type ScheduleAttributes, + type ScheduleProps, +} from "@/types/schedules"; import { buildProvidersTableRows, @@ -156,6 +166,33 @@ const toProviderRow = ( }, }); +const buildSchedule = ( + providerId: string, + overrides: Partial = {}, +): ScheduleProps => ({ + type: "schedules", + id: providerId, + attributes: { + scan_enabled: true, + scan_frequency: SCHEDULE_FREQUENCY.DAILY, + scan_hour: 9, + scan_timezone: "Europe/Madrid", + scan_interval_hours: null, + scan_day_of_week: null, + scan_day_of_month: null, + ...overrides, + }, + relationships: { + provider: { data: { type: "providers", id: providerId } }, + }, +}); + +const findProviderRow = ( + rows: { id: string }[], + providerId: string, +): ProvidersProviderRow | undefined => + rows.find((row) => row.id === providerId) as ProvidersProviderRow | undefined; + describe("buildProvidersTableRows", () => { it("returns a flat providers table for OSS", () => { // Given @@ -751,4 +788,98 @@ describe("loadProvidersAccountsViewData", () => { viewData.rows.every((row) => row.rowType === PROVIDERS_ROW_TYPE.PROVIDER), ).toBe(true); }); + + it("surfaces the real cadence (not a hardcoded label) from a configured schedule with no materialized scan yet", async () => { + // Given — provider-1 has a WEEKLY schedule but the backend has not yet + // created a Scan row (the gap between configuring and the first fire). + providersActionsMock.getProviders.mockResolvedValue(providersResponse); + providersActionsMock.getAllProviders.mockResolvedValue(providersResponse); + scansActionsMock.getScans.mockResolvedValue({ data: [] }); + schedulesActionsMock.getSchedules.mockResolvedValue({ + data: [ + buildSchedule("provider-1", { + scan_frequency: SCHEDULE_FREQUENCY.WEEKLY, + scan_hour: 9, + scan_day_of_week: 1, + }), + ], + }); + + // When + const viewData = await loadProvidersAccountsViewData({ + searchParams: {} satisfies SearchParamsProps, + isCloud: false, + }); + + // Then — the row carries the Weekly cadence, not "Daily". + const providerRow = findProviderRow(viewData.rows, "provider-1"); + expect(providerRow?.hasSchedule).toBe(true); + expect(providerRow?.scheduleSummary?.cadence).toBe("Weekly on Monday"); + expect(findProviderRow(viewData.rows, "provider-2")?.hasSchedule).toBe( + false, + ); + expect( + findProviderRow(viewData.rows, "provider-2")?.scheduleSummary, + ).toBeUndefined(); + }); + + it("ignores paused or unconfigured schedules", async () => { + // Given — provider-1 paused (disabled), provider-2 never configured. + providersActionsMock.getProviders.mockResolvedValue(providersResponse); + providersActionsMock.getAllProviders.mockResolvedValue(providersResponse); + scansActionsMock.getScans.mockResolvedValue({ data: [] }); + schedulesActionsMock.getSchedules.mockResolvedValue({ + data: [ + buildSchedule("provider-1", { scan_enabled: false, scan_hour: 9 }), + buildSchedule("provider-2", { scan_enabled: true, scan_hour: null }), + ], + }); + + // When + const viewData = await loadProvidersAccountsViewData({ + searchParams: {} satisfies SearchParamsProps, + isCloud: false, + }); + + // Then + expect(findProviderRow(viewData.rows, "provider-1")?.hasSchedule).toBe( + false, + ); + expect(findProviderRow(viewData.rows, "provider-2")?.hasSchedule).toBe( + false, + ); + }); + + it("falls back to scan-based detection when /schedules is unavailable (OSS)", async () => { + // Given — /schedules errors, but provider-1 has a materialized scheduled scan. + providersActionsMock.getProviders.mockResolvedValue(providersResponse); + providersActionsMock.getAllProviders.mockResolvedValue(providersResponse); + scansActionsMock.getScans.mockResolvedValue({ + data: [ + { + type: "scans", + id: "scan-1", + attributes: { trigger: "scheduled", state: "scheduled" }, + relationships: { + provider: { data: { type: "providers", id: "provider-1" } }, + }, + }, + ], + }); + schedulesActionsMock.getSchedules.mockResolvedValue({ error: "Not found" }); + + // When + const viewData = await loadProvidersAccountsViewData({ + searchParams: {} satisfies SearchParamsProps, + isCloud: false, + }); + + // Then — scan-based path still flags the provider; no throw from the error. + expect(findProviderRow(viewData.rows, "provider-1")?.hasSchedule).toBe( + true, + ); + expect(findProviderRow(viewData.rows, "provider-2")?.hasSchedule).toBe( + false, + ); + }); }); diff --git a/ui/app/(prowler)/providers/providers-page.utils.ts b/ui/app/(prowler)/providers/providers-page.utils.ts index db79452709..a37d6eb510 100644 --- a/ui/app/(prowler)/providers/providers-page.utils.ts +++ b/ui/app/(prowler)/providers/providers-page.utils.ts @@ -4,10 +4,16 @@ import { } from "@/actions/organizations/organizations"; import { getAllProviders, getProviders } from "@/actions/providers"; import { getScans } from "@/actions/scans"; +import { getSchedules } from "@/actions/schedules"; import { extractFiltersAndQuery, extractSortAndKey, } from "@/lib/helper-filters"; +import { + buildProviderScheduleSummary, + buildSchedulesByProviderId, + isScheduleConfigured, +} from "@/lib/schedules"; import { FilterEntity, FilterOption, @@ -27,7 +33,8 @@ import { ProvidersTableRow, ProvidersTableRowsInput, } from "@/types/providers-table"; -import { SCAN_TRIGGER, ScanProps } from "@/types/scans"; +import { SCAN_TRIGGER, ScanProps, ScanScheduleSummary } from "@/types/scans"; +import { ScheduleAttributes } from "@/types/schedules"; const PROVIDERS_STATUS_MAPPING = [ { @@ -127,22 +134,46 @@ const buildScheduledProviderIds = (scans: ScanProps[]): Set => { return scheduled; }; +// A schedule is backed by the Provider row itself, so its `/schedules` entry +// exists before the first scheduled Scan is materialized — only enabled, +// configured ones carry a displayable cadence summary. +const buildProviderScheduleSummaryFor = ( + attributes: ScheduleAttributes | undefined, + now: Date, +): ScanScheduleSummary | undefined => + attributes && attributes.scan_enabled && isScheduleConfigured(attributes) + ? buildProviderScheduleSummary(attributes, now) + : undefined; + const enrichProviders = ( - providersResponse?: ProvidersApiResponse, - scheduledProviderIds?: Set, + providersResponse: ProvidersApiResponse | undefined, + scanScheduledProviderIds: Set, + schedulesByProviderId: Record, ): ProvidersProviderRow[] => { const providerGroupLookup = createProviderGroupLookup(providersResponse); + const now = new Date(); - return (providersResponse?.data ?? []).map((provider) => ({ - ...provider, - rowType: PROVIDERS_ROW_TYPE.PROVIDER, - groupNames: - provider.relationships.provider_groups.data.map( - (providerGroup: { id: string }) => - providerGroupLookup.get(providerGroup.id) ?? "Unknown Group", - ) ?? [], - hasSchedule: scheduledProviderIds?.has(provider.id) ?? false, - })); + return (providersResponse?.data ?? []).map((provider) => { + const scheduleSummary = buildProviderScheduleSummaryFor( + schedulesByProviderId[provider.id], + now, + ); + + return { + ...provider, + rowType: PROVIDERS_ROW_TYPE.PROVIDER, + groupNames: + provider.relationships.provider_groups.data.map( + (providerGroup: { id: string }) => + providerGroupLookup.get(providerGroup.id) ?? "Unknown Group", + ) ?? [], + // A fired scheduled scan OR a configured schedule that hasn't fired yet. + hasSchedule: + scanScheduledProviderIds.has(provider.id) || + scheduleSummary !== undefined, + scheduleSummary, + }; + }); }; const createOrganizationRow = ({ @@ -453,6 +484,7 @@ export async function loadProvidersAccountsViewData({ providersResponse, allProvidersResponse, scansResponse, + schedulesResponse, organizationsResponse, organizationUnitsResponse, ] = await Promise.all([ @@ -468,7 +500,7 @@ export async function loadProvidersAccountsViewData({ // Unfiltered fetch for ProviderTypeSelector — only needs distinct types; // TODO: Replace with a dedicated lightweight endpoint when available. resolveActionResult(getAllProviders()), - // Fetch active scheduled scans to determine daily schedule per provider + // Fetch active scheduled scans to flag providers whose schedule has fired. resolveActionResult( getScans({ pageSize: 500, @@ -478,6 +510,9 @@ export async function loadProvidersAccountsViewData({ }, }), ), + // Fetch configured schedules to also flag providers whose schedule has not + // fired yet (best-effort: absent in OSS, where the helper yields no ids). + resolveActionResult(getSchedules()), isCloud ? listOrganizationsSafe() : Promise.resolve(emptyOrganizationsResponse), @@ -486,13 +521,18 @@ export async function loadProvidersAccountsViewData({ : Promise.resolve(emptyOrganizationUnitsResponse), ]); - const scheduledProviderIds = buildScheduledProviderIds( + const scanScheduledProviderIds = buildScheduledProviderIds( scansResponse?.data ?? [], ); + const schedulesByProviderId = buildSchedulesByProviderId(schedulesResponse); const orgs = organizationsResponse?.data ?? []; const ous = organizationUnitsResponse?.data ?? []; - const providers = enrichProviders(providersResponse, scheduledProviderIds); + const providers = enrichProviders( + providersResponse, + scanScheduledProviderIds, + schedulesByProviderId, + ); const rows = buildProvidersTableRows({ isCloud, diff --git a/ui/app/(prowler)/scans/page.test.ts b/ui/app/(prowler)/scans/page.test.ts new file mode 100644 index 0000000000..3ea7bcfa7c --- /dev/null +++ b/ui/app/(prowler)/scans/page.test.ts @@ -0,0 +1,23 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +describe("scans page onboarding", () => { + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + const pagePath = path.join(currentDir, "page.tsx"); + const source = readFileSync(pagePath, "utf8"); + + it("redirects the scan tour replay to add-provider when providers are missing or disconnected", () => { + expect(source).toContain('redirect("/providers?onboarding=add-provider")'); + expect(source).toContain( + 'resolvedSearchParams.onboarding === "view-first-scan"', + ); + }); + + it("passes the scan onboarding action to the page header when the tour can run", () => { + expect(source).toContain('flowId: "view-first-scan"'); + expect(source).toContain("onboardingAction={onboardingAction}"); + }); +}); diff --git a/ui/app/(prowler)/scans/page.tsx b/ui/app/(prowler)/scans/page.tsx index 94f4ae0880..876dfa592e 100644 --- a/ui/app/(prowler)/scans/page.tsx +++ b/ui/app/(prowler)/scans/page.tsx @@ -1,11 +1,16 @@ +import { redirect } from "next/navigation"; import { Suspense } from "react"; import { getAllProviders } from "@/actions/providers"; import { getScans } from "@/actions/scans"; +import { getSchedules } from "@/actions/schedules"; import { auth } from "@/auth.config"; +import { PageReady } from "@/components/onboarding"; import { ScansPageShell } from "@/components/scans/scans-page-shell"; import { ScansProvidersEmptyState } from "@/components/scans/scans-providers-empty-state"; import { + appendPendingScheduleRowsToPage, + getProviderIdsFromScans, getScanJobsTab, getScanJobsTabFilters, getScanJobsUserFilters, @@ -13,14 +18,43 @@ import { import { SkeletonTableScans } from "@/components/scans/table"; import { ScanJobsTable } from "@/components/scans/table/scan-jobs-table"; import { ContentLayout } from "@/components/ui"; +import { + buildProviderScheduleSummary, + buildSchedulesByProviderId, + isScheduleConfigured, +} from "@/lib/schedules"; import { ProviderProps, SCAN_JOBS_TAB, + SCAN_TRIGGER, ScanProps, SearchParamsProps, } from "@/types"; const ACTIVE_SCAN_COUNT_PAGE_SIZE = 1; +// Pending schedule rows are derived from provider schedules, but must honor the +// same provider filters as real scan rows. Keep these filter keys typed locally +// without narrowing the global SearchParamsProps shape used by Next pages. +const PENDING_ROW_PROVIDER_FILTER = { + PROVIDER_UID_IN: "provider_uid__in", + PROVIDER_UID: "provider_uid", + PROVIDER_TYPE_IN: "provider_type__in", + PROVIDER_TYPE: "provider_type", +} as const; + +type PendingRowProviderFilter = + (typeof PENDING_ROW_PROVIDER_FILTER)[keyof typeof PENDING_ROW_PROVIDER_FILTER]; +type PendingRowProviderFilterParam = `filter[${PendingRowProviderFilter}]`; + +const PROVIDER_UID_FILTER_KEYS = [ + `filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_UID_IN}]`, + `filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_UID}]`, +] as const satisfies ReadonlyArray; + +const PROVIDER_TYPE_FILTER_KEYS = [ + `filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_TYPE_IN}]`, + `filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_TYPE}]`, +] as const satisfies ReadonlyArray; const getFilterSearchQuery = ( filters: Record, @@ -31,6 +65,47 @@ const getFilterSearchQuery = ( return value ?? ""; }; +const parseCsvParam = (value?: string | string[]): string[] => { + const rawValue = Array.isArray(value) ? value.join(",") : value; + if (!rawValue) return []; + + return rawValue + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +}; + +const getFirstSearchParam = ( + searchParams: SearchParamsProps, + keys: ReadonlyArray, +): string | string[] | undefined => { + for (const key of keys) { + const value = searchParams[key]; + if (value !== undefined) return value; + } + + return undefined; +}; + +/** Applies the table's provider filters to synthetic pending-schedule rows. */ +const filterProvidersForPendingRows = ( + providers: ProviderProps[], + searchParams: SearchParamsProps, +): ProviderProps[] => { + const uids = parseCsvParam( + getFirstSearchParam(searchParams, PROVIDER_UID_FILTER_KEYS), + ); + const types = parseCsvParam( + getFirstSearchParam(searchParams, PROVIDER_TYPE_FILTER_KEYS), + ); + + return providers.filter( + (provider) => + (uids.length === 0 || uids.includes(provider.attributes.uid)) && + (types.length === 0 || types.includes(provider.attributes.provider)), + ); +}; + const getActiveScanCount = async ( searchParams: SearchParamsProps, ): Promise => { @@ -51,6 +126,40 @@ const getActiveScanCount = async ( return scansData && "meta" in scansData ? scansData.meta.pagination.count : 0; }; +/** + * A provider can already have a real scheduled scan on a different page. + * Current-page rows are not enough to decide whether a schedule needs a + * synthetic Pending row, so fetch all scheduled scan provider ids when the + * backend paginated result is larger than the current slice. + */ +const getCoveredScheduledProviderIds = async ({ + currentScans, + realScanCount, + query, + filters, +}: { + currentScans: ScanProps[]; + realScanCount: number; + query: string; + filters: Record; +}): Promise> => { + if (realScanCount === 0 || currentScans.length === realScanCount) { + return getProviderIdsFromScans(currentScans); + } + + const allScheduledScansData = await getScans({ + query, + page: 1, + pageSize: realScanCount, + filters, + include: "provider", + }); + + return getProviderIdsFromScans( + (allScheduledScansData?.data ?? []) as ScanProps[], + ); +}; + export default async function Scans({ searchParams, }: { @@ -69,19 +178,45 @@ export default async function Scans({ const thereIsNoProviders = providers.length === 0; const thereIsNoProvidersConnected = !thereIsNoProviders && connectedProviders.length === 0; + const missingScanPrerequisite = + thereIsNoProviders || thereIsNoProvidersConnected; + + if ( + missingScanPrerequisite && + resolvedSearchParams.onboarding === "view-first-scan" + ) { + redirect("/providers?onboarding=add-provider"); + } const hasManageScansPermission = Boolean( session?.user?.permissions?.manage_scans, ); - const activeScanCount = - thereIsNoProviders || thereIsNoProvidersConnected - ? 0 - : await getActiveScanCount(resolvedSearchParams); + const activeScanCount = missingScanPrerequisite + ? 0 + : await getActiveScanCount(resolvedSearchParams); + const onboardingAction = missingScanPrerequisite + ? { + flowId: "view-first-scan", + fallbackFlowId: "add-provider", + useFallback: true, + } + : { flowId: "view-first-scan" }; return ( - - {thereIsNoProviders || thereIsNoProvidersConnected ? ( - + + {missingScanPrerequisite ? ( + <> + {/* The populated branch mounts inside ScansPageShell to + enable the navbar tour icon. The empty branch must mark the route + ready too, otherwise the icon (which falls back to the add-provider + flow here) stays hidden for users with no connected provider. */} + + + ) : ( } > - + )} @@ -105,8 +243,10 @@ export default async function Scans({ const SSRDataTableScans = async ({ searchParams, + providers, }: { searchParams: SearchParamsProps; + providers: ProviderProps[]; }) => { const tab = getScanJobsTab(searchParams.tab); @@ -142,7 +282,7 @@ const SSRDataTableScans = async ({ const included = scansData?.included; const meta = scansData && "meta" in scansData ? scansData.meta : undefined; - const expandedScansData = + const expandedScansData: ScanProps[] = scans?.map((scan: ScanProps) => { const providerId = scan.relationships?.provider?.data?.id; @@ -163,10 +303,60 @@ const SSRDataTableScans = async ({ }; }) || []; + const needsSchedules = + tab === SCAN_JOBS_TAB.SCHEDULED || + expandedScansData.some( + (scan) => scan.attributes.trigger === SCAN_TRIGGER.SCHEDULED, + ); + const schedulesResult = needsSchedules ? await getSchedules() : null; + + // Schedules are keyed by provider id so real scheduled scan rows can display + // cadence/next-run info, and schedule-only providers can become Pending rows. + const schedulesByProviderId = buildSchedulesByProviderId(schedulesResult); + + const scansWithSchedule = expandedScansData.map((scan) => { + if (scan.attributes.trigger !== SCAN_TRIGGER.SCHEDULED) return scan; + + const providerId = scan.relationships?.provider?.data?.id; + const schedule = providerId ? schedulesByProviderId[providerId] : undefined; + if (!schedule || !isScheduleConfigured(schedule)) return scan; + + return { + ...scan, + providerSchedule: buildProviderScheduleSummary(schedule, new Date()), + }; + }); + + let tableData = scansWithSchedule; + let tableMeta = meta; + if (tab === SCAN_JOBS_TAB.SCHEDULED) { + // The backend paginates real scans only. Pending schedule rows are generated + // client-side, so reconcile both sources before passing data/meta to the table. + const coveredProviderIds = await getCoveredScheduledProviderIds({ + currentScans: scansWithSchedule, + realScanCount: meta?.pagination?.count ?? scansWithSchedule.length, + query, + filters, + }); + const scheduledTable = appendPendingScheduleRowsToPage({ + scans: scansWithSchedule, + meta, + page, + pageSize, + providers: filterProvidersForPendingRows(providers, searchParams), + schedulesByProviderId, + coveredProviderIds, + now: new Date(), + }); + + tableData = scheduledTable.data; + tableMeta = scheduledTable.meta; + } + return ( diff --git a/ui/app/(prowler)/users/page.tsx b/ui/app/(prowler)/users/page.tsx index 4b26c0baf2..fa1e838872 100644 --- a/ui/app/(prowler)/users/page.tsx +++ b/ui/app/(prowler)/users/page.tsx @@ -109,6 +109,9 @@ const SSRDataTable = async ({ roles, canBeExpelled, currentTenantId: canBeExpelled ? currentTenantId : undefined, + // Users may only delete their own account; gate the delete action so the + // UI matches the backend rule and never offers an action that would fail. + isCurrentUser: user.id === currentUserId, }; }); diff --git a/ui/app/instrumentation.client.ts b/ui/app/instrumentation.client.ts deleted file mode 100644 index a9abf3f345..0000000000 --- a/ui/app/instrumentation.client.ts +++ /dev/null @@ -1,121 +0,0 @@ -"use client"; - -/** - * Client-side Sentry instrumentation - * - * This file is automatically loaded by Next.js in the browser via the instrumentation hook. - * It configures Sentry for client-side error tracking and performance monitoring. - * - * For server-side configuration, see: instrumentation.ts - * For runtime-specific configs, see: sentry/sentry.server.config.ts and sentry/sentry.edge.config.ts - */ - -import * as Sentry from "@sentry/nextjs"; - -const SENTRY_DSN = process.env.NEXT_PUBLIC_SENTRY_DSN; - -// Only initialize Sentry in the browser (not during SSR) -if (typeof window !== "undefined" && SENTRY_DSN) { - const isDevelopment = process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT === "local"; - - /** - * Initialize Sentry error tracking and performance monitoring - * - * This setup includes: - * - Performance monitoring with Web Vitals tracking (LCP, FID, CLS, INP) - * - Long task detection for UI-blocking operations - * - beforeSend hook to filter noise - */ - Sentry.init({ - // 📍 DSN - Data Source Name (identifies your Sentry project) - dsn: SENTRY_DSN, - - // 🌍 Environment - Separate dev errors from production - environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT || "local", - - // 📦 Release - Track which version has the error - release: process.env.NEXT_PUBLIC_PROWLER_RELEASE_VERSION, - - // 🐛 Debug - Detailed logs in development console - debug: isDevelopment, - - // 📊 Sample Rates - Performance monitoring - // 100% in dev (test everything), 50% in production (balance visibility with costs) - tracesSampleRate: isDevelopment ? 1.0 : 0.5, - profilesSampleRate: isDevelopment ? 1.0 : 0.5, - - // 🔌 Integrations - browserTracingIntegration is client-only - integrations: [ - // 📊 Performance Monitoring: Core Web Vitals + RUM - // Tracks LCP, FID, CLS, INP - // Real User Monitoring captures actual user experience, not synthetic tests - Sentry.browserTracingIntegration({ - enableLongTask: true, // Detect tasks that block UI (>50ms) - enableInp: true, // Interaction to Next Paint (Core Web Vital) - }), - ], - - // 🎣 beforeSend Hook - Filter or modify events before sending to Sentry - ignoreErrors: [ - // Browser extensions - "top.GLOBALS", - // Random network errors - "Network request failed", - "NetworkError", - "Failed to fetch", - // User canceled actions - "AbortError", - "Non-Error promise rejection captured", - // NextAuth expected errors - "NEXT_REDIRECT", - // ResizeObserver errors (common browser quirk, not real bugs) - "ResizeObserver", - ], - - beforeSend(event, hint) { - // Filter out noise: ResizeObserver errors (common browser quirk, not real bugs) - if (event.message?.includes("ResizeObserver")) { - return null; // Don't send to Sentry - } - - // Filter out non-actionable errors - if (event.exception) { - const error = hint.originalException; - - // Don't send cancelled requests - if ( - error && - typeof error === "object" && - "name" in error && - error.name === "AbortError" - ) { - return null; - } - - // Add additional context for API errors - if ( - error && - typeof error === "object" && - "message" in error && - typeof error.message === "string" && - error.message.includes("Request failed") - ) { - event.tags = { - ...event.tags, - error_type: "api_error", - }; - } - } - - return event; // Send to Sentry - }, - }); - - // 👤 Set user context (identifies who experienced the error) - // In production, this will be updated after authentication - if (isDevelopment) { - Sentry.setUser({ - id: "dev-user", - }); - } -} diff --git a/ui/app/providers.tsx b/ui/app/providers.tsx index 7bda5d45fe..41157df06c 100644 --- a/ui/app/providers.tsx +++ b/ui/app/providers.tsx @@ -1,8 +1,5 @@ "use client"; -// Import Sentry client-side initialization -import "@/app/instrumentation.client"; - import { HeroUIProvider } from "@heroui/system"; import { useRouter } from "next/navigation"; import { SessionProvider } from "next-auth/react"; diff --git a/ui/components/compliance/compliance-card.tsx b/ui/components/compliance/compliance-card.tsx index 82b96e0d9c..f22a2a403a 100644 --- a/ui/components/compliance/compliance-card.tsx +++ b/ui/components/compliance/compliance-card.tsx @@ -10,6 +10,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/shadcn/tooltip"; +import { buildComplianceDetailPath } from "@/lib/compliance/compliance-detail-url"; import { getReportTypeForCompliance } from "@/lib/compliance/compliance-report-types"; import { getScoreIndicatorClass, @@ -70,20 +71,15 @@ export const ComplianceCard: React.FC = ({ }; const navigateToDetail = () => { - const formattedTitleForUrl = encodeURIComponent(title); - const path = `/compliance/${formattedTitleForUrl}`; - const params = new URLSearchParams(); - - params.set("complianceId", id); - params.set("version", version); - params.set("scanId", scanId); - - const regionFilter = searchParams.get("filter[region__in]"); - if (regionFilter) { - params.set("filter[region__in]", regionFilter); - } - - router.push(`${path}?${params.toString()}`); + router.push( + buildComplianceDetailPath({ + title, + complianceId: id, + version, + scanId, + regionFilter: searchParams.get("filter[region__in]"), + }), + ); }; return ( diff --git a/ui/components/compliance/compliance-custom-details/asd-essential-eight-details.tsx b/ui/components/compliance/compliance-custom-details/asd-essential-eight-details.tsx index 363eba2b10..2cb022a174 100644 --- a/ui/components/compliance/compliance-custom-details/asd-essential-eight-details.tsx +++ b/ui/components/compliance/compliance-custom-details/asd-essential-eight-details.tsx @@ -85,7 +85,7 @@ export const ASDEssentialEightCustomDetails = ({ )} @@ -93,7 +93,7 @@ export const ASDEssentialEightCustomDetails = ({ )} @@ -101,7 +101,7 @@ export const ASDEssentialEightCustomDetails = ({ )} diff --git a/ui/components/compliance/compliance-custom-details/aws-well-architected-details.tsx b/ui/components/compliance/compliance-custom-details/aws-well-architected-details.tsx index 96ed16a8f1..00aa51e4c5 100644 --- a/ui/components/compliance/compliance-custom-details/aws-well-architected-details.tsx +++ b/ui/components/compliance/compliance-custom-details/aws-well-architected-details.tsx @@ -52,7 +52,7 @@ export const AWSWellArchitectedCustomDetails = ({ )} @@ -60,7 +60,7 @@ export const AWSWellArchitectedCustomDetails = ({ )} @@ -68,7 +68,7 @@ export const AWSWellArchitectedCustomDetails = ({ )} diff --git a/ui/components/compliance/compliance-custom-details/ccc-details.tsx b/ui/components/compliance/compliance-custom-details/ccc-details.tsx index 309358264d..e3db174280 100644 --- a/ui/components/compliance/compliance-custom-details/ccc-details.tsx +++ b/ui/components/compliance/compliance-custom-details/ccc-details.tsx @@ -1,4 +1,4 @@ -import { cn } from "@/lib"; +import { Badge } from "@/components/shadcn/badge/badge"; import { CCC_MAPPING_SECTIONS, CCC_TEXT_SECTIONS } from "@/lib/compliance/ccc"; import { Requirement } from "@/types/compliance"; @@ -46,7 +46,7 @@ export const CCCCustomDetails = ({ requirement }: CCCDetailsProps) => { )} @@ -68,15 +68,9 @@ export const CCCCustomDetails = ({ requirement }: CCCDetailsProps) => {
{mapping.Identifiers.map((identifier, idx) => ( - + {identifier} - + ))}
diff --git a/ui/components/compliance/compliance-custom-details/cis-details.tsx b/ui/components/compliance/compliance-custom-details/cis-details.tsx index c46f061188..25823038d5 100644 --- a/ui/components/compliance/compliance-custom-details/cis-details.tsx +++ b/ui/components/compliance/compliance-custom-details/cis-details.tsx @@ -41,7 +41,7 @@ export const CISCustomDetails = ({ requirement }: CISDetailsProps) => { )} @@ -49,7 +49,7 @@ export const CISCustomDetails = ({ requirement }: CISDetailsProps) => { )} diff --git a/ui/components/compliance/compliance-custom-details/csa-details.tsx b/ui/components/compliance/compliance-custom-details/csa-details.tsx index 738e4a8aab..81dbcc158a 100644 --- a/ui/components/compliance/compliance-custom-details/csa-details.tsx +++ b/ui/components/compliance/compliance-custom-details/csa-details.tsx @@ -1,4 +1,4 @@ -import { cn } from "@/lib"; +import { Badge } from "@/components/shadcn/badge/badge"; import { CSA_MAPPING_SECTIONS } from "@/lib/compliance/csa"; import { Requirement } from "@/types/compliance"; @@ -36,28 +36,28 @@ export const CSACustomDetails = ({ requirement }: CSADetailsProps) => { )} {requirement.iaas && ( )} {requirement.paas && ( )} {requirement.saas && ( )} @@ -72,15 +72,9 @@ export const CSACustomDetails = ({ requirement }: CSADetailsProps) => {
{mapping.Identifiers.map((identifier, idx) => ( - + {identifier} - + ))}
diff --git a/ui/components/compliance/compliance-custom-details/dora-details.tsx b/ui/components/compliance/compliance-custom-details/dora-details.tsx new file mode 100644 index 0000000000..f27412be5b --- /dev/null +++ b/ui/components/compliance/compliance-custom-details/dora-details.tsx @@ -0,0 +1,49 @@ +import { Requirement } from "@/types/compliance"; + +import { + ComplianceBadge, + ComplianceBadgeContainer, + ComplianceDetailContainer, + ComplianceDetailSection, + ComplianceDetailText, +} from "./shared-components"; + +interface DORADetailsProps { + requirement: Requirement; +} + +export const DORACustomDetails = ({ requirement }: DORADetailsProps) => { + return ( + + {requirement.description && ( + + {requirement.description} + + )} + + + {requirement.pillar && ( + + )} + {requirement.article && ( + + )} + {requirement.article_title && ( + + )} + + + ); +}; diff --git a/ui/components/compliance/compliance-custom-details/ens-details.tsx b/ui/components/compliance/compliance-custom-details/ens-details.tsx index 9e947cea14..2950c495cd 100644 --- a/ui/components/compliance/compliance-custom-details/ens-details.tsx +++ b/ui/components/compliance/compliance-custom-details/ens-details.tsx @@ -28,7 +28,7 @@ export const ENSCustomDetails = ({ )} @@ -36,7 +36,7 @@ export const ENSCustomDetails = ({ )} diff --git a/ui/components/compliance/compliance-custom-details/generic-details.tsx b/ui/components/compliance/compliance-custom-details/generic-details.tsx index caa3fec05d..323e3d9a99 100644 --- a/ui/components/compliance/compliance-custom-details/generic-details.tsx +++ b/ui/components/compliance/compliance-custom-details/generic-details.tsx @@ -26,7 +26,7 @@ export const GenericCustomDetails = ({ )} @@ -34,7 +34,7 @@ export const GenericCustomDetails = ({ )} @@ -42,7 +42,7 @@ export const GenericCustomDetails = ({ )} diff --git a/ui/components/compliance/compliance-custom-details/mitre-details.tsx b/ui/components/compliance/compliance-custom-details/mitre-details.tsx index 8fa7fa276d..53fc583f2c 100644 --- a/ui/components/compliance/compliance-custom-details/mitre-details.tsx +++ b/ui/components/compliance/compliance-custom-details/mitre-details.tsx @@ -37,7 +37,7 @@ export const MITRECustomDetails = ({ )} @@ -81,17 +81,17 @@ export const MITRECustomDetails = ({
{service.comment && ( diff --git a/ui/components/compliance/compliance-custom-details/okta-idaas-stig-details.tsx b/ui/components/compliance/compliance-custom-details/okta-idaas-stig-details.tsx new file mode 100644 index 0000000000..0869b7f390 --- /dev/null +++ b/ui/components/compliance/compliance-custom-details/okta-idaas-stig-details.tsx @@ -0,0 +1,65 @@ +import { Severity, SeverityBadge } from "@/components/ui/table"; +import { Requirement } from "@/types/compliance"; + +import { + ComplianceBadge, + ComplianceBadgeContainer, + ComplianceChipContainer, + ComplianceDetailContainer, + ComplianceDetailSection, + ComplianceDetailText, +} from "./shared-components"; + +export const OktaIDaaSStigCustomDetails = ({ + requirement, +}: { + requirement: Requirement; +}) => { + const severity = requirement.severity as string | undefined; + const stigId = requirement.stig_id as string | undefined; + const ruleId = requirement.rule_id as string | undefined; + const cci = requirement.cci as string[] | undefined; + const checkText = requirement.check_text as string | undefined; + const fixText = requirement.fix_text as string | undefined; + + return ( + + + {severity && ( +
+ + Severity: + + +
+ )} + {stigId && ( + + )} + {ruleId && ( + + )} +
+ + {requirement.description && ( + + {requirement.description} + + )} + + + + {checkText && ( + + {checkText} + + )} + + {fixText && ( + + {fixText} + + )} +
+ ); +}; diff --git a/ui/components/compliance/compliance-custom-details/shared-components.tsx b/ui/components/compliance/compliance-custom-details/shared-components.tsx index d5c69499af..22ba330d57 100644 --- a/ui/components/compliance/compliance-custom-details/shared-components.tsx +++ b/ui/components/compliance/compliance-custom-details/shared-components.tsx @@ -1,4 +1,12 @@ -import { cn } from "@/lib/utils"; +import { VariantProps } from "class-variance-authority"; + +import { Badge, badgeVariants } from "@/components/shadcn/badge/badge"; + +// Variants come straight from the canonical shadcn Badge so compliance panels +// share the same badge vocabulary (and tokens) as the rest of the app. +export type ComplianceBadgeVariant = NonNullable< + VariantProps["variant"] +>; export const ComplianceDetailContainer = ({ children, @@ -43,55 +51,28 @@ export const ComplianceBadgeContainer = ({ return
{children}
; }; -type BadgeColor = - | "red" // Risk/Level/Severity - | "blue" // Assessment/Method - | "orange" // Type/Category - | "green" // Weight/Score (positive) - | "purple" // Profile - | "indigo" // IDs/References - | "gray"; // Additional Info/Neutral - export const ComplianceBadge = ({ label, value, - color, + variant, conditional = false, }: { label: string; value: string | number; - color: BadgeColor; + variant: ComplianceBadgeVariant; conditional?: boolean; }) => { - const actualColor = conditional && Number(value) === 0 ? "gray" : color; - - const colorClasses = { - red: "bg-red-50 text-red-700 ring-red-600/10 dark:bg-red-400/10 dark:text-red-400 dark:ring-red-400/20", - blue: "bg-blue-50 text-blue-700 ring-blue-600/10 dark:bg-blue-400/10 dark:text-blue-400 dark:ring-blue-400/20", - orange: - "bg-orange-50 text-orange-700 ring-orange-600/10 dark:bg-orange-400/10 dark:text-orange-400 dark:ring-orange-400/20", - green: - "bg-green-50 text-green-700 ring-green-600/10 dark:bg-green-400/10 dark:text-green-400 dark:ring-green-400/20", - purple: - "bg-purple-50 text-purple-700 ring-purple-600/10 dark:bg-purple-400/10 dark:text-purple-400 dark:ring-purple-400/20", - indigo: - "bg-indigo-50 text-indigo-700 ring-indigo-600/10 dark:bg-indigo-400/10 dark:text-indigo-400 dark:ring-indigo-400/20", - gray: "bg-gray-50 text-gray-600 ring-gray-500/10 dark:bg-gray-400/10 dark:text-gray-400 dark:ring-gray-400/20", - }; + // A "conditional" metric badge with a zero value drops to a neutral variant + // so empty scores don't read as a meaningful (e.g. positive) result. + const actualVariant: ComplianceBadgeVariant = + conditional && Number(value) === 0 ? "secondary" : variant; return (
{label}: - - {value} - + {value}
); }; @@ -132,12 +113,9 @@ export const ComplianceChipContainer = ({
{items.map((item: string, index: number) => ( - + {item} - + ))}
diff --git a/ui/components/compliance/compliance-custom-details/threat-details.tsx b/ui/components/compliance/compliance-custom-details/threat-details.tsx index 822612234d..542583ff0e 100644 --- a/ui/components/compliance/compliance-custom-details/threat-details.tsx +++ b/ui/components/compliance/compliance-custom-details/threat-details.tsx @@ -34,7 +34,7 @@ export const ThreatCustomDetails = ({ )} @@ -42,7 +42,7 @@ export const ThreatCustomDetails = ({ )} @@ -50,7 +50,7 @@ export const ThreatCustomDetails = ({ )} @@ -61,13 +61,13 @@ export const ThreatCustomDetails = ({ {requirement.totalFindings > 0 && ( )} diff --git a/ui/components/compliance/compliance-download-container.test.tsx b/ui/components/compliance/compliance-download-container.test.tsx index 81774b686f..31c68fb1fc 100644 --- a/ui/components/compliance/compliance-download-container.test.tsx +++ b/ui/components/compliance/compliance-download-container.test.tsx @@ -6,15 +6,19 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const { downloadComplianceCsvMock, downloadCompliancePdfMock } = vi.hoisted( - () => ({ - downloadComplianceCsvMock: vi.fn(), - downloadCompliancePdfMock: vi.fn(), - }), -); +const { + downloadComplianceCsvMock, + downloadComplianceOcsfMock, + downloadCompliancePdfMock, +} = vi.hoisted(() => ({ + downloadComplianceCsvMock: vi.fn(), + downloadComplianceOcsfMock: vi.fn(), + downloadCompliancePdfMock: vi.fn(), +})); vi.mock("@/lib/helper", () => ({ downloadComplianceCsv: downloadComplianceCsvMock, + downloadComplianceOcsf: downloadComplianceOcsfMock, downloadCompliancePdf: downloadCompliancePdfMock, })); @@ -131,4 +135,51 @@ describe("ComplianceDownloadContainer", () => { {}, ); }); + + it("should hide the OCSF action for frameworks without OCSF support", async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.click( + screen.getByRole("button", { name: "Open compliance export actions" }), + ); + + expect(screen.queryByText("Download OCSF report")).not.toBeInTheDocument(); + }); + + it("should surface and trigger the OCSF download for universal frameworks", async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.click( + screen.getByRole("button", { name: "Open compliance export actions" }), + ); + expect(screen.getByText("Download OCSF report")).toBeInTheDocument(); + + await user.click( + screen.getByRole("menuitem", { name: /Download OCSF report/i }), + ); + + expect(downloadComplianceOcsfMock).toHaveBeenCalledWith( + "scan-1", + "dora_2022_2554", + {}, + ); + }); }); diff --git a/ui/components/compliance/compliance-download-container.tsx b/ui/components/compliance/compliance-download-container.tsx index 6d2e0ddfee..6e29acc213 100644 --- a/ui/components/compliance/compliance-download-container.tsx +++ b/ui/components/compliance/compliance-download-container.tsx @@ -1,6 +1,6 @@ "use client"; -import { DownloadIcon, FileTextIcon } from "lucide-react"; +import { DownloadIcon, FileJsonIcon, FileTextIcon } from "lucide-react"; import { useState } from "react"; import { Button } from "@/components/shadcn/button/button"; @@ -14,8 +14,15 @@ import { TooltipTrigger, } from "@/components/shadcn/tooltip"; import { toast } from "@/components/ui"; -import type { ComplianceReportType } from "@/lib/compliance/compliance-report-types"; -import { downloadComplianceCsv, downloadCompliancePdf } from "@/lib/helper"; +import { + type ComplianceReportType, + isOcsfSupported, +} from "@/lib/compliance/compliance-report-types"; +import { + downloadComplianceCsv, + downloadComplianceOcsf, + downloadCompliancePdf, +} from "@/lib/helper"; import { cn } from "@/lib/utils"; interface ComplianceDownloadContainerProps { @@ -40,9 +47,14 @@ export const ComplianceDownloadContainer = ({ presentation = "buttons", }: ComplianceDownloadContainerProps) => { const [isDownloadingCsv, setIsDownloadingCsv] = useState(false); + const [isDownloadingOcsf, setIsDownloadingOcsf] = useState(false); const [isDownloadingPdf, setIsDownloadingPdf] = useState(false); const isIconWidth = buttonWidth === "icon"; const isDropdown = presentation === "dropdown"; + // Only universal frameworks declaring an ``outputs`` block expose a + // per-framework OCSF artifact (today: DORA, CSA CCM 4.0). Hide the + // action everywhere else so the user never hits a guaranteed 404. + const ocsfAvailable = isOcsfSupported(complianceId); const handleDownloadCsv = async () => { if (isDownloadingCsv) return; @@ -54,6 +66,16 @@ export const ComplianceDownloadContainer = ({ } }; + const handleDownloadOcsf = async () => { + if (!ocsfAvailable || isDownloadingOcsf) return; + setIsDownloadingOcsf(true); + try { + await downloadComplianceOcsf(scanId, complianceId, toast); + } finally { + setIsDownloadingOcsf(false); + } + }; + const handleDownloadPdf = async () => { if (!reportType || isDownloadingPdf) return; setIsDownloadingPdf(true); @@ -105,6 +127,18 @@ export const ComplianceDownloadContainer = ({ onSelect={handleDownloadCsv} disabled={disabled || isDownloadingCsv} /> + {ocsfAvailable && ( + + } + label="Download OCSF report" + onSelect={handleDownloadOcsf} + disabled={disabled || isDownloadingOcsf} + /> + )} {reportType && ( Download CSV report )} + {ocsfAvailable && ( + + + + + {showTooltip && ( + Download OCSF report + )} + + )} {reportType && ( diff --git a/ui/components/compliance/compliance-overview-grid.tsx b/ui/components/compliance/compliance-overview-grid.tsx index 280465520a..b042ccc658 100644 --- a/ui/components/compliance/compliance-overview-grid.tsx +++ b/ui/components/compliance/compliance-overview-grid.tsx @@ -1,12 +1,26 @@ "use client"; -import { useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Suspense, useState } from "react"; import { ComplianceCard } from "@/components/compliance/compliance-card"; +import { OnboardingTrigger, PageReady } from "@/components/onboarding"; import { DataTableSearch } from "@/components/ui/table/data-table-search"; +import { buildComplianceDetailPath } from "@/lib/compliance/compliance-detail-url"; +import { getFlowById } from "@/lib/onboarding"; +import { createViewComplianceTourStepHandlers } from "@/lib/tours/view-compliance.tour"; import type { ComplianceOverviewData } from "@/types/compliance"; import type { ScanEntity } from "@/types/scans"; +const viewComplianceFlow = getFlowById("view-compliance")!; + +// Module-level so the identity is stable: `configOverrides` is an effect dependency in +// `useDriverTour`, and a fresh object per keystroke would tear the tour down mid-typing. +const VIEW_COMPLIANCE_TOUR_CONFIG = { + // Last step opens the first card (see createViewComplianceTourStepHandlers). + doneBtnText: "Open Compliance", +}; + interface ComplianceOverviewGridProps { frameworks: ComplianceOverviewData[]; scanId: string; @@ -25,6 +39,8 @@ export const ComplianceOverviewGrid = ({ selectedScan, latestCisIds, }: ComplianceOverviewGridProps) => { + const router = useRouter(); + const searchParams = useSearchParams(); const [searchTerm, setSearchTerm] = useState(""); const filteredFrameworks = frameworks.filter((compliance) => @@ -33,20 +49,54 @@ export const ComplianceOverviewGrid = ({ .includes(searchTerm.toLowerCase()), ); + const resetSearch = () => { + setSearchTerm(""); + return frameworks.length > 0; + }; + + const openFirstFramework = () => { + const first = frameworks[0]; + if (!first) return; + router.push( + buildComplianceDetailPath({ + title: first.attributes.framework, + complianceId: first.id, + version: first.attributes.version, + scanId, + regionFilter: searchParams.get("filter[region__in]"), + }), + ); + }; + return ( <> -
- + + + {/* Signals the navbar that this route's data has loaded (enables the replay icon). */} + +
+
+ +
{filteredFrameworks.length.toLocaleString()} Total Entries
- {filteredFrameworks.map((compliance) => { + {filteredFrameworks.map((compliance, index) => { const { attributes, id } = compliance; const { framework, @@ -55,9 +105,8 @@ export const ComplianceOverviewGrid = ({ total_requirements, } = attributes; - return ( + const card = ( ); + + // Anchor the tour to a single card, not the whole grid: highlighting the + // grid lit up the entire viewport and scrolled the page to the bottom. + return index === 0 ? ( +
+ {card} +
+ ) : ( +
+ {card} +
+ ); })}
diff --git a/ui/components/compliance/compliance-warming.tsx b/ui/components/compliance/compliance-warming.tsx new file mode 100644 index 0000000000..19864ae121 --- /dev/null +++ b/ui/components/compliance/compliance-warming.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { Icon } from "@iconify/react"; +import { useRouter } from "next/navigation"; + +import { Button } from "@/components/shadcn/button/button"; +import { Card, CardContent } from "@/components/shadcn/card/card"; + +export const ComplianceWarming = () => { + const router = useRouter(); + + return ( +
+
+ + +
+
+ +
+

+ Compliance data is still loading +

+

+ This can happen for a few seconds right after an update. + Please try again shortly. +

+
+
+ +
+
+
+
+
+ ); +}; diff --git a/ui/components/compliance/index.ts b/ui/components/compliance/index.ts index e0bd631200..caea32e264 100644 --- a/ui/components/compliance/index.ts +++ b/ui/components/compliance/index.ts @@ -19,6 +19,7 @@ export * from "./compliance-header/compliance-scan-info"; export * from "./compliance-header/data-compliance"; export * from "./compliance-header/scan-selector"; export * from "./compliance-overview-grid"; +export * from "./compliance-warming"; export * from "./no-scans-available"; export * from "./skeletons/bar-chart-skeleton"; export * from "./skeletons/compliance-accordion-skeleton"; diff --git a/ui/components/findings/findings-filters.tsx b/ui/components/findings/findings-filters.tsx index 02a3aec62d..349769f3ad 100644 --- a/ui/components/findings/findings-filters.tsx +++ b/ui/components/findings/findings-filters.tsx @@ -95,7 +95,6 @@ export const FindingsFilterBatchControls = ({ const [isExpanded, setIsExpanded] = useState(false); const isAlertsEdit = variant === "alerts-edit"; - // Custom filters for the expandable section. const customFilters = [ ...filterFindings .filter((filter) => !isAlertsEdit || filter.key !== FilterType.STATUS) @@ -182,8 +181,6 @@ export const FindingsFilterBatchControls = ({ const showAppliedRow = appliedFilterChips.length > 0; const showPendingRow = hasChanges; - // Handler for removing a single chip: update the pending filter to remove that value. - // setPending handles both "filter[key]" and "key" formats internally. const handleChipRemove = (filterKey: string, value?: string) => { if (value === undefined) { setPending(filterKey, []); @@ -195,7 +192,6 @@ export const FindingsFilterBatchControls = ({ setPending(filterKey, nextValues); }; - // For the date picker, read from pendingFilters const pendingDateValues = pendingFilters["filter[inserted_at]"]; const pendingDateValue = pendingDateValues && pendingDateValues.length > 0 @@ -333,19 +329,21 @@ export const FindingsFilters = (props: FindingsFiltersProps) => { }); return ( - +
+ +
); }; diff --git a/ui/components/findings/table/findings-group-table.test.tsx b/ui/components/findings/table/findings-group-table.test.tsx index 38887bf1fb..411f7a64f7 100644 --- a/ui/components/findings/table/findings-group-table.test.tsx +++ b/ui/components/findings/table/findings-group-table.test.tsx @@ -9,17 +9,47 @@ vi.mock("next/navigation", () => ({ refresh: vi.fn(), }), useSearchParams: () => new URLSearchParams(), + usePathname: () => "/findings", })); vi.mock("@/components/ui/table", () => ({ - DataTable: ({ toolbarRightContent }: { toolbarRightContent?: ReactNode }) => ( + DataTable: ({ + data, + toolbarRightContent, + getRowAttributes, + }: { + data?: Array<{ checkId?: string }>; + toolbarRightContent?: ReactNode; + getRowAttributes?: (row: { + index: number; + original: { checkId?: string }; + }) => Record; + }) => (
{toolbarRightContent}
10 Total Entries + + + {(data ?? []).map((original, index) => ( + + + + ))} + +
{original.checkId}
), })); +vi.mock("@/components/onboarding", () => ({ + OnboardingTrigger: () =>
, + PageReady: () =>
, +})); + vi.mock("@/components/filters/custom-checkbox-muted-findings", () => ({ CustomCheckboxMutedFindings: () => (